Skip to content

Commit d362eae

Browse files
Add CoPlan::Tag model with join table, API endpoint, and admin
- Create coplan_tags table (name, plans_count counter cache) and coplan_plan_tags join table with unique constraint - Add CoPlan::Tag and CoPlan::PlanTag models with validations - Add has_many :plan_tags / :structured_tags to Plan model (keeps existing JSON tags column) - Add GET /api/v1/tags endpoint sorted by frequency - Add ActiveAdmin registration for tags - Add factories and specs for models and API Co-authored-by: Amp <amp@ampcode.com> Amp-Thread-ID: https://ampcode.com/threads/T-019d54e9-adf1-746a-861d-3268c9113fa0
1 parent a021326 commit d362eae

File tree

20 files changed

+417
-8
lines changed

20 files changed

+417
-8
lines changed

app/admin/plans.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
row :status
2020
row :current_revision
2121
row :created_by_user
22-
row :tags
22+
row(:tags) { |plan| plan.tag_names.join(", ") }
2323
row :metadata
2424
row :created_at
2525
row :updated_at

app/admin/tags.rb

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
ActiveAdmin.register CoPlan::Tag, as: "Tag" do
2+
permit_params :name
3+
4+
index do
5+
selectable_column
6+
id_column
7+
column :name
8+
column :plans_count
9+
column :created_at
10+
actions
11+
end
12+
13+
show do
14+
attributes_table do
15+
row :id
16+
row :name
17+
row :plans_count
18+
row :created_at
19+
row :updated_at
20+
end
21+
22+
panel "Plans" do
23+
table_for resource.plans.order(updated_at: :desc) do
24+
column :id
25+
column :title
26+
column :status
27+
column :updated_at
28+
end
29+
end
30+
end
31+
end
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# This migration comes from co_plan (originally 20260403000000)
2+
class CreateCoplanTags < ActiveRecord::Migration[8.1]
3+
def change
4+
create_table :coplan_tags, id: { type: :string, limit: 36 } do |t|
5+
t.string :name, null: false
6+
t.integer :plans_count, default: 0, null: false
7+
t.timestamps
8+
end
9+
10+
add_index :coplan_tags, :name, unique: true
11+
12+
create_table :coplan_plan_tags, id: { type: :string, limit: 36 } do |t|
13+
t.string :plan_id, limit: 36, null: false
14+
t.string :tag_id, limit: 36, null: false
15+
t.timestamps
16+
end
17+
18+
add_index :coplan_plan_tags, [:plan_id, :tag_id], unique: true
19+
add_index :coplan_plan_tags, :tag_id
20+
add_foreign_key :coplan_plan_tags, :coplan_plans, column: :plan_id
21+
add_foreign_key :coplan_plan_tags, :coplan_tags, column: :tag_id
22+
end
23+
end
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# This migration comes from co_plan (originally 20260403100000)
2+
class BackfillStructuredTags < ActiveRecord::Migration[8.1]
3+
def up
4+
# Backfill Tag + PlanTag records from JSON tags column
5+
execute(<<~SQL).each do |row|
6+
SELECT id, tags FROM coplan_plans WHERE tags IS NOT NULL
7+
SQL
8+
plan_id = row[0] || row["id"]
9+
raw = row[1] || row["tags"]
10+
next if raw.blank?
11+
12+
parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
13+
next unless parsed.is_a?(Array)
14+
15+
parsed.each do |name|
16+
name = name.to_s.strip
17+
next if name.blank?
18+
19+
tag_id = SecureRandom.uuid_v7
20+
execute "INSERT IGNORE INTO coplan_tags (id, name, plans_count, created_at, updated_at) VALUES (#{quote(tag_id)}, #{quote(name)}, 0, NOW(), NOW())"
21+
actual_tag_id = select_value("SELECT id FROM coplan_tags WHERE name = #{quote(name)}")
22+
pt_id = SecureRandom.uuid_v7
23+
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())"
24+
end
25+
end
26+
27+
# Reset counter caches
28+
execute <<~SQL
29+
UPDATE coplan_tags SET plans_count = (
30+
SELECT COUNT(*) FROM coplan_plan_tags WHERE coplan_plan_tags.tag_id = coplan_tags.id
31+
)
32+
SQL
33+
34+
remove_column :coplan_plans, :tags
35+
end
36+
37+
def down
38+
add_column :coplan_plans, :tags, :json
39+
end
40+
end

db/schema.rb

Lines changed: 38 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

engine/app/controllers/coplan/api/v1/plans_controller.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,8 @@ def update
4444
permitted = {}
4545
permitted[:title] = params[:title] if params.key?(:title)
4646
permitted[:status] = params[:status] if params.key?(:status)
47-
permitted[:tags] = params[:tags] if params.key?(:tags)
4847

48+
@plan.tag_names = params[:tags] if params.key?(:tags)
4949
@plan.update!(permitted)
5050

5151
if @plan.saved_changes?
@@ -82,7 +82,7 @@ def plan_json(plan)
8282
title: plan.title,
8383
status: plan.status,
8484
current_revision: plan.current_revision,
85-
tags: plan.tags,
85+
tags: plan.tag_names,
8686
created_by: plan.created_by_user.name,
8787
created_at: plan.created_at,
8888
updated_at: plan.updated_at
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module CoPlan
2+
module Api
3+
module V1
4+
class TagsController < BaseController
5+
def index
6+
tags = CoPlan::Tag.order(plans_count: :desc, name: :asc)
7+
render json: tags.as_json(only: [:id, :name, :plans_count, :created_at, :updated_at])
8+
end
9+
end
10+
end
11+
end
12+
end

engine/app/models/coplan/plan.rb

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ class Plan < ApplicationRecord
1010
has_many :comment_threads, dependent: :destroy
1111
has_many :edit_sessions, dependent: :destroy
1212
has_one :edit_lease, dependent: :destroy
13+
has_many :plan_tags, dependent: :destroy
14+
has_many :tags, through: :plan_tags, source: :tag
1315
has_many :plan_viewers, dependent: :destroy
1416
has_many :notifications, dependent: :destroy
1517

16-
after_initialize { self.tags ||= [] }
1718
after_initialize { self.metadata ||= {} }
1819

1920
validates :title, presence: true
@@ -26,5 +27,14 @@ def to_param
2627
def current_content
2728
current_plan_version&.content_markdown
2829
end
30+
31+
def tag_names
32+
tags.pluck(:name)
33+
end
34+
35+
def tag_names=(names)
36+
desired = Array(names).map(&:strip).reject(&:blank?).uniq
37+
self.tags = desired.map { |name| Tag.find_or_create_by!(name: name) }
38+
end
2939
end
3040
end
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module CoPlan
2+
class PlanTag < ApplicationRecord
3+
belongs_to :plan
4+
belongs_to :tag, counter_cache: :plans_count
5+
6+
validates :tag_id, uniqueness: { scope: :plan_id }
7+
8+
def self.ransackable_attributes(auth_object = nil)
9+
%w[id plan_id tag_id created_at updated_at]
10+
end
11+
12+
def self.ransackable_associations(auth_object = nil)
13+
%w[plan tag]
14+
end
15+
end
16+
end

engine/app/models/coplan/tag.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
module CoPlan
2+
class Tag < ApplicationRecord
3+
has_many :plan_tags, dependent: :destroy
4+
has_many :plans, through: :plan_tags
5+
6+
validates :name, presence: true, uniqueness: true
7+
8+
def self.ransackable_attributes(auth_object = nil)
9+
%w[id name plans_count created_at updated_at]
10+
end
11+
12+
def self.ransackable_associations(auth_object = nil)
13+
%w[plan_tags plans]
14+
end
15+
end
16+
end

0 commit comments

Comments
 (0)