diff --git a/app/admin/plan_types.rb b/app/admin/plan_types.rb new file mode 100644 index 0000000..a670e4f --- /dev/null +++ b/app/admin/plan_types.rb @@ -0,0 +1,25 @@ +ActiveAdmin.register CoPlan::PlanType, as: "PlanType" do + permit_params :name, :description, :template_content + + index do + selectable_column + id_column + column :name + column :description + column :created_at + actions + end + + show do + attributes_table do + row :id + row :name + row :description + row :default_tags + row :template_content + row :metadata + row :created_at + row :updated_at + end + end +end diff --git a/app/admin/plans.rb b/app/admin/plans.rb index 91d61bd..dd4be1f 100644 --- a/app/admin/plans.rb +++ b/app/admin/plans.rb @@ -1,6 +1,12 @@ ActiveAdmin.register CoPlan::Plan, as: "Plan" do permit_params :title, :status + filter :title + filter :status, as: :select, collection: CoPlan::Plan::STATUSES + filter :plan_type, as: :select + filter :created_at + filter :updated_at + index do selectable_column id_column diff --git a/db/schema.rb b/db/schema.rb index 0e5a8a1..2d39b1b 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.1].define(version: 2026_04_03_195620) do +ActiveRecord::Schema[8.1].define(version: 2026_04_03_202530) do create_table "active_admin_comments", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "author_id" t.string "author_type" @@ -148,6 +148,17 @@ t.index ["user_id"], name: "index_coplan_plan_collaborators_on_user_id" end + create_table "coplan_plan_types", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.json "default_tags" + t.text "description" + t.json "metadata" + t.string "name", null: false + t.text "template_content" + t.datetime "updated_at", null: false + t.index ["name"], name: "index_coplan_plan_types_on_name", unique: true + end + create_table "coplan_plan_versions", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.string "actor_id", limit: 36 t.string "actor_type", null: false @@ -186,12 +197,14 @@ t.string "current_plan_version_id", limit: 36 t.integer "current_revision", default: 0, null: false t.json "metadata" + t.string "plan_type_id", limit: 36 t.string "status", default: "brainstorm", null: false t.json "tags" t.string "title", null: false t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_coplan_plans_on_created_by_user_id" t.index ["current_plan_version_id"], name: "fk_rails_c401577583" + t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["status"], name: "index_coplan_plans_on_status" t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" end @@ -233,6 +246,7 @@ add_foreign_key "coplan_plan_versions", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_viewers", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_viewers", "coplan_users", column: "user_id" + add_foreign_key "coplan_plans", "coplan_plan_types", column: "plan_type_id" add_foreign_key "coplan_plans", "coplan_plan_versions", column: "current_plan_version_id" add_foreign_key "coplan_plans", "coplan_users", column: "created_by_user_id" end diff --git a/db/seeds.rb b/db/seeds.rb index 9891922..0368308 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -63,6 +63,26 @@ puts " (Save this — it won't be shown again)" end +puts "Seeding plan types..." +general = CoPlan::PlanType.find_or_create_by!(name: "General") do |pt| + pt.description = "General-purpose plan" +end +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 +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 +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 + +# Backfill any plans without a plan type +CoPlan::Plan.where(plan_type_id: nil).update_all(plan_type_id: general.id) + puts "Seeding automated plan reviewers..." CoPlan::AutomatedPlanReviewer.create_defaults diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index fe801d0..5947158 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -7,6 +7,7 @@ class PlansController < BaseController def index plans = Plan + .includes(:plan_type, :created_by_user) .where.not(status: "brainstorm") .or(Plan.where(created_by_user: current_user)) .order(updated_at: :desc) @@ -25,7 +26,8 @@ def create plan = Plans::Create.call( title: params[:title], content: params[:content] || "", - user: current_user + user: current_user, + plan_type_id: params[:plan_type_id].presence ) render json: plan_json(plan).merge( current_content: plan.current_content, @@ -33,6 +35,8 @@ def create ), status: :created rescue ActiveRecord::RecordInvalid => e render json: { error: e.message }, status: :unprocessable_content + rescue ActiveRecord::InvalidForeignKey + render json: { error: "Invalid plan_type_id" }, status: :unprocessable_content end def update @@ -83,6 +87,8 @@ def plan_json(plan) status: plan.status, current_revision: plan.current_revision, tags: plan.tags, + plan_type_id: plan.plan_type_id, + plan_type_name: plan.plan_type&.name, created_by: plan.created_by_user.name, created_at: plan.created_at, updated_at: plan.updated_at diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 300cabc..c1e3015 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -4,6 +4,7 @@ class Plan < ApplicationRecord belongs_to :created_by_user, class_name: "CoPlan::User" belongs_to :current_plan_version, class_name: "PlanVersion", optional: true + belongs_to :plan_type, optional: true has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy has_many :plan_collaborators, dependent: :destroy has_many :collaborators, through: :plan_collaborators, source: :user @@ -19,6 +20,14 @@ class Plan < ApplicationRecord validates :title, presence: true validates :status, presence: true, inclusion: { in: STATUSES } + def self.ransackable_attributes(auth_object = nil) + %w[id title status plan_type_id created_by_user_id current_plan_version_id current_revision created_at updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[plan_type created_by_user] + end + def to_param id end diff --git a/engine/app/models/coplan/plan_type.rb b/engine/app/models/coplan/plan_type.rb new file mode 100644 index 0000000..2d0c52d --- /dev/null +++ b/engine/app/models/coplan/plan_type.rb @@ -0,0 +1,18 @@ +module CoPlan + class PlanType < ApplicationRecord + has_many :plans, dependent: :nullify + + after_initialize { self.default_tags ||= [] } + after_initialize { self.metadata ||= {} } + + validates :name, presence: true, uniqueness: true + + def self.ransackable_attributes(auth_object = nil) + %w[id name description template_content created_at updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[plans] + end + end +end diff --git a/engine/app/services/coplan/plans/create.rb b/engine/app/services/coplan/plans/create.rb index e62d7f3..c5a736a 100644 --- a/engine/app/services/coplan/plans/create.rb +++ b/engine/app/services/coplan/plans/create.rb @@ -1,19 +1,20 @@ module CoPlan module Plans class Create - def self.call(title:, content:, user:) - new(title:, content:, user:).call + def self.call(title:, content:, user:, plan_type_id: nil) + new(title:, content:, user:, plan_type_id:).call end - def initialize(title:, content:, user:) + def initialize(title:, content:, user:, plan_type_id: nil) @title = title @content = content @user = user + @plan_type_id = plan_type_id end def call ActiveRecord::Base.transaction do - plan = Plan.create!(title: @title, created_by_user: @user) + plan = Plan.create!(title: @title, created_by_user: @user, plan_type_id: @plan_type_id) version = PlanVersion.create!( plan: plan, revision: 1, diff --git a/engine/db/migrate/20260403000000_create_coplan_plan_types.rb b/engine/db/migrate/20260403000000_create_coplan_plan_types.rb new file mode 100644 index 0000000..67d7530 --- /dev/null +++ b/engine/db/migrate/20260403000000_create_coplan_plan_types.rb @@ -0,0 +1,18 @@ +class CreateCoplanPlanTypes < ActiveRecord::Migration[8.1] + def change + create_table :coplan_plan_types, id: { type: :string, limit: 36 } do |t| + t.string :name, null: false + t.text :description + t.json :default_tags + t.text :template_content + t.json :metadata + t.timestamps + end + + add_index :coplan_plan_types, :name, unique: true + + add_column :coplan_plans, :plan_type_id, :string, limit: 36 + add_index :coplan_plans, :plan_type_id + add_foreign_key :coplan_plans, :coplan_plan_types, column: :plan_type_id + end +end diff --git a/engine/db/migrate/20260403100000_seed_general_plan_type.rb b/engine/db/migrate/20260403100000_seed_general_plan_type.rb new file mode 100644 index 0000000..6f10d8f --- /dev/null +++ b/engine/db/migrate/20260403100000_seed_general_plan_type.rb @@ -0,0 +1,22 @@ +class SeedGeneralPlanType < ActiveRecord::Migration[8.1] + def up + general_id = SecureRandom.uuid_v7 + execute <<~SQL + INSERT INTO coplan_plan_types (id, name, description, default_tags, template_content, metadata, created_at, updated_at) + VALUES (#{quote(general_id)}, 'General', 'General-purpose plan', '[]', NULL, '{}', NOW(), NOW()) + SQL + + execute <<~SQL + UPDATE coplan_plans SET plan_type_id = #{quote(general_id)} WHERE plan_type_id IS NULL + SQL + end + + def down + general = execute("SELECT id FROM coplan_plan_types WHERE name = 'General' LIMIT 1") + if general.any? + general_id = general.first[0] + execute("UPDATE coplan_plans SET plan_type_id = NULL WHERE plan_type_id = #{quote(general_id)}") + execute("DELETE FROM coplan_plan_types WHERE id = #{quote(general_id)}") + end + end +end diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 24662e5..c665715 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -7,6 +7,7 @@ class Configuration attr_accessor :onboarding_banner attr_accessor :agent_auth_instructions attr_accessor :agent_curl_prefix + attr_accessor :seed_plan_types # Lambda for user search used by the /api/v1/users/search endpoint. # Accepts a query string, returns an array of hashes with keys: @@ -28,6 +29,7 @@ def initialize @notification_handler = nil @onboarding_banner = 'Want to upload Agentic plans? Give your agent these instructions.' @agent_curl_prefix = 'curl -s -H "Authorization: Bearer $TOKEN"' + @seed_plan_types = [] @agent_auth_instructions = <<~MARKDOWN ## Authentication diff --git a/spec/factories/plan_types.rb b/spec/factories/plan_types.rb new file mode 100644 index 0000000..d450a2b --- /dev/null +++ b/spec/factories/plan_types.rb @@ -0,0 +1,9 @@ +FactoryBot.define do + factory :plan_type, class: "CoPlan::PlanType" do + sequence(:name) { |n| "Plan Type #{n}" } + description { "A plan type for testing" } + default_tags { [] } + template_content { "# Template\n\nDefault content." } + metadata { {} } + end +end diff --git a/spec/models/plan_type_spec.rb b/spec/models/plan_type_spec.rb new file mode 100644 index 0000000..82ba5d2 --- /dev/null +++ b/spec/models/plan_type_spec.rb @@ -0,0 +1,44 @@ +require "rails_helper" + +RSpec.describe CoPlan::PlanType, type: :model do + it "is valid with valid attributes" do + plan_type = create(:plan_type) + expect(plan_type).to be_valid + end + + it "requires name" do + plan_type = build(:plan_type, name: nil) + expect(plan_type).not_to be_valid + expect(plan_type.errors[:name]).to include("can't be blank") + end + + it "validates name uniqueness" do + create(:plan_type, name: "RFC") + duplicate = build(:plan_type, name: "RFC") + expect(duplicate).not_to be_valid + expect(duplicate.errors[:name]).to include("has already been taken") + end + + it "defaults default_tags to empty array" do + plan_type = CoPlan::PlanType.new + expect(plan_type.default_tags).to eq([]) + end + + it "defaults metadata to empty hash" do + plan_type = CoPlan::PlanType.new + expect(plan_type.metadata).to eq({}) + end + + it "has many plans" do + plan_type = create(:plan_type) + plan = create(:plan, plan_type: plan_type) + expect(plan_type.plans).to include(plan) + end + + it "nullifies plans when destroyed" do + plan_type = create(:plan_type) + plan = create(:plan, plan_type: plan_type) + plan_type.destroy! + expect(plan.reload.plan_type_id).to be_nil + end +end diff --git a/spec/requests/api/v1/plans_spec.rb b/spec/requests/api/v1/plans_spec.rb index 1bc52d1..947cc0f 100644 --- a/spec/requests/api/v1/plans_spec.rb +++ b/spec/requests/api/v1/plans_spec.rb @@ -65,6 +65,22 @@ expect(body["current_revision"]).to eq(1) end + it "create with plan_type_id" do + plan_type = create(:plan_type) + post api_v1_plans_path, params: { title: "Typed Plan", content: "# Typed", plan_type_id: plan_type.id }, headers: headers, as: :json + expect(response).to have_http_status(:created) + body = JSON.parse(response.body) + expect(body["plan_type_id"]).to eq(plan_type.id) + expect(body["plan_type_name"]).to eq(plan_type.name) + end + + it "create with invalid plan_type_id returns 422" do + post api_v1_plans_path, params: { title: "Bad Type", plan_type_id: "nonexistent-id" }, headers: headers, as: :json + expect(response).to have_http_status(:unprocessable_content) + body = JSON.parse(response.body) + expect(body["error"]).to include("plan_type_id") + end + it "create without title fails" do post api_v1_plans_path, params: { content: "no title" }, headers: headers, as: :json expect(response).to have_http_status(:unprocessable_content) diff --git a/spec/services/plans/create_spec.rb b/spec/services/plans/create_spec.rb index d17c618..21ec9e9 100644 --- a/spec/services/plans/create_spec.rb +++ b/spec/services/plans/create_spec.rb @@ -23,4 +23,30 @@ expect(version.actor_id).to eq(user.id) expect(version.content_sha256).to be_present end + + it "creates plan with plan_type" do + user = create(:coplan_user) + plan_type = create(:plan_type) + plan = CoPlan::Plans::Create.call( + title: "Typed Plan", + content: "# Typed", + user: user, + plan_type_id: plan_type.id + ) + + expect(plan).to be_persisted + expect(plan.plan_type).to eq(plan_type) + end + + it "creates plan without plan_type" do + user = create(:coplan_user) + plan = CoPlan::Plans::Create.call( + title: "Untyped Plan", + content: "# Untyped", + user: user + ) + + expect(plan).to be_persisted + expect(plan.plan_type_id).to be_nil + end end