diff --git a/app/admin/plans.rb b/app/admin/plans.rb index dd4be1f..14aa310 100644 --- a/app/admin/plans.rb +++ b/app/admin/plans.rb @@ -25,7 +25,7 @@ row :status row :current_revision row :created_by_user - row :tags + row(:tags) { |plan| plan.tag_names.join(", ") } row :metadata row :created_at row :updated_at diff --git a/app/admin/tags.rb b/app/admin/tags.rb new file mode 100644 index 0000000..a65cecb --- /dev/null +++ b/app/admin/tags.rb @@ -0,0 +1,31 @@ +ActiveAdmin.register CoPlan::Tag, as: "Tag" do + permit_params :name + + index do + selectable_column + id_column + column :name + column :plans_count + column :created_at + actions + end + + show do + attributes_table do + row :id + row :name + row :plans_count + row :created_at + row :updated_at + end + + panel "Plans" do + table_for resource.plans.order(updated_at: :desc) do + column :id + column :title + column :status + column :updated_at + end + end + end +end diff --git a/db/migrate/20260403213228_create_coplan_plan_types.co_plan.rb b/db/migrate/20260403213228_create_coplan_plan_types.co_plan.rb new file mode 100644 index 0000000..013b280 --- /dev/null +++ b/db/migrate/20260403213228_create_coplan_plan_types.co_plan.rb @@ -0,0 +1,19 @@ +# This migration comes from co_plan (originally 20260403000000) +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/db/migrate/20260403213229_seed_general_plan_type.co_plan.rb b/db/migrate/20260403213229_seed_general_plan_type.co_plan.rb new file mode 100644 index 0000000..08f3019 --- /dev/null +++ b/db/migrate/20260403213229_seed_general_plan_type.co_plan.rb @@ -0,0 +1,23 @@ +# This migration comes from co_plan (originally 20260403100000) +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/db/migrate/20260403220000_create_coplan_tags.co_plan.rb b/db/migrate/20260403220000_create_coplan_tags.co_plan.rb new file mode 100644 index 0000000..90b429f --- /dev/null +++ b/db/migrate/20260403220000_create_coplan_tags.co_plan.rb @@ -0,0 +1,23 @@ +# This migration comes from co_plan (originally 20260403000000) +class CreateCoplanTags < ActiveRecord::Migration[8.1] + def change + create_table :coplan_tags, id: { type: :string, limit: 36 } do |t| + t.string :name, null: false + t.integer :plans_count, default: 0, null: false + t.timestamps + end + + add_index :coplan_tags, :name, unique: true + + create_table :coplan_plan_tags, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :tag_id, limit: 36, null: false + t.timestamps + end + + add_index :coplan_plan_tags, [:plan_id, :tag_id], unique: true + add_index :coplan_plan_tags, :tag_id + add_foreign_key :coplan_plan_tags, :coplan_plans, column: :plan_id + add_foreign_key :coplan_plan_tags, :coplan_tags, column: :tag_id + end +end diff --git a/db/migrate/20260403220001_backfill_structured_tags.co_plan.rb b/db/migrate/20260403220001_backfill_structured_tags.co_plan.rb new file mode 100644 index 0000000..af0a737 --- /dev/null +++ b/db/migrate/20260403220001_backfill_structured_tags.co_plan.rb @@ -0,0 +1,40 @@ +# This migration comes from co_plan (originally 20260403100000) +class BackfillStructuredTags < ActiveRecord::Migration[8.1] + def up + # Backfill Tag + PlanTag records from JSON tags column + execute(<<~SQL).each do |row| + SELECT id, tags FROM coplan_plans WHERE tags IS NOT NULL + SQL + plan_id = row[0] || row["id"] + raw = row[1] || row["tags"] + next if raw.blank? + + parsed = raw.is_a?(String) ? JSON.parse(raw) : raw + next unless parsed.is_a?(Array) + + parsed.each do |name| + name = name.to_s.strip + next if name.blank? + + tag_id = SecureRandom.uuid_v7 + execute "INSERT IGNORE INTO coplan_tags (id, name, plans_count, created_at, updated_at) VALUES (#{quote(tag_id)}, #{quote(name)}, 0, NOW(), NOW())" + actual_tag_id = select_value("SELECT id FROM coplan_tags WHERE name = #{quote(name)}") + pt_id = SecureRandom.uuid_v7 + execute "INSERT IGNORE INTO coplan_plan_tags (id, plan_id, tag_id, created_at, updated_at) VALUES (#{quote(pt_id)}, #{quote(plan_id)}, #{quote(actual_tag_id)}, NOW(), NOW())" + end + end + + # Reset counter caches + execute <<~SQL + UPDATE coplan_tags SET plans_count = ( + SELECT COUNT(*) FROM coplan_plan_tags WHERE coplan_plan_tags.tag_id = coplan_tags.id + ) + SQL + + remove_column :coplan_plans, :tags + end + + def down + add_column :coplan_plans, :tags, :json + end +end diff --git a/db/schema.rb b/db/schema.rb index 2d39b1b..b94a5cd 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_202530) do +ActiveRecord::Schema[8.1].define(version: 2026_04_03_220001) 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,15 @@ t.index ["user_id"], name: "index_coplan_plan_collaborators_on_user_id" end + create_table "coplan_plan_tags", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "plan_id", limit: 36, null: false + t.string "tag_id", limit: 36, null: false + t.datetime "updated_at", null: false + t.index ["plan_id", "tag_id"], name: "index_coplan_plan_tags_on_plan_id_and_tag_id", unique: true + t.index ["tag_id"], name: "index_coplan_plan_tags_on_tag_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" @@ -199,7 +208,6 @@ 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" @@ -209,6 +217,14 @@ t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" end + create_table "coplan_tags", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "name", null: false + t.integer "plans_count", default: 0, null: false + t.datetime "updated_at", null: false + t.index ["name"], name: "index_coplan_tags_on_name", unique: true + end + create_table "coplan_users", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.boolean "admin", default: false, null: false t.string "avatar_url" @@ -243,6 +259,8 @@ add_foreign_key "coplan_plan_collaborators", "coplan_plans", column: "plan_id" add_foreign_key "coplan_plan_collaborators", "coplan_users", column: "added_by_user_id" add_foreign_key "coplan_plan_collaborators", "coplan_users", column: "user_id" + add_foreign_key "coplan_plan_tags", "coplan_plans", column: "plan_id" + add_foreign_key "coplan_plan_tags", "coplan_tags", column: "tag_id" 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" diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index 5947158..3b4c67e 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -48,8 +48,8 @@ def update permitted = {} permitted[:title] = params[:title] if params.key?(:title) permitted[:status] = params[:status] if params.key?(:status) - permitted[:tags] = params[:tags] if params.key?(:tags) + @plan.tag_names = params[:tags] if params.key?(:tags) @plan.update!(permitted) if @plan.saved_changes? @@ -86,7 +86,7 @@ def plan_json(plan) title: plan.title, status: plan.status, current_revision: plan.current_revision, - tags: plan.tags, + tags: plan.tag_names, plan_type_id: plan.plan_type_id, plan_type_name: plan.plan_type&.name, created_by: plan.created_by_user.name, diff --git a/engine/app/controllers/coplan/api/v1/tags_controller.rb b/engine/app/controllers/coplan/api/v1/tags_controller.rb new file mode 100644 index 0000000..0c6521b --- /dev/null +++ b/engine/app/controllers/coplan/api/v1/tags_controller.rb @@ -0,0 +1,29 @@ +module CoPlan + module Api + module V1 + class TagsController < BaseController + def index + visible_plans = Plan.where.not(status: "brainstorm") + .or(Plan.where(created_by_user: current_user)) + + tags = Tag + .joins(:plan_tags) + .where(plan_tags: { plan_id: visible_plans.select(:id) }) + .select("coplan_tags.*, COUNT(plan_tags.plan_id) AS visible_plans_count") + .group("coplan_tags.id") + .order("visible_plans_count DESC, coplan_tags.name ASC") + + render json: tags.map { |t| + { + id: t.id, + name: t.name, + plans_count: t.visible_plans_count, + created_at: t.created_at, + updated_at: t.updated_at + } + } + end + end + end + end +end diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index c1e3015..eac9e91 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -11,10 +11,11 @@ class Plan < ApplicationRecord has_many :comment_threads, dependent: :destroy has_many :edit_sessions, dependent: :destroy has_one :edit_lease, dependent: :destroy + has_many :plan_tags, dependent: :destroy + has_many :tags, through: :plan_tags, source: :tag has_many :plan_viewers, dependent: :destroy has_many :notifications, dependent: :destroy - after_initialize { self.tags ||= [] } after_initialize { self.metadata ||= {} } validates :title, presence: true @@ -35,5 +36,14 @@ def to_param def current_content current_plan_version&.content_markdown end + + def tag_names + tags.pluck(:name) + end + + def tag_names=(names) + desired = Array(names).map(&:strip).reject(&:blank?).uniq + self.tags = desired.map { |name| Tag.find_or_create_by!(name: name) } + end end end diff --git a/engine/app/models/coplan/plan_tag.rb b/engine/app/models/coplan/plan_tag.rb new file mode 100644 index 0000000..210fa69 --- /dev/null +++ b/engine/app/models/coplan/plan_tag.rb @@ -0,0 +1,16 @@ +module CoPlan + class PlanTag < ApplicationRecord + belongs_to :plan + belongs_to :tag, counter_cache: :plans_count + + validates :tag_id, uniqueness: { scope: :plan_id } + + def self.ransackable_attributes(auth_object = nil) + %w[id plan_id tag_id created_at updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[plan tag] + end + end +end diff --git a/engine/app/models/coplan/tag.rb b/engine/app/models/coplan/tag.rb new file mode 100644 index 0000000..0ed238b --- /dev/null +++ b/engine/app/models/coplan/tag.rb @@ -0,0 +1,16 @@ +module CoPlan + class Tag < ApplicationRecord + has_many :plan_tags, dependent: :destroy + has_many :plans, through: :plan_tags + + validates :name, presence: true, uniqueness: true + + def self.ransackable_attributes(auth_object = nil) + %w[id name plans_count created_at updated_at] + end + + def self.ransackable_associations(auth_object = nil) + %w[plan_tags plans] + end + end +end diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index 095052f..e90123b 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -46,6 +46,26 @@ Update plan metadata (title, status, tags). Only fields included in the request Allowed fields: `title` (string), `status` (string), `tags` (array of strings). +### List Tags + +```bash +<%= @curl %> \ + "<%= @base %>/api/v1/tags" | jq . +``` + +Returns all tags sorted by usage frequency (most-used first). Each tag includes `id`, `name`, `plans_count`. + +### Tags + +Tags categorize plans for discovery. Use them to help people find related plans. + +**Guidelines:** +- Check existing tags with `GET /api/v1/tags` before creating new ones — reuse over invention. +- Use lowercase, hyphenated names (e.g., `infrastructure`, `api-design`, `cost-reduction`). +- Apply 1–3 tags per plan. Too many tags dilute meaning. +- Update tags via `PATCH /api/v1/plans/:id` with `{"tags": ["tag-1", "tag-2"]}`. +- Good tags describe the *domain* or *concern* (e.g., `security`, `onboarding`), not the status or team. + ### Plan Statuses Plans move through a lifecycle. **Keep the status current** — update it as the plan progresses. diff --git a/engine/config/routes.rb b/engine/config/routes.rb index 617e1b5..7abfbe8 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -24,6 +24,7 @@ resources :users, only: [] do get :search, on: :collection end + resources :tags, only: [:index] resources :plans, only: [:index, :show, :create, :update] do get :versions, on: :member get :comments, on: :member diff --git a/engine/db/migrate/20260403000000_create_coplan_tags.rb b/engine/db/migrate/20260403000000_create_coplan_tags.rb new file mode 100644 index 0000000..1166007 --- /dev/null +++ b/engine/db/migrate/20260403000000_create_coplan_tags.rb @@ -0,0 +1,22 @@ +class CreateCoplanTags < ActiveRecord::Migration[8.1] + def change + create_table :coplan_tags, id: { type: :string, limit: 36 } do |t| + t.string :name, null: false + t.integer :plans_count, default: 0, null: false + t.timestamps + end + + add_index :coplan_tags, :name, unique: true + + create_table :coplan_plan_tags, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :tag_id, limit: 36, null: false + t.timestamps + end + + add_index :coplan_plan_tags, [:plan_id, :tag_id], unique: true + add_index :coplan_plan_tags, :tag_id + add_foreign_key :coplan_plan_tags, :coplan_plans, column: :plan_id + add_foreign_key :coplan_plan_tags, :coplan_tags, column: :tag_id + end +end diff --git a/engine/db/migrate/20260403100000_backfill_structured_tags.rb b/engine/db/migrate/20260403100000_backfill_structured_tags.rb new file mode 100644 index 0000000..dc84c51 --- /dev/null +++ b/engine/db/migrate/20260403100000_backfill_structured_tags.rb @@ -0,0 +1,39 @@ +class BackfillStructuredTags < ActiveRecord::Migration[8.1] + def up + # Backfill Tag + PlanTag records from JSON tags column + execute(<<~SQL).each do |row| + SELECT id, tags FROM coplan_plans WHERE tags IS NOT NULL + SQL + plan_id = row[0] || row["id"] + raw = row[1] || row["tags"] + next if raw.blank? + + parsed = raw.is_a?(String) ? JSON.parse(raw) : raw + next unless parsed.is_a?(Array) + + parsed.each do |name| + name = name.to_s.strip + next if name.blank? + + tag_id = SecureRandom.uuid_v7 + execute "INSERT IGNORE INTO coplan_tags (id, name, plans_count, created_at, updated_at) VALUES (#{quote(tag_id)}, #{quote(name)}, 0, NOW(), NOW())" + actual_tag_id = select_value("SELECT id FROM coplan_tags WHERE name = #{quote(name)}") + pt_id = SecureRandom.uuid_v7 + execute "INSERT IGNORE INTO coplan_plan_tags (id, plan_id, tag_id, created_at, updated_at) VALUES (#{quote(pt_id)}, #{quote(plan_id)}, #{quote(actual_tag_id)}, NOW(), NOW())" + end + end + + # Reset counter caches + execute <<~SQL + UPDATE coplan_tags SET plans_count = ( + SELECT COUNT(*) FROM coplan_plan_tags WHERE coplan_plan_tags.tag_id = coplan_tags.id + ) + SQL + + remove_column :coplan_plans, :tags + end + + def down + add_column :coplan_plans, :tags, :json + end +end diff --git a/spec/factories/plan_tags.rb b/spec/factories/plan_tags.rb new file mode 100644 index 0000000..cd5ee41 --- /dev/null +++ b/spec/factories/plan_tags.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :plan_tag, class: "CoPlan::PlanTag" do + plan + tag + end +end diff --git a/spec/factories/tags.rb b/spec/factories/tags.rb new file mode 100644 index 0000000..a98694f --- /dev/null +++ b/spec/factories/tags.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :tag, class: "CoPlan::Tag" do + sequence(:name) { |n| "tag-#{n}" } + end +end diff --git a/spec/models/plan_tag_spec.rb b/spec/models/plan_tag_spec.rb new file mode 100644 index 0000000..2cce923 --- /dev/null +++ b/spec/models/plan_tag_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe CoPlan::PlanTag, type: :model do + it "is valid with valid attributes" do + plan_tag = create(:plan_tag) + expect(plan_tag).to be_valid + end + + it "requires unique tag per plan" do + plan = create(:plan) + tag = create(:tag) + create(:plan_tag, plan: plan, tag: tag) + duplicate = build(:plan_tag, plan: plan, tag: tag) + expect(duplicate).not_to be_valid + expect(duplicate.errors[:tag_id]).to include("has already been taken") + end + + it "belongs to plan" do + plan_tag = create(:plan_tag) + expect(plan_tag.plan).to be_a(CoPlan::Plan) + end + + it "belongs to tag" do + plan_tag = create(:plan_tag) + expect(plan_tag.tag).to be_a(CoPlan::Tag) + end +end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb new file mode 100644 index 0000000..b53f99b --- /dev/null +++ b/spec/models/tag_spec.rb @@ -0,0 +1,63 @@ +require "rails_helper" + +RSpec.describe CoPlan::Tag, type: :model do + it "is valid with valid attributes" do + tag = create(:tag) + expect(tag).to be_valid + end + + it "requires name" do + tag = build(:tag, name: nil) + expect(tag).not_to be_valid + expect(tag.errors[:name]).to include("can't be blank") + end + + it "requires unique name" do + create(:tag, name: "infrastructure") + tag = build(:tag, name: "infrastructure") + expect(tag).not_to be_valid + expect(tag.errors[:name]).to include("has already been taken") + end + + it "has many plans through plan_tags" do + tag = create(:tag) + plan = create(:plan) + create(:plan_tag, plan: plan, tag: tag) + expect(tag.plans).to include(plan) + end + + it "updates plans_count via counter cache" do + tag = create(:tag) + plan = create(:plan) + expect { create(:plan_tag, plan: plan, tag: tag) }.to change { tag.reload.plans_count }.from(0).to(1) + end + + describe "Plan#tag_names=" do + it "creates Tag and PlanTag records" do + plan = create(:plan) + plan.tag_names = ["infrastructure", "api-design"] + expect(plan.tag_names).to match_array(["infrastructure", "api-design"]) + expect(CoPlan::Tag.where(name: "infrastructure")).to exist + end + + it "reuses existing Tag records" do + create(:tag, name: "security") + plan = create(:plan) + expect { plan.tag_names = ["security"] }.not_to change(CoPlan::Tag, :count) + expect(plan.tag_names).to eq(["security"]) + end + + it "removes old associations when tags change" do + plan = create(:plan) + plan.tag_names = ["alpha", "beta"] + plan.tag_names = ["beta", "gamma"] + expect(plan.tag_names).to match_array(["beta", "gamma"]) + end + + it "handles blank and duplicate names" do + plan = create(:plan) + plan.tag_names = [" infra ", "infra", "", "api"] + expect(plan.tag_names).to match_array(["infra", "api"]) + end + end +end diff --git a/spec/requests/api/v1/plans_spec.rb b/spec/requests/api/v1/plans_spec.rb index 947cc0f..561ef9c 100644 --- a/spec/requests/api/v1/plans_spec.rb +++ b/spec/requests/api/v1/plans_spec.rb @@ -107,8 +107,8 @@ patch api_v1_plan_path(plan), params: { tags: ["infra", "api"] }, headers: headers, as: :json expect(response).to have_http_status(:success) body = JSON.parse(response.body) - expect(body["tags"]).to eq(["infra", "api"]) - expect(plan.reload.tags).to eq(["infra", "api"]) + expect(body["tags"]).to match_array(["infra", "api"]) + expect(plan.reload.tag_names).to match_array(["infra", "api"]) end it "updates multiple fields at once" do diff --git a/spec/requests/api/v1/tags_spec.rb b/spec/requests/api/v1/tags_spec.rb new file mode 100644 index 0000000..a5a634d --- /dev/null +++ b/spec/requests/api/v1/tags_spec.rb @@ -0,0 +1,68 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Tags", type: :request do + let(:alice) { create(:coplan_user, :admin) } + let(:alice_token) { create(:api_token, user: alice, raw_token: "test-token-alice") } + let(:headers) { { "Authorization" => "Bearer test-token-alice" } } + + before do + alice_token # ensure token exists + end + + it "index returns tags sorted by plans_count descending" do + popular = create(:tag, name: "infrastructure") + niche = create(:tag, name: "experimental") + plan1 = create(:plan, :considering, created_by_user: alice) + plan2 = create(:plan, :considering, created_by_user: alice) + create(:plan_tag, plan: plan1, tag: popular) + create(:plan_tag, plan: plan2, tag: popular) + create(:plan_tag, plan: plan1, tag: niche) + + get api_v1_tags_path, headers: headers + expect(response).to have_http_status(:success) + tags = JSON.parse(response.body) + expect(tags.length).to eq(2) + expect(tags.first["name"]).to eq("infrastructure") + expect(tags.first["plans_count"]).to eq(2) + expect(tags.second["name"]).to eq("experimental") + expect(tags.second["plans_count"]).to eq(1) + end + + it "index returns empty array when no tags exist" do + get api_v1_tags_path, headers: headers + expect(response).to have_http_status(:success) + tags = JSON.parse(response.body) + expect(tags).to eq([]) + end + + it "index requires authentication" do + get api_v1_tags_path + expect(response).to have_http_status(:unauthorized) + end + + it "excludes tags only attached to other users' brainstorm plans" do + bob = create(:coplan_user) + secret_tag = create(:tag, name: "secret-project") + visible_tag = create(:tag, name: "public-project") + brainstorm_plan = create(:plan, created_by_user: bob) # brainstorm by default + public_plan = create(:plan, :considering, created_by_user: bob) + create(:plan_tag, plan: brainstorm_plan, tag: secret_tag) + create(:plan_tag, plan: public_plan, tag: visible_tag) + + get api_v1_tags_path, headers: headers + tags = JSON.parse(response.body) + tag_names = tags.map { |t| t["name"] } + expect(tag_names).to include("public-project") + expect(tag_names).not_to include("secret-project") + end + + it "includes tags from the current user's own brainstorm plans" do + my_tag = create(:tag, name: "my-draft") + brainstorm_plan = create(:plan, created_by_user: alice) + create(:plan_tag, plan: brainstorm_plan, tag: my_tag) + + get api_v1_tags_path, headers: headers + tags = JSON.parse(response.body) + expect(tags.map { |t| t["name"] }).to include("my-draft") + end +end