diff --git a/Gemfile b/Gemfile index 054f8860..5a0ec0ed 100644 --- a/Gemfile +++ b/Gemfile @@ -61,6 +61,9 @@ group :development, :test do # See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem gem "debug", platforms: %i[ mri windows ], require: "debug/prelude" + # Generate realistic, reproducible local seed data + gem "faker" + # Audits gems for known security defects (use config/bundler-audit.yml to ignore issues) gem "bundler-audit", require: false diff --git a/Gemfile.lock b/Gemfile.lock index dc1b40d9..6cd855cf 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -165,6 +165,8 @@ GEM factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) + faker (3.8.0) + i18n (>= 1.8.11, < 2) faraday (2.14.1) faraday-net_http (>= 2.0, < 3.5) json @@ -537,6 +539,7 @@ DEPENDENCIES debug diffy factory_bot_rails + faker image_processing (~> 1.2) importmap-rails jbuilder @@ -612,6 +615,7 @@ CHECKSUMS event_stream_parser (1.0.0) sha256=a2683bab70126286f8184dc88f7968ffc4028f813161fb073ec90d171f7de3c8 factory_bot (6.5.6) sha256=12beb373214dccc086a7a63763d6718c49769d5606f0501e0a4442676917e077 factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68 + faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc faraday (2.14.1) sha256=a43cceedc1e39d188f4d2cdd360a8aaa6a11da0c407052e426ba8d3fb42ef61c faraday-mashify (1.0.2) sha256=ebdc93b3f807af9a4b18c3d967fc7bcda01e297aed9cc2015ffe8db6e7add2e9 faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 diff --git a/db/seeds.rb b/db/seeds.rb index 4dd3442f..f133f618 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,141 +1,6 @@ -puts "Seeding users..." -hampton = CoPlan::User.find_or_create_by!(email: "hampton@squareup.com") do |u| - u.external_id = "hampton@squareup.com" - u.name = "Hampton Lintorn-Catlin" - u.username = "hampton" - u.admin = true - u.avatar_url = "https://avatars.githubusercontent.com/u/111?s=80" - u.title = "Staff Engineer" - u.team = "Developer Tools" +if Rails.env.local? + require_relative "seeds/development" + CoPlan::DevelopmentSeed.call +else + warn "Development seed data is only available in development and test environments." end -hampton.update!(avatar_url: "https://avatars.githubusercontent.com/u/111?s=80") if hampton.avatar_url.blank? - -puts "Seeding plans..." -if CoPlan::Plan.count == 0 - plan = CoPlan::Plans::Create.call( - title: "Q3 Product Roadmap", - content: "# Q3 Product Roadmap\n\n## Goals\n\n- Launch new dashboard\n- Improve API performance\n- Add team collaboration features\n\n## Timeline\n\n### Month 1\n- Design reviews\n- Technical planning\n\n### Month 2\n- Core implementation\n- Testing\n\n### Month 3\n- Beta launch\n- Feedback collection\n", - user: hampton - ) - plan.update!(status: "considering") -end - -puts "Seeding comments..." -if CoPlan::CommentThread.count == 0 - plan = CoPlan::Plan.first - if plan&.current_plan_version - reviewer = CoPlan::User.find_or_create_by!(email: "reviewer@squareup.com") do |u| - u.external_id = "reviewer@squareup.com" - u.name = "Plan Reviewer" - u.title = "Senior Engineer" - u.team = "Platform" - end - - thread = CoPlan::CommentThread.create!( - plan: plan, - plan_version: plan.current_plan_version, - start_line: 5, - end_line: 8, - created_by_user: reviewer - ) - thread.comments.create!( - author_type: "human", - author_id: reviewer.id, - body_markdown: "I think the timeline for Month 1 is too aggressive. Can we break this into smaller milestones?" - ) - - general_thread = CoPlan::CommentThread.create!( - plan: plan, - plan_version: plan.current_plan_version, - created_by_user: hampton - ) - general_thread.comments.create!( - author_type: "human", - author_id: hampton.id, - body_markdown: "Overall this is looking good. Let's move forward with the **beta launch** plan." - ) - end -end - -puts "Seeding API tokens..." -if CoPlan::ApiToken.count == 0 - raw_token = "dev-api-token-#{SecureRandom.hex(8)}" - CoPlan::ApiToken.create!( - user: hampton, - name: "Development Agent", - token_digest: Digest::SHA256.hexdigest(raw_token), - token_prefix: raw_token[0, 8] - ) - puts " Created API token: #{raw_token}" - puts " (Save this — it won't be shown again)" -end - -puts "Seeding plan types..." -# Icons come from the built-in named set (CoPlan::PlansHelper::PLAN_TYPE_ICONS) -# so each install can brand its document types. -general = CoPlan::PlanType.find_or_create_by!(name: "General") do |pt| - pt.description = "General-purpose plan" -end -general.update!(icon: "file-text") if general.icon.blank? -CoPlan::PlanType.find_or_create_by!(name: "RFC") do |pt| - pt.description = "Request for Comments — propose a significant change for team review" - pt.default_tags = ["rfc"] -end.tap { |pt| pt.update!(icon: "scroll") if pt.icon.blank? } -CoPlan::PlanType.find_or_create_by!(name: "Design Doc") do |pt| - pt.description = "Technical design document for a new system or feature" - pt.default_tags = ["design"] -end.tap { |pt| pt.update!(icon: "compass") if pt.icon.blank? } -CoPlan::PlanType.find_or_create_by!(name: "ADR") do |pt| - pt.description = "Architecture Decision Record — document a key technical decision" - pt.default_tags = ["adr"] -end.tap { |pt| pt.update!(icon: "scale") if pt.icon.blank? } - -# Backfill any plans without a plan type -CoPlan::Plan.where(plan_type_id: nil).update_all(plan_type_id: general.id) - -puts "Seeding tags on plans..." -CoPlan::Plan.includes(:tags, :plan_type).find_each do |p| - if p.tags.empty? && p.plan_type&.default_tags&.any? - p.tag_names = p.plan_type.default_tags - end -end - -# Add some demo plans with tags for local development -if CoPlan::Plan.count < 3 - rfc_type = CoPlan::PlanType.find_by(name: "RFC") - design_type = CoPlan::PlanType.find_by(name: "Design Doc") - - api_plan = CoPlan::Plans::Create.call( - title: "API Rate Limiting Strategy", - content: "# API Rate Limiting Strategy\n\n## Problem\n\nOur API endpoints have no rate limiting, leading to occasional abuse.\n\n## Proposal\n\nImplement token-bucket rate limiting at the gateway level.\n\n## Alternatives Considered\n\n- Per-IP limiting\n- API key quotas\n", - user: hampton, - plan_type_id: rfc_type&.id - ) - api_plan.update!(status: "considering") - api_plan.tag_names = ["api", "infrastructure", "security"] - - auth_plan = CoPlan::Plans::Create.call( - title: "Authentication System Redesign", - content: "# Authentication System Redesign\n\n## Goals\n\n- Migrate from session-based to token-based auth\n- Support SSO providers\n- Improve security posture\n\n## Architecture\n\nOIDC-based flow with JWT access tokens.\n", - user: hampton, - plan_type_id: design_type&.id - ) - auth_plan.update!(status: "developing") - auth_plan.tag_names = ["security", "infrastructure", "design"] -end - -# Tag the original Q3 roadmap if it has no tags -q3 = CoPlan::Plan.find_by(title: "Q3 Product Roadmap") -q3.tag_names = ["roadmap", "product"] if q3 && q3.tags.empty? - -puts "Seeding folders..." -if CoPlan::Folder.count == 0 - product = CoPlan::Folder.find_or_create_by_path!("Product/Q3", created_by_user: hampton) - infra = CoPlan::Folder.find_or_create_by_path!("Infrastructure", created_by_user: hampton) - - q3&.update!(folder: product) if q3&.folder_id.nil? - api_seed = CoPlan::Plan.find_by(title: "API Rate Limiting Strategy") - api_seed&.update!(folder: infra) if api_seed&.folder_id.nil? -end - -puts "Done! #{CoPlan::User.count} users, #{CoPlan::Plan.count} plans, #{CoPlan::Folder.count} folders, #{CoPlan::CommentThread.count} threads, #{CoPlan::Comment.count} comments, #{CoPlan::ApiToken.count} API tokens." diff --git a/db/seeds/development.rb b/db/seeds/development.rb new file mode 100644 index 00000000..b14d7642 --- /dev/null +++ b/db/seeds/development.rb @@ -0,0 +1,264 @@ +require "faker" + +module CoPlan + # A deterministic, idempotent dataset for exercising CoPlan's development UI. + # Existing seed documents keep local content edits; missing records are restored + # on the next run and seed-owned metadata remains consistent. + module DevelopmentSeed + module_function + + USERS = [ + { key: "alex", locale: :en, admin: true }, + { key: "priya", locale: :en }, + { key: "mateo", locale: :es }, + { key: "aiko", locale: :ja }, + { key: "noura", locale: :ar }, + { key: "sam", locale: :en } + ].freeze + + PLAN_TYPES = [ + { name: "General", icon: "file-text", description: "A flexible document for notes and proposals", default_tags: [] }, + { name: "RFC", icon: "scroll", description: "A request for comments on a significant change", default_tags: [ "rfc" ] }, + { name: "Design Doc", icon: "compass", description: "Technical design for a system or feature", default_tags: [ "design" ] }, + { name: "ADR", icon: "scale", description: "A durable architecture decision record", default_tags: [ "architecture" ] }, + { name: "Product Brief", icon: "lightbulb", description: "Product context, goals, and measures of success", default_tags: [ "product" ] }, + { name: "Runbook", icon: "wrench", description: "Operational diagnosis and recovery steps", default_tags: [ "operations" ] }, + { name: "Research Note", icon: "flask", description: "Findings, evidence, and open questions", default_tags: [ "research" ] }, + { name: "Roadmap", icon: "map", description: "Sequenced outcomes and milestones", default_tags: [ "roadmap" ] } + ].freeze + + DOCUMENTS = [ + { + key: "one-line-decision", author: "alex", type: "ADR", title: "Use UUIDv7 identifiers", + tags: %w[architecture database], visibility: "published", folder: "Engineering/Architecture decisions", sections: 0 + }, + { + key: "api-gateway", author: "alex", type: "RFC", title: "RFC: Consolidating edge authentication in the API gateway", + tags: %w[api security infrastructure], visibility: "published", folder: "Engineering/Active projects", fixture: :flowchart + }, + { + key: "mobile-checkout", author: "mateo", type: "Design Doc", title: "Mobile checkout: resilient state transitions when the network disappears", + tags: %w[mobile reliability payments], visibility: "published", folder: "Product/Mobile", fixture: :state_diagram + }, + { + key: "spanish-brief", author: "mateo", type: "Product Brief", title: "Mejoras para la experiencia de incorporación", + tags: %w[onboarding product localization], visibility: "published", folder: "Product/Discovery", fixture: :spanish + }, + { + key: "japanese-roadmap", author: "aiko", type: "Roadmap", title: "信頼性向上ロードマップ — 2027年前半", + tags: %w[reliability roadmap], visibility: "published", folder: "Operations/Reliability", fixture: :japanese + }, + { + key: "arabic-research", author: "noura", type: "Research Note", title: "بحث: تقليل مخاطر سرقة الجلسات", + tags: %w[security research authentication], visibility: "published", folder: "Research/Security", fixture: :arabic + }, + { + key: "incident-runbook", author: "aiko", type: "Runbook", title: "Payments API latency incident runbook", + tags: %w[operations payments on-call], visibility: "published", folder: "Operations/Runbooks" + }, + { + key: "experiment-results", author: "sam", type: "Research Note", title: "Search ranking experiment #42", + tags: %w[search experimentation data], visibility: "published", folder: "Research/Experiments", fixture: :table + }, + { + key: "draft-notes", author: "priya", type: "General", title: "Untitled thoughts on activation", + tags: %w[product draft], visibility: "draft", folder: "Personal notes", sections: 1 + }, + { + key: "archived-proposal", author: "alex", type: "RFC", title: "Retired proposal: weekly XML exports", + tags: %w[archive integrations], visibility: "published", archived: true, folder: "Engineering/Archive", sections: 1 + }, + { + key: "emoji-title", author: "priya", type: "Product Brief", title: "Faster feedback loops ⚡", + tags: %w[collaboration product], visibility: "published", folder: "Product/Discovery" + }, + { + key: "long-title", author: "noura", type: "Design Doc", + title: "Designing a privacy-preserving audit trail for delegated administrative actions across regional data boundaries", + tags: %w[security privacy compliance], visibility: "published", folder: "Engineering/Architecture decisions", fixture: :sequence_diagram + }, + { + key: "long-mobile-toc", author: "priya", type: "Product Brief", title: "The complete guide to launching shared workspaces across web, iOS, and Android", + tags: %w[collaboration mobile launch], visibility: "published", folder: "Product/Launches/Shared workspace", long: true + } + ].freeze + + CONTENT_FIXTURES = { + flowchart: <<~MARKDOWN, + ```mermaid + flowchart LR + Client --> Gateway + Gateway --> API + API --> DB[(Database)] + ``` + MARKDOWN + state_diagram: <<~MARKDOWN, + ```mermaid + stateDiagram-v2 + [*] --> Editing + Editing --> Submitting + Submitting --> Confirmed + Confirmed --> [*] + ``` + MARKDOWN + sequence_diagram: <<~MARKDOWN, + ```mermaid + sequenceDiagram + participant A as Administrator + participant S as Service + A->>S: Delegated action + S-->>A: Receipt ID + ``` + MARKDOWN + table: <<~MARKDOWN, + | Metric | Control | Treatment | + | --- | ---: | ---: | + | Success | 61.2% | 62.9% | + | Errors | 18.4% | 17.7% | + MARKDOWN + spanish: "## Problema\n\nLas personas nuevas necesitan saber qué paso completar.\n\n## Resultado\n\nUna lista breve muestra el siguiente paso.", + japanese: "## 目標\n\n障害の影響を小さくし、復旧までの時間を短縮します。\n\n## 次のステップ\n\n復旧手順を自動で検証します。", + arabic: "## الملخص\n\nتقارن هذه المذكرة بين الجلسات قصيرة العمر وتدوير الرموز.\n\n## الخطوة التالية\n\nتشغيل تجربة محكومة لقياس الأمان." + }.freeze + + LONG_DOCUMENT_SECTIONS = [ + "Executive summary", "Customer problem", "Audience", "Principles", "Goals", "Non-goals", + "Jobs to be done", "Information architecture", "Web navigation", "iOS navigation", "Android navigation", + "Small-screen table of contents", "Workspace creation", "Invitations", "Roles and permissions", + "Document placement", "Folder behavior", "Search", "Activity", "Notifications", "Empty states", + "Loading and offline states", "Accessibility", "Localization", "Analytics", "Privacy", "Performance budgets", + "Migration", "Rollout", "Support readiness", "Risks", "Open questions", "Decision log", "Appendix: terminology" + ].freeze + + def call + puts "Seeding reusable development data..." + with_reproducible_faker do + users = seed_users + plan_types = seed_plan_types + plans = seed_documents(users, plan_types) + seed_shared_library_examples(users, plans) + end + + puts "Done! #{User.count} users, #{Plan.count} documents, #{Folder.count} folders, #{Tag.count} tags, and #{PlanType.count} document types." + end + + def seed_users + USERS.index_with do |attributes| + user = User.find_or_initialize_by(external_id: "seed:#{attributes.fetch(:key)}") + Faker::Config.locale = attributes.fetch(:locale) + name = Faker::Name.name + Faker::Config.locale = :en + user.assign_attributes( + name: name, + username: attributes.fetch(:key), + email: Faker::Internet.unique.email(name: name), + title: Faker::Job.title, + team: Faker::Company.industry, + admin: attributes.fetch(:admin, false), + metadata: user.metadata.to_h.merge("development_seed" => true) + ) + user.save! + user.library + user + end.transform_keys { |attributes| attributes.fetch(:key) } + end + + def seed_plan_types + PLAN_TYPES.index_with do |attributes| + PlanType.find_or_initialize_by(name: attributes.fetch(:name)).tap do |plan_type| + plan_type.assign_attributes(attributes) + plan_type.save! + end + end.transform_keys { |attributes| attributes.fetch(:name) } + end + + def seed_documents(users, plan_types) + DOCUMENTS.index_with do |definition| + author = users.fetch(definition.fetch(:author)) + plan = find_seed_document(definition.fetch(:key), author) + + unless plan + plan = Plans::Create.call( + title: definition.fetch(:title), + content: document_content(definition), + user: author, + plan_type_id: plan_types.fetch(definition.fetch(:type)).id, + visibility: definition.fetch(:visibility) + ) + plan.update!( + archived_at: definition[:archived] ? Time.current : nil, + metadata: plan.metadata.to_h.merge("development_seed_key" => definition.fetch(:key)) + ) + end + + plan.tag_names = definition.fetch(:tags) + place(plan, definition.fetch(:folder), author) + plan + end.transform_keys { |definition| definition.fetch(:key) } + end + + def find_seed_document(key, author) + author.created_plans.detect { |plan| plan.metadata.to_h["development_seed_key"] == key } + end + + def place(plan, path, user) + folder = Folder.find_or_create_by_path!(path, library: user.library, created_by_user: user) + result = Plans::Place.call(plan: plan, folder: folder, actor: user) + raise result.error unless result.success? + end + + def seed_shared_library_examples(users, plans) + # Demonstrate that a published document can sit on someone else's shelf + # without changing the author's organization. + place(plans.fetch("api-gateway"), "Reading list/Security", users.fetch("noura")) + place(plans.fetch("experiment-results"), "Research to discuss", users.fetch("alex")) + end + + def document_content(definition) + return long_document_content if definition[:long] + + fixture = CONTENT_FIXTURES[definition[:fixture]] + parts = [ "# #{definition.fetch(:title)}" ] + + if %i[spanish japanese arabic].include?(definition[:fixture]) + parts << fixture + return parts.join("\n\n") + end + + parts << Faker::Lorem.paragraph(sentence_count: 3) + definition.fetch(:sections, 3).times do + heading = Faker::Lorem.words(number: 3).join(" ").capitalize + parts << "## #{heading}\n\n#{Faker::Lorem.paragraph(sentence_count: 3)}" + end + parts << fixture if fixture + parts.join("\n\n") + end + + def long_document_content + sections = LONG_DOCUMENT_SECTIONS.map do |heading| + "## #{heading}\n\n#{Faker::Lorem.paragraph(sentence_count: 3)}" + end.join("\n\n") + + <<~MARKDOWN + # The complete guide to launching shared workspaces + + #{Faker::Lorem.paragraph(sentence_count: 3)} + + #{sections} + MARKDOWN + end + + def with_reproducible_faker + previous_random = Faker::Config.random + previous_locale = Faker::Config.locale + Faker::Config.random = Random.new(12_345) + Faker::Config.locale = :en + Faker::UniqueGenerator.clear + yield + ensure + Faker::Config.random = previous_random + Faker::Config.locale = previous_locale + Faker::UniqueGenerator.clear + end + end +end diff --git a/spec/lib/development_seed_spec.rb b/spec/lib/development_seed_spec.rb new file mode 100644 index 00000000..593e8ba6 --- /dev/null +++ b/spec/lib/development_seed_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" +require Rails.root.join("db/seeds/development") + +RSpec.describe CoPlan::DevelopmentSeed do + describe ".call" do + it "creates a varied, idempotent development dataset" do + described_class.call + + counts = [ + CoPlan::User.count, + CoPlan::Plan.count, + CoPlan::Folder.count, + CoPlan::Tag.count, + CoPlan::PlanType.count, + CoPlan::PlanPlacement.count + ] + generated_users = CoPlan::User.order(:external_id).pluck(:name, :email, :title, :team) + + described_class.call + + expect([ + CoPlan::User.count, + CoPlan::Plan.count, + CoPlan::Folder.count, + CoPlan::Tag.count, + CoPlan::PlanType.count, + CoPlan::PlanPlacement.count + ]).to eq(counts) + expect(CoPlan::User.order(:external_id).pluck(:name, :email, :title, :team)).to eq(generated_users) + + seeded_plans = CoPlan::Plan.select { |plan| plan.metadata.to_h.key?("development_seed_key") } + expect(seeded_plans.size).to be >= 12 + expect(seeded_plans.map(&:visibility)).to include("draft", "published") + expect(seeded_plans).to include(be_archived) + expect(seeded_plans.map { |plan| plan.plan_type.name }.uniq.size).to be >= 7 + expect(seeded_plans.flat_map(&:tag_names).uniq.size).to be >= 12 + expect(seeded_plans.map(&:title)).to include(a_string_matching(/信頼性/), a_string_matching(/تقليل/)) + + long_document = seeded_plans.find { |plan| plan.metadata["development_seed_key"] == "long-mobile-toc" } + expect(long_document.current_content.scan(/^## /).size).to be >= 30 + expect(seeded_plans.count { |plan| plan.current_content.include?("```mermaid") }).to be >= 3 + expect(CoPlan::Folder.where.not(parent_id: nil)).to exist + end + + it "preserves edits to existing seed document content and titles" do + described_class.call + plan = CoPlan::Plan.find { |candidate| candidate.metadata.to_h["development_seed_key"] == "api-gateway" } + plan.update!(title: "Locally edited title") + plan.current_plan_version.update!(content_markdown: "# Locally edited content", content_sha256: nil) + + described_class.call + + expect(plan.reload.title).to eq("Locally edited title") + expect(plan.current_content).to eq("# Locally edited content") + end + end +end