diff --git a/Gemfile b/Gemfile
index 66bb164..f14786a 100644
--- a/Gemfile
+++ b/Gemfile
@@ -54,7 +54,7 @@ group :development, :test do
gem "pry-rails"
# Static analysis for security vulnerabilities [https://brakemanscanner.org/]
- gem "brakeman", "~> 8.0.2", require: false
+ gem "brakeman", "~> 8.0.4", require: false
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
@@ -72,4 +72,8 @@ group :test do
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
gem "capybara"
gem "selenium-webdriver"
+
+ # RSpec test helpers
+ gem "shoulda-matchers", "~> 6.0"
+ gem "factory_bot_rails"
end
diff --git a/Gemfile.lock b/Gemfile.lock
index c4f0854..630a5d2 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -82,7 +82,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.6)
msgpack (~> 1.2)
- brakeman (8.0.2)
+ brakeman (8.0.4)
racc
builder (3.3.0)
capybara (3.40.0)
@@ -111,6 +111,11 @@ GEM
erubi (1.13.1)
et-orbi (1.4.0)
tzinfo
+ factory_bot (6.5.6)
+ activesupport (>= 6.1.0)
+ factory_bot_rails (6.5.1)
+ factory_bot (~> 6.5)
+ railties (>= 6.1.0)
fugit (1.11.2)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
@@ -325,6 +330,8 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 4.0)
websocket (~> 1.0)
+ shoulda-matchers (6.5.0)
+ activesupport (>= 5.2.0)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -396,10 +403,11 @@ PLATFORMS
DEPENDENCIES
bootsnap
- brakeman (~> 8.0.2)
+ brakeman (~> 8.0.4)
capybara
csv
debug
+ factory_bot_rails
hirb
importmap-rails
jbuilder
@@ -412,6 +420,7 @@ DEPENDENCIES
rspec-rails
rubocop-rails-omakase
selenium-webdriver
+ shoulda-matchers (~> 6.0)
solid_cable
solid_cache
solid_queue
diff --git a/app/controllers/companies_controller.rb b/app/controllers/companies_controller.rb
index 728e7cf..b953148 100644
--- a/app/controllers/companies_controller.rb
+++ b/app/controllers/companies_controller.rb
@@ -99,6 +99,11 @@ def set_company
# Only allow a list of trusted parameters through.
def company_params
- params.expect(company: [ :name, :industry, :company_type, :location, :size, :website, :linkedin, :known_tech_stack ])
+ params.expect(company: [
+ :name, :industry, :company_type, :location, :size, :website, :linkedin, :known_tech_stack,
+ :primary_product, :revenue_model, :funding_stage, :estimated_revenue, :estimated_employees,
+ :growth_signal, :product_maturity, :engineering_maturity, :process_maturity,
+ :market_position, :competitor_tier, :brand_signal_strength, :market_size_estimate
+ ])
end
end
diff --git a/app/controllers/interview_sessions_controller.rb b/app/controllers/interview_sessions_controller.rb
new file mode 100644
index 0000000..8f3f7f4
--- /dev/null
+++ b/app/controllers/interview_sessions_controller.rb
@@ -0,0 +1,53 @@
+class InterviewSessionsController < ApplicationController
+ before_action :set_interview_session, only: [ :show, :edit, :update, :destroy ]
+
+ def index
+ @interview_sessions = InterviewSession.all.includes(:opportunity).order(date: :desc)
+ end
+
+ def show
+ end
+
+ def new
+ @interview_session = InterviewSession.new
+ end
+
+ def create
+ @interview_session = InterviewSession.new(interview_session_params)
+
+ if @interview_session.save
+ redirect_to @interview_session, notice: "Interview session was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ if @interview_session.update(interview_session_params)
+ redirect_to @interview_session, notice: "Interview session was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @interview_session.destroy
+ redirect_to interview_sessions_url, notice: "Interview session was successfully deleted."
+ end
+
+ private
+
+ def set_interview_session
+ @interview_session = InterviewSession.find(params[:id])
+ end
+
+ def interview_session_params
+ params.require(:interview_session).permit(
+ :opportunity_id, :stage, :date, :confidence_score, :clarity_score,
+ :questions_asked, :weak_areas, :strong_areas, :follow_up, :overall_signal
+ )
+ end
+end
diff --git a/app/controllers/opportunities_controller.rb b/app/controllers/opportunities_controller.rb
index f0a8c0e..3d395d0 100644
--- a/app/controllers/opportunities_controller.rb
+++ b/app/controllers/opportunities_controller.rb
@@ -25,7 +25,7 @@ def index
sort_direction = params[:direction] == "desc" ? "desc" : "asc"
# Validate sort column to prevent SQL injection
- allowed_columns = %w[position_title application_date status remote tech_stack created_at salary_range chatgpt_match jobright_match linkedin_match role_type]
+ allowed_columns = %w[position_title application_date status remote tech_stack created_at salary_range bus_factor chatgpt_match jobright_match linkedin_match role_type]
if allowed_columns.include?(sort_column)
@opportunities = @opportunities.order("#{sort_column} #{sort_direction}")
elsif sort_column == "company"
@@ -111,7 +111,7 @@ def set_opportunity
def opportunity_params
params.expect(opportunity: [
:company_id, :position_title, :application_date, :status, :notes, :remote,
- :tech_stack, :other_tech_stack, :source, :salary_range, :listing_url,
+ :tech_stack, :other_tech_stack, :source, :salary_range, :bus_factor, :listing_url,
:chatgpt_match, :jobright_match, :linkedin_match, :role_type,
technology_ids: [],
role_metadata: {}
diff --git a/app/controllers/star_stories_controller.rb b/app/controllers/star_stories_controller.rb
new file mode 100644
index 0000000..31bcc94
--- /dev/null
+++ b/app/controllers/star_stories_controller.rb
@@ -0,0 +1,73 @@
+class StarStoriesController < ApplicationController
+ before_action :set_star_story, only: [ :show, :edit, :update, :destroy ]
+
+ def index
+ @star_stories = StarStory.all.order(created_at: :desc)
+
+ respond_to do |format|
+ format.html
+ format.csv do
+ exporter = StarStoriesCsvExporter.new(@star_stories)
+ send_data exporter.generate,
+ filename: "star_stories_#{Date.current.strftime('%Y-%m-%d')}.csv",
+ type: "text/csv"
+ end
+ end
+ end
+
+ def new
+ @star_story = StarStory.new
+ end
+
+ def create
+ @star_story = StarStory.new(star_story_params)
+ process_skills_from_form
+
+ if @star_story.save
+ redirect_to @star_story, notice: "STAR story was successfully created."
+ else
+ render :new, status: :unprocessable_entity
+ end
+ end
+
+ def edit
+ end
+
+ def update
+ process_skills_from_form
+
+ if @star_story.update(star_story_params)
+ redirect_to @star_story, notice: "STAR story was successfully updated."
+ else
+ render :edit, status: :unprocessable_entity
+ end
+ end
+
+ def destroy
+ @star_story.destroy
+ redirect_to star_stories_url, notice: "STAR story was successfully deleted."
+ end
+
+ def show
+ end
+
+ private
+
+ def set_star_story
+ @star_story = StarStory.find(params[:id])
+ end
+
+ def star_story_params
+ params.require(:star_story).permit(
+ :title, :situation, :task, :action, :result, :category, :outcome,
+ :strength_score, :notes, :skills, opportunity_ids: []
+ )
+ end
+
+ def process_skills_from_form
+ if params[:star_story][:skills].is_a?(String)
+ skills_array = params[:star_story][:skills].split("\n").map(&:strip).reject(&:blank?)
+ params[:star_story][:skills] = skills_array
+ end
+ end
+end
diff --git a/app/helpers/interview_sessions_helper.rb b/app/helpers/interview_sessions_helper.rb
new file mode 100644
index 0000000..f5b35af
--- /dev/null
+++ b/app/helpers/interview_sessions_helper.rb
@@ -0,0 +1,2 @@
+module InterviewSessionsHelper
+end
diff --git a/app/helpers/star_stories_helper.rb b/app/helpers/star_stories_helper.rb
new file mode 100644
index 0000000..77ddc5b
--- /dev/null
+++ b/app/helpers/star_stories_helper.rb
@@ -0,0 +1,2 @@
+module StarStoriesHelper
+end
diff --git a/app/models/core_narrative.rb b/app/models/core_narrative.rb
new file mode 100644
index 0000000..d1951cf
--- /dev/null
+++ b/app/models/core_narrative.rb
@@ -0,0 +1,60 @@
+class CoreNarrative < ApplicationRecord
+ # Enums
+ enum :narrative_type, {
+ about_me: "about_me",
+ why_company: "why_company",
+ why_me: "why_me",
+ salary: "salary",
+ remote_positioning: "remote_positioning",
+ leadership: "leadership",
+ technical_depth: "technical_depth",
+ career_trajectory: "career_trajectory"
+ }, prefix: true
+
+ enum :role_target, {
+ software_engineer: "software_engineer",
+ sales_engineer: "sales_engineer",
+ solutions_engineer: "solutions_engineer",
+ product_manager: "product_manager",
+ support_engineer: "support_engineer",
+ success_engineer: "success_engineer",
+ devops: "devops",
+ hybrid: "hybrid",
+ universal: "universal"
+ }, prefix: true
+
+ # Validations
+ validates :narrative_type, presence: true
+ validates :content, presence: true
+
+ # Callbacks
+ before_save :set_last_updated, if: :content_changed?
+
+ # Scopes
+ scope :for_role, ->(role) { where(role_target: [ role, :universal ]) }
+ scope :by_type, ->(type) { where(narrative_type: type) }
+ scope :recently_updated, -> { where("last_updated >= ?", 30.days.ago).order(last_updated: :desc) }
+ scope :current_version, -> { where(version: "current").or(where(version: nil)) }
+
+ # Methods
+ def word_count
+ content.to_s.split.size
+ end
+
+ def estimated_reading_time
+ # Average reading speed: 200 words per minute
+ (word_count / 200.0).ceil
+ end
+
+ def needs_update?
+ return true if last_updated.nil?
+
+ last_updated < 60.days.ago
+ end
+
+ private
+
+ def set_last_updated
+ self.last_updated = Date.today
+ end
+end
diff --git a/app/models/interview_session.rb b/app/models/interview_session.rb
new file mode 100644
index 0000000..9f61793
--- /dev/null
+++ b/app/models/interview_session.rb
@@ -0,0 +1,47 @@
+class InterviewSession < ApplicationRecord
+ belongs_to :opportunity
+
+ # Enums
+ enum :stage, {
+ recruiter: "recruiter",
+ tech_screen: "tech_screen",
+ panel: "panel",
+ final: "final",
+ hiring_manager: "hiring_manager",
+ peer: "peer",
+ behavioral: "behavioral"
+ }, prefix: true
+
+ enum :overall_signal, {
+ strong: "strong",
+ neutral: "neutral",
+ weak: "weak"
+ }, prefix: true
+
+ # Validations
+ validates :stage, presence: true
+ validates :date, presence: true
+ validates :confidence_score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 10 }, allow_nil: true
+ validates :clarity_score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 10 }, allow_nil: true
+
+ # Scopes
+ scope :recent, -> { order(date: :desc) }
+ scope :by_stage, ->(stage) { where(stage: stage) }
+ scope :strong_signals, -> { where(overall_signal: :strong) }
+ scope :this_month, -> { where(date: Date.today.beginning_of_month..Date.today.end_of_month) }
+
+ # Methods
+ def performance_score
+ return nil unless confidence_score.present? && clarity_score.present?
+
+ (confidence_score + clarity_score) / 2.0
+ end
+
+ def needs_follow_up?
+ follow_up.present? && follow_up.strip.length > 0
+ end
+
+ def has_preparation_gaps?
+ weak_areas.present? && weak_areas.strip.length > 0
+ end
+end
diff --git a/app/models/opportunity.rb b/app/models/opportunity.rb
index f5f15e1..484af75 100644
--- a/app/models/opportunity.rb
+++ b/app/models/opportunity.rb
@@ -11,6 +11,9 @@ class Opportunity < ApplicationRecord
belongs_to :company
has_many :opportunity_technologies, dependent: :destroy
has_many :technologies, through: :opportunity_technologies
+ has_many :interview_sessions, dependent: :destroy
+ has_many :star_story_opportunities, dependent: :destroy
+ has_many :star_stories, through: :star_story_opportunities
accepts_nested_attributes_for :opportunity_technologies, allow_destroy: true
@@ -28,7 +31,30 @@ class Opportunity < ApplicationRecord
"other" => "Other"
}.freeze
+ # Career Intelligence Enums
+ enum :retirement_plan_type, {
+ four_oh_one_k: "401k",
+ simple_ira: "simple_ira",
+ none: "none",
+ unknown: "unknown"
+ }, prefix: :retirement
+
+ enum :remote_type, {
+ remote: "remote",
+ hybrid: "hybrid",
+ onsite: "onsite"
+ }, prefix: :work
+
+ enum :risk_level, {
+ low: "low",
+ medium: "medium",
+ high: "high"
+ }, prefix: :risk
+
validates :role_type, presence: true, inclusion: { in: ROLE_TYPES.keys }
+ validates :fit_score, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 10 }, allow_nil: true
+ validates :trajectory_score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 10 }, allow_nil: true
+ validates :strategic_value, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 10 }, allow_nil: true
before_save :shorten_urls
before_save :standardize_salary_range
diff --git a/app/models/star_story.rb b/app/models/star_story.rb
new file mode 100644
index 0000000..c5eda0a
--- /dev/null
+++ b/app/models/star_story.rb
@@ -0,0 +1,56 @@
+class StarStory < ApplicationRecord
+ # Associations
+ has_many :star_story_opportunities, dependent: :destroy
+ has_many :opportunities, through: :star_story_opportunities
+
+ # Enums
+ enum :category, {
+ incident: "incident",
+ leadership: "leadership",
+ devops: "devops",
+ refactor: "refactor",
+ conflict: "conflict",
+ architecture: "architecture",
+ scaling: "scaling",
+ problem_solving: "problem_solving",
+ collaboration: "collaboration",
+ innovation: "innovation"
+ }, prefix: true
+
+ enum :outcome, {
+ advanced: "advanced",
+ rejected: "rejected",
+ offer: "offer",
+ unknown: "unknown"
+ }, prefix: true
+
+ # Validations
+ validates :title, presence: true
+ validates :strength_score, numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 10 }, allow_nil: true
+ validates :times_used, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ # Scopes
+ scope :top_rated, -> { where("strength_score >= ?", 4).order(strength_score: :desc) }
+ scope :frequently_used, -> { where("times_used > ?", 2).order(times_used: :desc) }
+ scope :by_category, ->(category) { where(category: category) }
+ scope :successful, -> { where(outcome: [ :advanced, :offer ]) }
+
+ # Methods
+ def mark_as_used!
+ increment!(:times_used)
+ update(last_used_at: Date.today)
+ end
+
+ def complete?
+ situation.present? && task.present? && action.present? && result.present?
+ end
+
+ def incomplete_fields
+ fields = []
+ fields << "situation" if situation.blank?
+ fields << "task" if task.blank?
+ fields << "action" if action.blank?
+ fields << "result" if result.blank?
+ fields
+ end
+end
diff --git a/app/models/star_story_opportunity.rb b/app/models/star_story_opportunity.rb
new file mode 100644
index 0000000..ab1ece7
--- /dev/null
+++ b/app/models/star_story_opportunity.rb
@@ -0,0 +1,6 @@
+class StarStoryOpportunity < ApplicationRecord
+ belongs_to :star_story
+ belongs_to :opportunity
+
+ validates :star_story_id, uniqueness: { scope: :opportunity_id }
+end
diff --git a/app/services/star_stories_csv_exporter.rb b/app/services/star_stories_csv_exporter.rb
new file mode 100644
index 0000000..d84fec0
--- /dev/null
+++ b/app/services/star_stories_csv_exporter.rb
@@ -0,0 +1,43 @@
+require "csv"
+
+class StarStoriesCsvExporter
+ def initialize(star_stories)
+ @star_stories = star_stories
+ end
+
+ def generate
+ CSV.generate(headers: true) do |csv|
+ csv << headers
+
+ @star_stories.each do |story|
+ csv << [
+ story.title,
+ story.situation,
+ story.task,
+ story.action,
+ story.result,
+ story.skills&.join(", "),
+ story.category&.titleize,
+ story.outcome&.titleize,
+ story.notes
+ ]
+ end
+ end
+ end
+
+ private
+
+ def headers
+ [
+ "Title",
+ "Situation",
+ "Task",
+ "Action",
+ "Result",
+ "Skills",
+ "Category",
+ "Outcome",
+ "Notes"
+ ]
+ end
+end
diff --git a/app/views/companies/_company_details.html.erb b/app/views/companies/_company_details.html.erb
index a77a04a..af3fa3d 100644
--- a/app/views/companies/_company_details.html.erb
+++ b/app/views/companies/_company_details.html.erb
@@ -40,6 +40,67 @@
<% end %>
+
+ <% if company.primary_product.present? || company.revenue_model.present? || company.funding_stage.present? || company.product_maturity.present? %>
+
+
Company Intelligence
+
+ <% if company.primary_product.present? %>
+
Primary Product: <%= company.primary_product %>
+ <% end %>
+
+ <% if company.revenue_model.present? %>
+
Revenue Model: <%= company.revenue_model %>
+ <% end %>
+
+ <% if company.funding_stage.present? %>
+
Funding Stage: <%= company.funding_stage %>
+ <% end %>
+
+ <% if company.estimated_revenue.present? %>
+
Estimated Revenue: <%= company.estimated_revenue %>
+ <% end %>
+
+ <% if company.estimated_employees.present? %>
+
Estimated Employees: <%= company.estimated_employees %>
+ <% end %>
+
+ <% if company.growth_signal.present? %>
+
Growth Signal: <%= company.growth_signal %>
+ <% end %>
+
+
+ <% if company.product_maturity.present? %>
+
Product Maturity: <%= company.product_maturity %>/10
+ <% end %>
+
+ <% if company.engineering_maturity.present? %>
+
Engineering Maturity: <%= company.engineering_maturity %>/10
+ <% end %>
+
+ <% if company.process_maturity.present? %>
+
Process Maturity: <%= company.process_maturity %>/10
+ <% end %>
+
+
+ <% if company.market_position.present? %>
+
Market Position: <%= company.market_position %>
+ <% end %>
+
+ <% if company.competitor_tier.present? %>
+
Competitor Tier: <%= company.competitor_tier %>
+ <% end %>
+
+ <% if company.brand_signal_strength.present? %>
+
Brand Signal Strength: <%= company.brand_signal_strength %>/10
+ <% end %>
+
+ <% if company.market_size_estimate.present? %>
+
Market Size: <%= company.market_size_estimate %>
+ <% end %>
+
+ <% end %>
+
<% if company.tech_stack_summary.any? || company.known_tech_stack.present? %>
Technologies:
diff --git a/app/views/companies/_form.html.erb b/app/views/companies/_form.html.erb
index d926c2e..10381d2 100644
--- a/app/views/companies/_form.html.erb
+++ b/app/views/companies/_form.html.erb
@@ -79,6 +79,79 @@
<%= hidden_field_tag "company[known_tech_stack_list][]", "" %>
- <%= form.submit style: "margin-top: 20px; background-color: #0d6efd; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+
+
+
Company Intelligence
+
+
+ <%= form.label :primary_product, style: "display: block" %>
+ <%= form.text_area :primary_product, rows: 3, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
+ <%= form.label :revenue_model, style: "display: block" %>
+ <%= form.select :revenue_model, [["Select...", ""], ["SaaS", "SaaS"], ["Enterprise License", "Enterprise License"], ["Freemium", "Freemium"], ["Marketplace", "Marketplace"], ["Advertising", "Advertising"], ["Consulting", "Consulting"]], {}, { style: "width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :funding_stage, style: "display: block" %>
+ <%= form.select :funding_stage, [["Select...", ""], ["Bootstrapped", "Bootstrapped"], ["Seed", "Seed"], ["Series A", "Series A"], ["Series B", "Series B"], ["Series C+", "Series C+"], ["Public", "Public"], ["Acquired", "Acquired"]], {}, { style: "width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :estimated_revenue, style: "display: block" %>
+ <%= form.text_field :estimated_revenue, placeholder: "e.g., $10M-$50M ARR", style: "width: 250px; height: 25px;" %>
+
+
+
+ <%= form.label :estimated_employees, style: "display: block" %>
+ <%= form.number_field :estimated_employees, style: "width: 150px; height: 25px;" %>
+
+
+
+ <%= form.label :growth_signal, style: "display: block" %>
+ <%= form.select :growth_signal, [["Select...", ""], ["Rapid Growth", "Rapid Growth"], ["Steady Growth", "Steady Growth"], ["Stable", "Stable"], ["Declining", "Declining"], ["Unknown", "Unknown"]], {}, { style: "width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :product_maturity, "Product Maturity (1-10)", style: "display: block" %>
+ <%= form.number_field :product_maturity, min: 1, max: 10, style: "width: 100px; height: 25px;" %>
+ 1=early prototype, 10=mature/established
+
+
+
+ <%= form.label :engineering_maturity, "Engineering Maturity (1-10)", style: "display: block" %>
+ <%= form.number_field :engineering_maturity, min: 1, max: 10, style: "width: 100px; height: 25px;" %>
+ 1=chaos, 10=world-class practices
+
+
+
+ <%= form.label :process_maturity, "Process Maturity (1-10)", style: "display: block" %>
+ <%= form.number_field :process_maturity, min: 1, max: 10, style: "width: 100px; height: 25px;" %>
+ 1=ad-hoc, 10=optimized
+
+
+
+ <%= form.label :market_position, style: "display: block" %>
+ <%= form.select :market_position, [["Select...", ""], ["Market Leader", "Market Leader"], ["Strong Challenger", "Strong Challenger"], ["Niche Player", "Niche Player"], ["Emerging", "Emerging"], ["Unknown", "Unknown"]], {}, { style: "width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :competitor_tier, style: "display: block" %>
+ <%= form.select :competitor_tier, [["Select...", ""], ["Tier 1 (FAANG-level)", "Tier 1"], ["Tier 2 (Unicorn/Strong)", "Tier 2"], ["Tier 3 (Mid-market)", "Tier 3"], ["Tier 4 (Small/Startup)", "Tier 4"]], {}, { style: "width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :brand_signal_strength, "Brand Signal Strength (1-10)", style: "display: block" %>
+ <%= form.number_field :brand_signal_strength, min: 1, max: 10, style: "width: 100px; height: 25px;" %>
+ 1=unknown, 10=household name
+
+
+
+ <%= form.label :market_size_estimate, style: "display: block" %>
+ <%= form.text_area :market_size_estimate, rows: 2, placeholder: "e.g., TAM $50B, growing 20% YoY", style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
<% end %>
diff --git a/app/views/companies/index.html.erb b/app/views/companies/index.html.erb
index 2adba42..085d278 100644
--- a/app/views/companies/index.html.erb
+++ b/app/views/companies/index.html.erb
@@ -21,7 +21,7 @@
<% end %>
<% end %>
- <%= link_to "New company", new_company_path, style: "padding: 8px 16px; background-color: #0d6efd; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+ <%= link_to "+", new_company_path, style: "padding: 8px 16px; background-color: #0d6efd; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block; font-size: 1.25rem; line-height: 1;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
diff --git a/app/views/contacts/index.html.erb b/app/views/contacts/index.html.erb
index 8b24b0a..935c6ad 100644
--- a/app/views/contacts/index.html.erb
+++ b/app/views/contacts/index.html.erb
@@ -2,7 +2,10 @@
<% content_for :title, "Contacts" %>
-Contacts
+
+
Contacts
+ <%= link_to "+", new_contact_path, style: "padding: 8px 16px; background-color: #0d6efd; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block; font-size: 1.25rem; line-height: 1;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+
-
-<%= link_to "New contact", new_contact_path %>
diff --git a/app/views/interview_sessions/_form.html.erb b/app/views/interview_sessions/_form.html.erb
new file mode 100644
index 0000000..b80e680
--- /dev/null
+++ b/app/views/interview_sessions/_form.html.erb
@@ -0,0 +1,55 @@
+<%= form_with(model: interview_session, local: true) do |form| %>
+
+ <%= form.label :opportunity_id, "Opportunity" %>
+ <%= form.collection_select :opportunity_id, Opportunity.all.order(:position_title), :id, :position_title, { prompt: "Select an opportunity" }, { class: "form-control" } %>
+
+
+
+ <%= form.label :stage %>
+ <%= form.text_field :stage, class: "form-control", placeholder: "e.g., Phone Screen, Technical Interview, Final Round" %>
+
+
+
+ <%= form.label :date %>
+ <%= form.date_field :date, class: "form-control" %>
+
+
+
+ <%= form.label :confidence_score, "Confidence Score (1-10)" %>
+ <%= form.number_field :confidence_score, class: "form-control", min: 1, max: 10 %>
+
+
+
+ <%= form.label :clarity_score, "Clarity Score (1-10)" %>
+ <%= form.number_field :clarity_score, class: "form-control", min: 1, max: 10 %>
+
+
+
+ <%= form.label :questions_asked %>
+ <%= form.text_area :questions_asked, class: "form-control", rows: 4, placeholder: "What questions were you asked?" %>
+
+
+
+ <%= form.label :weak_areas %>
+ <%= form.text_area :weak_areas, class: "form-control", rows: 3, placeholder: "Areas where you struggled or could improve" %>
+
+
+
+ <%= form.label :strong_areas %>
+ <%= form.text_area :strong_areas, class: "form-control", rows: 3, placeholder: "Areas where you performed well" %>
+
+
+
+ <%= form.label :follow_up %>
+ <%= form.text_area :follow_up, class: "form-control", rows: 3, placeholder: "Follow-up actions or notes" %>
+
+
+
+ <%= form.label :overall_signal %>
+ <%= form.text_area :overall_signal, class: "form-control", rows: 3, placeholder: "Overall assessment of how the interview went" %>
+
+
+
+ <%= form.submit class: "btn btn-primary" %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/interview_sessions/edit.html.erb b/app/views/interview_sessions/edit.html.erb
new file mode 100644
index 0000000..9dc1b1a
--- /dev/null
+++ b/app/views/interview_sessions/edit.html.erb
@@ -0,0 +1,4 @@
+InterviewSessions#edit
+
+<%= render 'form', interview_session: @interview_session %>
+<%= link_to "Back", interview_session_path(@interview_session) %>
\ No newline at end of file
diff --git a/app/views/interview_sessions/index.html.erb b/app/views/interview_sessions/index.html.erb
new file mode 100644
index 0000000..f452cec
--- /dev/null
+++ b/app/views/interview_sessions/index.html.erb
@@ -0,0 +1,2 @@
+InterviewSessions#index
+Find me in app/views/interview_sessions/index.html.erb
diff --git a/app/views/interview_sessions/new.html.erb b/app/views/interview_sessions/new.html.erb
new file mode 100644
index 0000000..f6d2103
--- /dev/null
+++ b/app/views/interview_sessions/new.html.erb
@@ -0,0 +1,5 @@
+InterviewSessions#new
+
+<%= render 'form', interview_session: @interview_session %>
+
+<%= link_to "Back", interview_sessions_path %>
\ No newline at end of file
diff --git a/app/views/interview_sessions/show.html.erb b/app/views/interview_sessions/show.html.erb
new file mode 100644
index 0000000..8bbcd6c
--- /dev/null
+++ b/app/views/interview_sessions/show.html.erb
@@ -0,0 +1,2 @@
+InterviewSessions#show
+Find me in app/views/interview_sessions/show.html.erb
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 9968bde..0a20601 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -30,6 +30,8 @@
<%= link_to "Companies", companies_path, class: "nav-link" %>
<%= link_to "Contacts", contacts_path, class: "nav-link" %>
<%= link_to "Opportunities", opportunities_path, class: "nav-link" %>
+ <%= link_to "STAR", star_stories_path, class: "nav-link" %>
+ <%= link_to "Interviews", interview_sessions_path, class: "nav-link" %>
diff --git a/app/views/opportunities/_form.html.erb b/app/views/opportunities/_form.html.erb
index 3972493..9e402ce 100644
--- a/app/views/opportunities/_form.html.erb
+++ b/app/views/opportunities/_form.html.erb
@@ -160,6 +160,12 @@
<%= form.text_field :salary_range, style: "width: 250px; height: 25px;" %>
+
+ <%= form.label :bus_factor, "Bus Factor (1-10)", style: "display: block" %>
+ <%= form.number_field :bus_factor, min: 1, max: 10, style: "width: 100px; height: 25px;" %>
+ How many key people leaving would collapse the project/company? (1=very fragile, 10=very stable)
+
+
<%= form.label :listing_url, "Listing URL", style: "display: block" %>
<%= form.url_field :listing_url, placeholder: "https://...", style: "width: 250px; height: 25px;" %>
diff --git a/app/views/opportunities/_opportunity.html.erb b/app/views/opportunities/_opportunity.html.erb
index a55e7c5..dce5f85 100644
--- a/app/views/opportunities/_opportunity.html.erb
+++ b/app/views/opportunities/_opportunity.html.erb
@@ -83,6 +83,7 @@
<% end %>
<%= opportunity.salary_range %> |
+
<%= opportunity.bus_factor %> |
<%= opportunity.chatgpt_match %> |
<%= opportunity.jobright_match %> |
<%= opportunity.linkedin_match %> |
diff --git a/app/views/opportunities/index.html.erb b/app/views/opportunities/index.html.erb
index 2da0c1b..3c16a47 100644
--- a/app/views/opportunities/index.html.erb
+++ b/app/views/opportunities/index.html.erb
@@ -32,7 +32,7 @@
<% end %>
<% end %>
- <%= link_to "New opportunity", new_opportunity_path, style: "padding: 8px 16px; background-color: #0d6efd; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+ <%= link_to "+", new_opportunity_path, style: "padding: 8px 16px; background-color: #0d6efd; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block; font-size: 1.25rem; line-height: 1;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
<%= link_to "Download CSV", opportunities_path(format: :csv, start_date: params[:start_date], end_date: params[:end_date]), style: "padding: 8px 16px; background-color: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block;", onmouseover: "this.style.backgroundColor='#5a6268'", onmouseout: "this.style.backgroundColor='#6c757d'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
@@ -50,6 +50,7 @@
<%= sortable_link :source %> |
<%= sortable_link :tech_stack, "Tech Stack" %> |
<%= sortable_link :salary_range, "Salary Range" %> |
+ <%= sortable_link :bus_factor, "Bus Factor" %> |
<%= sortable_link :chatgpt_match, "ChatGPT" %> |
<%= sortable_link :jobright_match, "Jobright" %> |
<%= sortable_link :linkedin_match, "LinkedIn" %> |
diff --git a/app/views/star_stories/_form.html.erb b/app/views/star_stories/_form.html.erb
new file mode 100644
index 0000000..66260d3
--- /dev/null
+++ b/app/views/star_stories/_form.html.erb
@@ -0,0 +1,74 @@
+<%= form_with(model: star_story) do |form| %>
+ <% if star_story.errors.any? %>
+
+
<%= pluralize(star_story.errors.count, "error") %> prohibited this STAR story from being saved:
+
+
+ <% star_story.errors.each do |error| %>
+ - <%= error.full_message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <%= form.label :title, style: "display: block" %>
+ <%= form.text_field :title, style: "width: 500px; height: 25px;" %>
+
+
+
+ <%= form.label :situation, style: "display: block" %>
+ <%= form.text_area :situation, rows: 4, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
+ <%= form.label :task, style: "display: block" %>
+ <%= form.text_area :task, rows: 4, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
+ <%= form.label :action, style: "display: block" %>
+ <%= form.text_area :action, rows: 4, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
+ <%= form.label :result, style: "display: block" %>
+ <%= form.text_area :result, rows: 4, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
+ <%= form.label :skills, "Skills (one per line)", style: "display: block" %>
+ <%= form.text_area :skills, value: star_story.skills&.join("\n"), rows: 3, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+ Enter each skill on a new line
+
+
+
+ <%= form.label :category, style: "display: block" %>
+ <%= form.select :category, StarStory.categories.keys.map { |k| [k.titleize, k] }, { prompt: "Select a category" }, { style: "display: block; width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :outcome, style: "display: block" %>
+ <%= form.select :outcome, StarStory.outcomes.keys.map { |k| [k.titleize, k] }, { prompt: "Select an outcome" }, { style: "display: block; width: 250px; height: 30px;" } %>
+
+
+
+ <%= form.label :strength_score, "Strength Score (1-10)", style: "display: block" %>
+ <%= form.number_field :strength_score, min: 1, max: 10, style: "width: 100px; height: 25px;" %>
+ How compelling/impactful is this story? (1=weak, 10=very strong)
+
+
+
+ <%= form.label :notes, "Notes/Reflection", style: "display: block" %>
+ <%= form.text_area :notes, rows: 4, style: "width: 500px; padding: 8px; border: 1px solid #ddd; border-radius: 4px;" %>
+
+
+
+ <%= form.label :opportunity_ids, "Related Opportunities", style: "display: block" %>
+ <%= form.collection_select :opportunity_ids, Opportunity.all.order(:position_title), :id, :position_title, {}, { multiple: true, style: "display: block; width: 500px; height: 150px;" } %>
+
+
+
+ <%= form.submit %>
+
+<% end %>
\ No newline at end of file
diff --git a/app/views/star_stories/edit.html.erb b/app/views/star_stories/edit.html.erb
new file mode 100644
index 0000000..b256ee8
--- /dev/null
+++ b/app/views/star_stories/edit.html.erb
@@ -0,0 +1,12 @@
+<% content_for :title, "Edit STAR Story" %>
+
+Edit STAR Story
+
+<%= render 'form', star_story: @star_story %>
+
+
+
+
+ <%= link_to "Show this STAR story", @star_story %> |
+ <%= link_to "Back to STAR stories", star_stories_path %>
+
\ No newline at end of file
diff --git a/app/views/star_stories/index.html.erb b/app/views/star_stories/index.html.erb
new file mode 100644
index 0000000..628e100
--- /dev/null
+++ b/app/views/star_stories/index.html.erb
@@ -0,0 +1,48 @@
+<%= notice %>
+
+<% content_for :title, "STAR Stories" %>
+
+
+
+
STAR Stories
+
+ <%= link_to "+", new_star_story_path, style: "padding: 8px 16px; background-color: #0d6efd; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block; font-size: 1.25rem; line-height: 1;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+ <%= link_to "Download", star_stories_path(format: :csv), style: "padding: 8px 16px; background-color: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block;", onmouseover: "this.style.backgroundColor='#5a6268'", onmouseout: "this.style.backgroundColor='#6c757d'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+
+
+
+
+
+
+
+
+ | Title |
+ Situation |
+ Task |
+ Action |
+ Result |
+ Skills |
+ Category |
+ Outcome |
+ Notes |
+
+
+
+ <% @star_stories.each do |star_story| %>
+
+ | <%= star_story.title %> |
+ <%= truncate(star_story.situation, length: 100) %> |
+ <%= truncate(star_story.task, length: 100) %> |
+ <%= truncate(star_story.action, length: 100) %> |
+ <%= truncate(star_story.result, length: 100) %> |
+ <%= star_story.skills&.join(", ") %> |
+ <%= star_story.category&.titleize %> |
+ <%= star_story.outcome&.titleize %> |
+ <%= truncate(star_story.notes, length: 100) %> |
+
+ <% end %>
+
+
+
+
+
diff --git a/app/views/star_stories/new.html.erb b/app/views/star_stories/new.html.erb
new file mode 100644
index 0000000..b924e47
--- /dev/null
+++ b/app/views/star_stories/new.html.erb
@@ -0,0 +1,11 @@
+<% content_for :title, "New STAR Story" %>
+
+New STAR Story
+
+<%= render 'form', star_story: @star_story %>
+
+
+
+
+ <%= link_to "Back to STAR stories", star_stories_path %>
+
\ No newline at end of file
diff --git a/app/views/star_stories/show.html.erb b/app/views/star_stories/show.html.erb
new file mode 100644
index 0000000..f69f7a8
--- /dev/null
+++ b/app/views/star_stories/show.html.erb
@@ -0,0 +1,66 @@
+<%= notice %>
+
+<% content_for :title, @star_story.title %>
+
+<%= @star_story.title %>
+
+
+
+ Category: <%= @star_story.category&.titleize %> |
+ Outcome: <%= @star_story.outcome&.titleize %> |
+ Strength: <%= @star_story.strength_score %>/10
+
+
+
+
Situation
+
<%= @star_story.situation %>
+
+
+
+
Task
+
<%= @star_story.task %>
+
+
+
+
Action
+
<%= @star_story.action %>
+
+
+
+
Result
+
<%= @star_story.result %>
+
+
+ <% if @star_story.skills&.any? %>
+
+ Skills:
+ <% @star_story.skills.each do |skill| %>
+ <%= skill %>
+ <% end %>
+
+ <% end %>
+
+ <% if @star_story.notes.present? %>
+
+
Notes
+
<%= @star_story.notes %>
+
+ <% end %>
+
+ <% if @star_story.opportunities.any? %>
+
+
Related Opportunities
+
+ <% @star_story.opportunities.each do |opportunity| %>
+ - <%= link_to opportunity.position_title, opportunity %>
+ <% end %>
+
+
+ <% end %>
+
+
+
+ <%= link_to "Edit this STAR story", edit_star_story_path(@star_story), style: "background-color: #0d6efd; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block;", onmouseover: "this.style.backgroundColor='#0b5ed7'", onmouseout: "this.style.backgroundColor='#0d6efd'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+ <%= link_to "Back to STAR stories", star_stories_path, style: "background-color: #6c757d; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease; text-decoration: none; display: inline-block;", onmouseover: "this.style.backgroundColor='#5a6268'", onmouseout: "this.style.backgroundColor='#6c757d'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+ <%= button_to "Destroy this STAR story", @star_story, method: :delete, style: "margin: 0; background-color: #dc3545; color: white; padding: 10px 20px; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s ease;", data: { confirm: "Are you sure?" }, onmouseover: "this.style.backgroundColor='#c82333'", onmouseout: "this.style.backgroundColor='#dc3545'", onmousedown: "this.style.transform='scale(0.95)'", onmouseup: "this.style.transform='scale(1)'" %>
+
diff --git a/config/routes.rb b/config/routes.rb
index 846762d..ea6ca24 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,10 +1,11 @@
Rails.application.routes.draw do
- get "dashboard/index"
get "dashboard" => "dashboard#index"
resources :opportunities
resources :interactions
+ resources :interview_sessions
resources :contacts
resources :companies
+ resources :star_stories
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
diff --git a/db/migrate/20260227180045_create_star_stories.rb b/db/migrate/20260227180045_create_star_stories.rb
new file mode 100644
index 0000000..1fa0a32
--- /dev/null
+++ b/db/migrate/20260227180045_create_star_stories.rb
@@ -0,0 +1,24 @@
+class CreateStarStories < ActiveRecord::Migration[8.0]
+ def change
+ create_table :star_stories do |t|
+ t.string :title, null: false
+ t.text :situation
+ t.text :task
+ t.text :action
+ t.text :result
+ t.string :skills, array: true, default: []
+ t.string :category
+ t.integer :strength_score
+ t.integer :times_used, default: 0
+ t.date :last_used_at
+ t.string :outcome
+ t.text :notes
+
+ t.timestamps
+ end
+
+ add_index :star_stories, :category
+ add_index :star_stories, :skills, using: :gin
+ add_index :star_stories, :strength_score
+ end
+end
diff --git a/db/migrate/20260227180111_create_star_story_opportunities.rb b/db/migrate/20260227180111_create_star_story_opportunities.rb
new file mode 100644
index 0000000..7a25916
--- /dev/null
+++ b/db/migrate/20260227180111_create_star_story_opportunities.rb
@@ -0,0 +1,10 @@
+class CreateStarStoryOpportunities < ActiveRecord::Migration[8.0]
+ def change
+ create_table :star_story_opportunities do |t|
+ t.references :star_story, null: false, foreign_key: true
+ t.references :opportunity, null: false, foreign_key: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20260227180213_create_interview_sessions.rb b/db/migrate/20260227180213_create_interview_sessions.rb
new file mode 100644
index 0000000..9f6fb27
--- /dev/null
+++ b/db/migrate/20260227180213_create_interview_sessions.rb
@@ -0,0 +1,22 @@
+class CreateInterviewSessions < ActiveRecord::Migration[8.0]
+ def change
+ create_table :interview_sessions do |t|
+ t.references :opportunity, null: false, foreign_key: true
+ t.string :stage, null: false
+ t.date :date, null: false
+ t.integer :confidence_score
+ t.integer :clarity_score
+ t.text :questions_asked
+ t.text :weak_areas
+ t.text :strong_areas
+ t.text :follow_up
+ t.string :overall_signal
+
+ t.timestamps
+ end
+
+ add_index :interview_sessions, [ :opportunity_id, :date ]
+ add_index :interview_sessions, :stage
+ add_index :interview_sessions, :overall_signal
+ end
+end
diff --git a/db/migrate/20260227180241_create_core_narratives.rb b/db/migrate/20260227180241_create_core_narratives.rb
new file mode 100644
index 0000000..61a768e
--- /dev/null
+++ b/db/migrate/20260227180241_create_core_narratives.rb
@@ -0,0 +1,16 @@
+class CreateCoreNarratives < ActiveRecord::Migration[8.0]
+ def change
+ create_table :core_narratives do |t|
+ t.string :narrative_type, null: false
+ t.string :version
+ t.string :role_target
+ t.text :content, null: false
+ t.date :last_updated
+
+ t.timestamps
+ end
+
+ add_index :core_narratives, [ :narrative_type, :role_target ]
+ add_index :core_narratives, :narrative_type
+ end
+end
diff --git a/db/migrate/20260227180326_add_career_intel_to_opportunities.rb b/db/migrate/20260227180326_add_career_intel_to_opportunities.rb
new file mode 100644
index 0000000..e1334bf
--- /dev/null
+++ b/db/migrate/20260227180326_add_career_intel_to_opportunities.rb
@@ -0,0 +1,17 @@
+class AddCareerIntelToOpportunities < ActiveRecord::Migration[8.0]
+ def change
+ add_column :opportunities, :fit_score, :decimal, precision: 3, scale: 2
+ add_column :opportunities, :income_range_low, :integer
+ add_column :opportunities, :income_range_high, :integer
+ add_column :opportunities, :retirement_plan_type, :string
+ add_column :opportunities, :remote_type, :string
+ add_column :opportunities, :company_size, :integer
+ add_column :opportunities, :risk_level, :string
+ add_column :opportunities, :trajectory_score, :integer
+ add_column :opportunities, :strategic_value, :integer
+
+ add_index :opportunities, :fit_score
+ add_index :opportunities, :risk_level
+ add_index :opportunities, :remote_type
+ end
+end
diff --git a/db/migrate/20260227180347_add_intelligence_to_companies.rb b/db/migrate/20260227180347_add_intelligence_to_companies.rb
new file mode 100644
index 0000000..55d69d5
--- /dev/null
+++ b/db/migrate/20260227180347_add_intelligence_to_companies.rb
@@ -0,0 +1,21 @@
+class AddIntelligenceToCompanies < ActiveRecord::Migration[8.0]
+ def change
+ # industry already exists, skip it
+ add_column :companies, :primary_product, :text
+ add_column :companies, :revenue_model, :string
+ add_column :companies, :funding_stage, :string
+ add_column :companies, :estimated_revenue, :string
+ add_column :companies, :estimated_employees, :integer
+ add_column :companies, :growth_signal, :string
+ add_column :companies, :product_maturity, :integer
+ add_column :companies, :engineering_maturity, :integer
+ add_column :companies, :process_maturity, :integer
+ add_column :companies, :market_position, :string
+ add_column :companies, :competitor_tier, :string
+ add_column :companies, :brand_signal_strength, :integer
+
+ add_index :companies, :industry
+ add_index :companies, :funding_stage
+ add_index :companies, :growth_signal
+ end
+end
diff --git a/db/migrate/20260227180400_add_market_intel_to_companies.rb b/db/migrate/20260227180400_add_market_intel_to_companies.rb
new file mode 100644
index 0000000..2c24d1d
--- /dev/null
+++ b/db/migrate/20260227180400_add_market_intel_to_companies.rb
@@ -0,0 +1,12 @@
+class AddMarketIntelToCompanies < ActiveRecord::Migration[8.0]
+ def change
+ add_column :companies, :market_size_estimate, :text
+ add_column :companies, :market_growth_rate, :string
+ add_column :companies, :regulatory_environment, :string
+ add_column :companies, :switching_costs, :string
+ add_column :companies, :industry_volatility, :integer
+ add_column :companies, :tech_disruption_risk, :integer
+ add_column :companies, :personal_upside_score, :integer
+ add_column :companies, :career_risk_score, :integer
+ end
+end
diff --git a/db/migrate/20260228015924_add_bus_factor_to_opportunities.rb b/db/migrate/20260228015924_add_bus_factor_to_opportunities.rb
new file mode 100644
index 0000000..2093913
--- /dev/null
+++ b/db/migrate/20260228015924_add_bus_factor_to_opportunities.rb
@@ -0,0 +1,5 @@
+class AddBusFactorToOpportunities < ActiveRecord::Migration[8.0]
+ def change
+ add_column :opportunities, :bus_factor, :integer
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 46892f6..9358bf7 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2026_02_03_130000) do
+ActiveRecord::Schema[8.0].define(version: 2026_02_28_015924) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_catalog.plpgsql"
@@ -25,6 +25,29 @@
t.text "known_tech_stack"
t.string "company_type"
t.string "size"
+ t.text "primary_product"
+ t.string "revenue_model"
+ t.string "funding_stage"
+ t.string "estimated_revenue"
+ t.integer "estimated_employees"
+ t.string "growth_signal"
+ t.integer "product_maturity"
+ t.integer "engineering_maturity"
+ t.integer "process_maturity"
+ t.string "market_position"
+ t.string "competitor_tier"
+ t.integer "brand_signal_strength"
+ t.text "market_size_estimate"
+ t.string "market_growth_rate"
+ t.string "regulatory_environment"
+ t.string "switching_costs"
+ t.integer "industry_volatility"
+ t.integer "tech_disruption_risk"
+ t.integer "personal_upside_score"
+ t.integer "career_risk_score"
+ t.index ["funding_stage"], name: "index_companies_on_funding_stage"
+ t.index ["growth_signal"], name: "index_companies_on_growth_signal"
+ t.index ["industry"], name: "index_companies_on_industry"
end
create_table "contacts", force: :cascade do |t|
@@ -41,6 +64,18 @@
t.index ["company_id"], name: "index_contacts_on_company_id"
end
+ create_table "core_narratives", force: :cascade do |t|
+ t.string "narrative_type", null: false
+ t.string "version"
+ t.string "role_target"
+ t.text "content", null: false
+ t.date "last_updated"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["narrative_type", "role_target"], name: "index_core_narratives_on_narrative_type_and_role_target"
+ t.index ["narrative_type"], name: "index_core_narratives_on_narrative_type"
+ end
+
create_table "interactions", force: :cascade do |t|
t.bigint "contact_id"
t.bigint "company_id", null: false
@@ -54,6 +89,25 @@
t.index ["contact_id"], name: "index_interactions_on_contact_id"
end
+ create_table "interview_sessions", force: :cascade do |t|
+ t.bigint "opportunity_id", null: false
+ t.string "stage", null: false
+ t.date "date", null: false
+ t.integer "confidence_score"
+ t.integer "clarity_score"
+ t.text "questions_asked"
+ t.text "weak_areas"
+ t.text "strong_areas"
+ t.text "follow_up"
+ t.string "overall_signal"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["opportunity_id", "date"], name: "index_interview_sessions_on_opportunity_id_and_date"
+ t.index ["opportunity_id"], name: "index_interview_sessions_on_opportunity_id"
+ t.index ["overall_signal"], name: "index_interview_sessions_on_overall_signal"
+ t.index ["stage"], name: "index_interview_sessions_on_stage"
+ end
+
create_table "opportunities", force: :cascade do |t|
t.bigint "company_id", null: false
t.string "position_title"
@@ -73,7 +127,20 @@
t.text "other_tech_stack"
t.string "role_type", default: "software_engineer", null: false
t.jsonb "role_metadata", default: {}, null: false
+ t.decimal "fit_score", precision: 3, scale: 2
+ t.integer "income_range_low"
+ t.integer "income_range_high"
+ t.string "retirement_plan_type"
+ t.string "remote_type"
+ t.integer "company_size"
+ t.string "risk_level"
+ t.integer "trajectory_score"
+ t.integer "strategic_value"
+ t.integer "bus_factor"
t.index ["company_id"], name: "index_opportunities_on_company_id"
+ t.index ["fit_score"], name: "index_opportunities_on_fit_score"
+ t.index ["remote_type"], name: "index_opportunities_on_remote_type"
+ t.index ["risk_level"], name: "index_opportunities_on_risk_level"
t.index ["role_metadata"], name: "index_opportunities_on_role_metadata", using: :gin
t.index ["role_type"], name: "index_opportunities_on_role_type"
end
@@ -88,6 +155,35 @@
t.index ["technology_id"], name: "index_opportunity_technologies_on_technology_id"
end
+ create_table "star_stories", force: :cascade do |t|
+ t.string "title", null: false
+ t.text "situation"
+ t.text "task"
+ t.text "action"
+ t.text "result"
+ t.string "skills", default: [], array: true
+ t.string "category"
+ t.integer "strength_score"
+ t.integer "times_used", default: 0
+ t.date "last_used_at"
+ t.string "outcome"
+ t.text "notes"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["category"], name: "index_star_stories_on_category"
+ t.index ["skills"], name: "index_star_stories_on_skills", using: :gin
+ t.index ["strength_score"], name: "index_star_stories_on_strength_score"
+ end
+
+ create_table "star_story_opportunities", force: :cascade do |t|
+ t.bigint "star_story_id", null: false
+ t.bigint "opportunity_id", null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["opportunity_id"], name: "index_star_story_opportunities_on_opportunity_id"
+ t.index ["star_story_id"], name: "index_star_story_opportunities_on_star_story_id"
+ end
+
create_table "technologies", force: :cascade do |t|
t.string "name", null: false
t.string "category", null: false
@@ -100,7 +196,10 @@
add_foreign_key "contacts", "companies"
add_foreign_key "interactions", "companies"
add_foreign_key "interactions", "contacts"
+ add_foreign_key "interview_sessions", "opportunities"
add_foreign_key "opportunities", "companies"
add_foreign_key "opportunity_technologies", "opportunities"
add_foreign_key "opportunity_technologies", "technologies"
+ add_foreign_key "star_story_opportunities", "opportunities"
+ add_foreign_key "star_story_opportunities", "star_stories"
end
diff --git a/spec/models/star_story_spec.rb b/spec/models/star_story_spec.rb
new file mode 100644
index 0000000..044deb2
--- /dev/null
+++ b/spec/models/star_story_spec.rb
@@ -0,0 +1,113 @@
+require 'rails_helper'
+
+RSpec.describe StarStory, type: :model do
+ describe 'associations' do
+ it { should have_many(:star_story_opportunities).dependent(:destroy) }
+ it { should have_many(:opportunities).through(:star_story_opportunities) }
+ end
+
+ describe 'validations' do
+ it { should validate_presence_of(:title) }
+
+ it 'validates strength_score is between 1 and 10' do
+ story = StarStory.new(title: "Test", strength_score: 11)
+ expect(story).not_to be_valid
+ expect(story.errors[:strength_score]).to include("must be less than or equal to 10")
+ end
+
+ it 'validates times_used is non-negative' do
+ story = StarStory.new(title: "Test", times_used: -1)
+ expect(story).not_to be_valid
+ end
+ end
+
+ describe 'enums' do
+ it 'defines category enum' do
+ expect(StarStory.categories.keys).to include('incident', 'leadership', 'devops')
+ end
+
+ it 'defines outcome enum' do
+ expect(StarStory.outcomes.keys).to include('advanced', 'rejected', 'offer', 'unknown')
+ end
+ end
+
+ describe 'scopes' do
+ let!(:top_story) { StarStory.create!(title: "Top Story", strength_score: 5) }
+ let!(:weak_story) { StarStory.create!(title: "Weak Story", strength_score: 2) }
+ let!(:frequent_story) { StarStory.create!(title: "Frequent", times_used: 5) }
+ let!(:rare_story) { StarStory.create!(title: "Rare", times_used: 1) }
+
+ describe '.top_rated' do
+ it 'returns stories with strength_score >= 4' do
+ expect(StarStory.top_rated).to include(top_story)
+ expect(StarStory.top_rated).not_to include(weak_story)
+ end
+ end
+
+ describe '.frequently_used' do
+ it 'returns stories used more than 2 times' do
+ expect(StarStory.frequently_used).to include(frequent_story)
+ expect(StarStory.frequently_used).not_to include(rare_story)
+ end
+ end
+
+ describe '.successful' do
+ let!(:offer_story) { StarStory.create!(title: "Got Offer", outcome: :offer) }
+ let!(:rejected_story) { StarStory.create!(title: "Rejected", outcome: :rejected) }
+
+ it 'returns stories with positive outcomes' do
+ expect(StarStory.successful).to include(offer_story)
+ expect(StarStory.successful).not_to include(rejected_story)
+ end
+ end
+ end
+
+ describe '#mark_as_used!' do
+ let(:story) { StarStory.create!(title: "Test Story", times_used: 0) }
+
+ it 'increments times_used' do
+ expect { story.mark_as_used! }.to change { story.times_used }.by(1)
+ end
+
+ it 'updates last_used_at to today' do
+ story.mark_as_used!
+ expect(story.last_used_at).to eq(Date.today)
+ end
+ end
+
+ describe '#complete?' do
+ it 'returns true when all STAR fields are present' do
+ story = StarStory.new(
+ title: "Complete",
+ situation: "S",
+ task: "T",
+ action: "A",
+ result: "R"
+ )
+ expect(story.complete?).to be true
+ end
+
+ it 'returns false when any STAR field is missing' do
+ story = StarStory.new(title: "Incomplete", situation: "S", task: "T")
+ expect(story.complete?).to be false
+ end
+ end
+
+ describe '#incomplete_fields' do
+ it 'returns array of missing STAR fields' do
+ story = StarStory.new(title: "Test", situation: "S", task: "T")
+ expect(story.incomplete_fields).to contain_exactly("action", "result")
+ end
+
+ it 'returns empty array when complete' do
+ story = StarStory.new(
+ title: "Complete",
+ situation: "S",
+ task: "T",
+ action: "A",
+ result: "R"
+ )
+ expect(story.incomplete_fields).to be_empty
+ end
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index ef75d46..61018d3 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -70,3 +70,11 @@
# arbitrary gems may also be filtered via:
# config.filter_gems_from_backtrace("gem name")
end
+
+# Shoulda Matchers configuration
+Shoulda::Matchers.configure do |config|
+ config.integrate do |with|
+ with.test_framework :rspec
+ with.library :rails
+ end
+end
diff --git a/spec/views/interview_sessions/edit.html.erb_spec.rb b/spec/views/interview_sessions/edit.html.erb_spec.rb
new file mode 100644
index 0000000..2e03ff2
--- /dev/null
+++ b/spec/views/interview_sessions/edit.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "interview_sessions/edit.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/interview_sessions/index.html.erb_spec.rb b/spec/views/interview_sessions/index.html.erb_spec.rb
new file mode 100644
index 0000000..c0f405e
--- /dev/null
+++ b/spec/views/interview_sessions/index.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "interview_sessions/index.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/interview_sessions/new.html.erb_spec.rb b/spec/views/interview_sessions/new.html.erb_spec.rb
new file mode 100644
index 0000000..898ab92
--- /dev/null
+++ b/spec/views/interview_sessions/new.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "interview_sessions/new.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/interview_sessions/show.html.erb_spec.rb b/spec/views/interview_sessions/show.html.erb_spec.rb
new file mode 100644
index 0000000..b32d795
--- /dev/null
+++ b/spec/views/interview_sessions/show.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "interview_sessions/show.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/star_stories/edit.html.erb_spec.rb b/spec/views/star_stories/edit.html.erb_spec.rb
new file mode 100644
index 0000000..5102329
--- /dev/null
+++ b/spec/views/star_stories/edit.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "star_stories/edit.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/star_stories/index.html.erb_spec.rb b/spec/views/star_stories/index.html.erb_spec.rb
new file mode 100644
index 0000000..d2f906c
--- /dev/null
+++ b/spec/views/star_stories/index.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "star_stories/index.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/star_stories/new.html.erb_spec.rb b/spec/views/star_stories/new.html.erb_spec.rb
new file mode 100644
index 0000000..9e4c63b
--- /dev/null
+++ b/spec/views/star_stories/new.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "star_stories/new.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/spec/views/star_stories/show.html.erb_spec.rb b/spec/views/star_stories/show.html.erb_spec.rb
new file mode 100644
index 0000000..b1bb2f9
--- /dev/null
+++ b/spec/views/star_stories/show.html.erb_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe "star_stories/show.html.erb", type: :view do
+ pending "add some examples to (or delete) #{__FILE__}"
+end
diff --git a/test/controllers/dashboard_controller_test.rb b/test/controllers/dashboard_controller_test.rb
index 447c045..a46443c 100644
--- a/test/controllers/dashboard_controller_test.rb
+++ b/test/controllers/dashboard_controller_test.rb
@@ -2,7 +2,7 @@
class DashboardControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
- get dashboard_index_url
+ get dashboard_url
assert_response :success
end
end