Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions app/admin/plan_events.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
ActiveAdmin.register CoPlan::PlanEvent, as: "PlanEvent" do
actions :index, :show

index do
selectable_column
id_column
column :plan
column :event_type
column :field
column :before_value
column :after_value
column :actor_type
column :actor_user
column :created_at
actions
end

filter :plan
filter :event_type, as: :select, collection: CoPlan::PlanEvent::EVENT_TYPES
filter :actor_type, as: :select, collection: CoPlan::PlanEvent::ACTOR_TYPES
filter :created_at

show do
attributes_table do
row :id
row :plan
row :event_type
row :field
row :before_value
row :after_value
row :actor_type
row :actor_user
row :metadata
row :created_at
end
end
end
20 changes: 20 additions & 0 deletions db/migrate/20260519011926_create_coplan_plan_events.co_plan.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# This migration comes from co_plan (originally 20260519000000)
class CreateCoplanPlanEvents < ActiveRecord::Migration[8.1]
def change
create_table :coplan_plan_events, id: { type: :string, limit: 36 } do |t|
t.string :plan_id, limit: 36, null: false
t.string :actor_id, limit: 36
t.string :actor_type, null: false
t.string :event_type, null: false
t.string :field
t.text :before_value
t.text :after_value
t.json :metadata
t.datetime :created_at, null: false

t.index :plan_id
t.index [:plan_id, :created_at]
t.index :event_type
end
end
end
17 changes: 16 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

29 changes: 29 additions & 0 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -880,6 +880,35 @@ img.avatar {
margin-top: 2px;
}

/* Metadata events in the history feed (status changes, tag adds, etc.).
Reuses .history-split__item layout but is non-interactive — clicking does
not load a diff because the event payload *is* the diff. */
.history-split__item--event {
cursor: default;
}

.history-split__item--event:hover {
background: inherit;
}

.history-split__event-chip {
display: inline-block;
padding: 1px 6px;
border-radius: var(--radius);
background: var(--color-bg-muted);
border: 1px solid var(--color-border);
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--color-text-muted);
}

.history-split__event-link {
color: var(--color-primary);
text-decoration: underline;
}

.history-split__detail {
padding: var(--space-md) var(--space-lg);
overflow-y: auto;
Expand Down
35 changes: 35 additions & 0 deletions engine/app/controllers/coplan/api/v1/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -72,24 +72,59 @@ def update
permitted[:title] = params[:title] if params.key?(:title)
permitted[:status] = params[:status] if params.key?(:status)

# Snapshot before-state so LogEvent can record meaningful diffs.
old_title = @plan.title
old_status = @plan.status
old_tag_names = @plan.tag_names

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

if @plan.saved_changes?
Broadcaster.replace_to(@plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: @plan })
end

if permitted.key?(:title) && @plan.saved_change_to_title?
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "title_changed",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve API actor type when logging metadata

When this API endpoint is called with a bearer token, current_user is the token owner, while the rest of the API records content edits with api_author_type == "local_agent" and api_actor_id from the token. Passing current_user here makes all API title/status/tag/reference metadata events look like human edits by the owner, so the new history feed gives the wrong answer for agent-driven metadata changes; the other Plans::LogEvent.call sites in this action have the same attribution issue.

Useful? React with 👍 / 👎.

before: old_title, after: @plan.title
)
end

if permitted.key?(:status) && @plan.saved_change_to_status?
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "status_changed",
before: old_status, after: @plan.status
)
Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: permitted[:status], triggered_by: current_user)
end

if params.key?(:tags)
new_tag_names = @plan.tag_names
(new_tag_names - old_tag_names).each do |added|
Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "tag_added", after: added)
end
(old_tag_names - new_tag_names).each do |removed|
Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "tag_removed", before: removed)
end
end

if params[:references].is_a?(Array)
params[:references].each do |ref_params|
next unless ref_params[:url].present?
ref_type = ref_params[:reference_type].presence || Reference.classify_url(ref_params[:url])
ref = @plan.references.find_or_initialize_by(url: ref_params[:url])
# Only emit a "reference_added" event for genuinely new references;
# existing-reference updates fall through silently for now.
was_new = ref.new_record?
ref.assign_attributes(key: ref_params[:key], title: ref_params[:title], reference_type: ref_type, source: "explicit")
ref.save!
if was_new
Plans::LogEvent.call(
plan: @plan, actor: current_user, event_type: "reference_added",
after: ref.url, metadata: { title: ref.title, reference_type: ref.reference_type }
)
end
end
end

Expand Down
1 change: 1 addition & 0 deletions engine/app/controllers/coplan/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def self.controller_path
helper CoPlan::MarkdownHelper
helper CoPlan::CommentsHelper
helper CoPlan::ReferencesHelper
helper CoPlan::PlanEventsHelper

# Skip host auth — CoPlan handles authentication internally via config.authenticate
skip_before_action :authenticate_user!, raise: false
Expand Down
21 changes: 19 additions & 2 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ def show

def history
authorize!(@plan, :show?)
@versions = @plan.plan_versions.includes(:actor_user).order(revision: :desc)
@history_items = @plan.history_items
render layout: false
end

Expand All @@ -52,17 +52,34 @@ def edit

def update
authorize!(@plan, :update?)
@plan.update!(title: params[:plan][:title])
old_title = @plan.title
new_title = params[:plan][:title]
@plan.update!(title: new_title)
Plans::LogEvent.call(
plan: @plan,
actor: current_user,
event_type: "title_changed",
before: old_title,
after: new_title
)
broadcast_plan_update(@plan)
redirect_to plan_path(@plan), notice: "Plan updated."
end

def update_status
authorize!(@plan, :update_status?)
new_status = params[:status]
old_status = @plan.status
if Plan::STATUSES.include?(new_status) && @plan.update(status: new_status)
broadcast_plan_update(@plan)
if @plan.saved_change_to_status?
Plans::LogEvent.call(
plan: @plan,
actor: current_user,
event_type: "status_changed",
before: old_status,
after: new_status
)
Plans::TriggerAutomatedReviews.call(plan: @plan, new_status: new_status, triggered_by: current_user)
end
redirect_to plan_path(@plan), notice: "Status updated to #{new_status}."
Expand Down
22 changes: 22 additions & 0 deletions engine/app/controllers/coplan/references_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ def create
end

ref = @plan.references.find_or_initialize_by(url: url)
was_new = ref.new_record?
ref.assign_attributes(
key: params[:reference][:key].presence || ref.key,
title: params[:reference][:title].presence || ref.title,
Expand All @@ -23,6 +24,16 @@ def create
)
ref.save!

if was_new
Plans::LogEvent.call(
plan: @plan,
actor: current_user,
event_type: "reference_added",
after: ref.url,
metadata: { title: ref.title, reference_type: ref.reference_type }
)
end

respond_to do |format|
format.turbo_stream { render_references_stream }
format.html { redirect_to plan_path(@plan, tab: "references"), notice: "Reference added." }
Expand All @@ -38,8 +49,19 @@ def destroy
authorize!(@plan, :update?)

ref = @plan.references.find(params[:id])
removed_url = ref.url
removed_title = ref.title
removed_type = ref.reference_type
ref.destroy!

Plans::LogEvent.call(
plan: @plan,
actor: current_user,
event_type: "reference_removed",
before: removed_url,
metadata: { title: removed_title, reference_type: removed_type }
)

respond_to do |format|
format.turbo_stream { render_references_stream }
format.html { redirect_to plan_path(@plan, tab: "references"), notice: "Reference removed." }
Expand Down
51 changes: 51 additions & 0 deletions engine/app/helpers/coplan/plan_events_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
module CoPlan
module PlanEventsHelper
# Render a one-line, human-readable summary of a PlanEvent for the
# history feed. Each event type gets a tailored "X → Y" or "added X" /
# "removed X" phrasing instead of a generic field/before/after dump,
# because the same shape doesn't read well for status changes and tag
# adds and reference removals.
def render_event_summary(event)
case event.event_type
when "status_changed"
safe_join([
"Status: ",
content_tag(:strong, event.before_value || "—"),
" → ",
content_tag(:strong, event.after_value || "—")
])
when "title_changed"
safe_join([
"Renamed: ",
content_tag(:em, event.before_value.to_s.presence || "—"),
" → ",
content_tag(:em, event.after_value.to_s.presence || "—")
])
when "plan_type_changed"
safe_join([
"Plan type: ",
content_tag(:strong, event.before_value || "—"),
" → ",
content_tag(:strong, event.after_value || "—")
])
when "tag_added"
safe_join(["Added tag ", content_tag(:code, event.after_value.to_s)])
when "tag_removed"
safe_join(["Removed tag ", content_tag(:code, event.before_value.to_s)])
when "reference_added"
title = event.metadata.is_a?(Hash) ? event.metadata["title"].presence : nil
url = event.after_value.to_s
label = title || url
safe_join(["Added reference ", link_to(label, url, class: "history-split__event-link", target: "_blank", rel: "noopener")])
when "reference_removed"
title = event.metadata.is_a?(Hash) ? event.metadata["title"].presence : nil
url = event.before_value.to_s
label = title || url
safe_join(["Removed reference ", content_tag(:span, label, class: "history-split__event-link")])
else
# Fallback for unknown / future event types — still useful, never blank.
"#{event.event_type}: #{event.before_value || "—"} → #{event.after_value || "—"}"
end
end
end
end
14 changes: 14 additions & 0 deletions engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class Plan < ApplicationRecord
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_events, dependent: :destroy
has_many :plan_collaborators, dependent: :destroy
has_many :collaborators, through: :plan_collaborators, source: :user
has_many :comment_threads, dependent: :destroy
Expand Down Expand Up @@ -58,5 +59,18 @@ 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

# Unified, time-sorted feed of everything that has happened to this plan:
# both content versions (PlanVersion) and metadata events (PlanEvent).
# Used by the history tab. Newest first.
#
# PlanVersions and PlanEvents both expose a `created_at` and a
# `#history_kind` so the view can render each appropriately without
# branching on class. Eager-loads actors for both to avoid N+1.
def history_items
versions = plan_versions.includes(:actor_user).order(revision: :desc).to_a
events = plan_events.includes(:actor_user).order(created_at: :desc).to_a
(versions + events).sort_by { |item| -item.created_at.to_f }
end
end
end
Loading
Loading