From b655749ef5b74e484b07f4b344a99bff78fdd661 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Jul 2026 15:58:26 -0500 Subject: [PATCH 01/19] Phase 1: replace 5-state status with visibility (draft/published) + archived_at - Plans are born published; visibility:draft is the opt-in private flag - Publishing is one-way; archive replaces abandon (opt-in visible, reversible) - PlanPolicy#show? is now THE visibility predicate (was: true); attachments controller's duplicated brainstorm gate collapsed into it - Legacy API compat: status accepted on writes, legacy_status emitted on reads - Workspace groups by published/drafts; archived opt-in via ?filter=archived - developing/live preserved as tags in the data migration Co-Authored-By: Claude Fable 5 --- app/admin/plans.rb | 11 +- ...us_with_visibility_and_archival.co_plan.rb | 74 +++++++++++ db/schema.rb | 8 +- .../assets/stylesheets/coplan/application.css | 53 ++++++-- .../coplan/api/v1/attachments_controller.rb | 21 +-- .../coplan/api/v1/plans_controller.rb | 106 ++++++++++++--- .../controllers/coplan/plans_controller.rb | 122 +++++++++++------- .../app/helpers/coplan/plan_events_helper.rb | 6 + engine/app/helpers/coplan/plans_helper.rb | 27 ++-- .../coplan/plan_groups_controller.js | 4 +- engine/app/models/coplan/plan.rb | 67 ++++++++-- engine/app/models/coplan/plan_event.rb | 5 + engine/app/policies/coplan/plan_policy.rb | 22 +++- engine/app/services/coplan/plans/create.rb | 13 +- engine/app/services/coplan/plans/log_event.rb | 1 + .../coplan/agent_instructions/show.text.erb | 36 +++--- .../app/views/coplan/plans/_header.html.erb | 2 +- .../views/coplan/plans/_plan_group.html.erb | 21 +-- .../views/coplan/plans/_plan_page.html.erb | 10 +- .../app/views/coplan/plans/_plan_row.html.erb | 11 +- .../app/views/coplan/plans/_sidebar.html.erb | 20 ++- engine/app/views/coplan/plans/index.html.erb | 22 ++-- engine/app/views/coplan/plans/show.html.erb | 39 +++--- .../app/views/coplan/search/_results.html.erb | 6 +- .../coplan/welcome/_default_landing.html.erb | 4 +- engine/config/routes.rb | 4 +- ...ace_status_with_visibility_and_archival.rb | 73 +++++++++++ spec/factories/plans.rb | 31 ++++- 28 files changed, 599 insertions(+), 220 deletions(-) create mode 100644 db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb create mode 100644 engine/db/migrate/20260717000000_replace_status_with_visibility_and_archival.rb diff --git a/app/admin/plans.rb b/app/admin/plans.rb index 14aa3102..13c2ed30 100644 --- a/app/admin/plans.rb +++ b/app/admin/plans.rb @@ -1,8 +1,9 @@ ActiveAdmin.register CoPlan::Plan, as: "Plan" do - permit_params :title, :status + permit_params :title, :visibility, :archived_at filter :title - filter :status, as: :select, collection: CoPlan::Plan::STATUSES + filter :visibility, as: :select, collection: CoPlan::Plan::VISIBILITIES + filter :archived_at filter :plan_type, as: :select filter :created_at filter :updated_at @@ -11,7 +12,8 @@ selectable_column id_column column :title - column :status + column :visibility + column("Archived") { |plan| plan.archived? } column :current_revision column :created_by_user column :updated_at @@ -22,7 +24,8 @@ attributes_table do row :id row :title - row :status + row :visibility + row :archived_at row :current_revision row :created_by_user row(:tags) { |plan| plan.tag_names.join(", ") } diff --git a/db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb b/db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb new file mode 100644 index 00000000..86404010 --- /dev/null +++ b/db/migrate/20260717205624_replace_status_with_visibility_and_archival.co_plan.rb @@ -0,0 +1,74 @@ +# This migration comes from co_plan (originally 20260717000000) +class ReplaceStatusWithVisibilityAndArchival < ActiveRecord::Migration[8.1] + # Migration-local model stubs so this migration never breaks as app models + # evolve. Only the columns touched here are relied upon. + class MigrationPlan < ActiveRecord::Base + self.table_name = "coplan_plans" + end + + class MigrationTag < ActiveRecord::Base + self.table_name = "coplan_tags" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlanTag < ActiveRecord::Base + self.table_name = "coplan_plan_tags" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + def up + # Plans are shared by default — private drafts are the opt-in exception, + # so "published" is the column default for new rows. + add_column :coplan_plans, :visibility, :string, null: false, default: "published" + add_column :coplan_plans, :archived_at, :datetime + add_index :coplan_plans, :visibility + add_index :coplan_plans, :archived_at + + # brainstorm was "private draft"; everything else was org-visible. + # abandoned was the de-facto archive. developing/live carried lifecycle + # info some plans genuinely have, so it is preserved as tags rather than + # destroyed. updated_at is deliberately left untouched — archival state + # is derived data, not user activity. + execute <<~SQL + UPDATE coplan_plans + SET visibility = CASE WHEN status = 'brainstorm' THEN 'draft' ELSE 'published' END, + archived_at = CASE WHEN status = 'abandoned' THEN updated_at ELSE NULL END + SQL + + %w[developing live].each do |lifecycle| + plan_ids = MigrationPlan.where(status: lifecycle).pluck(:id) + next if plan_ids.empty? + + tag = MigrationTag.find_or_create_by!(name: lifecycle) + existing = MigrationPlanTag.where(tag_id: tag.id, plan_id: plan_ids).pluck(:plan_id) + (plan_ids - existing).each do |plan_id| + MigrationPlanTag.create!(plan_id: plan_id, tag_id: tag.id) + end + end + + remove_index :coplan_plans, :status + remove_column :coplan_plans, :status + end + + def down + add_column :coplan_plans, :status, :string, null: false, default: "brainstorm" + add_index :coplan_plans, :status + + # Best-effort reversal: draft→brainstorm, archived→abandoned, otherwise + # considering (the developing/live distinction lives in tags and is not + # re-derived here). + execute <<~SQL + UPDATE coplan_plans + SET status = CASE + WHEN visibility = 'draft' THEN 'brainstorm' + WHEN archived_at IS NOT NULL THEN 'abandoned' + ELSE 'considering' + END + SQL + + remove_index :coplan_plans, :archived_at + remove_index :coplan_plans, :visibility + remove_column :coplan_plans, :archived_at + remove_column :coplan_plans, :visibility + end +end diff --git a/db/schema.rb b/db/schema.rb index 5838e594..0c5193c6 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_07_16_154924) do +ActiveRecord::Schema[8.1].define(version: 2026_07_17_205624) 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" @@ -244,6 +244,7 @@ end create_table "coplan_plans", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "archived_at" t.datetime "created_at", null: false t.string "created_by_user_id", limit: 36, null: false t.string "current_plan_version_id", limit: 36 @@ -252,19 +253,20 @@ t.json "metadata" t.string "plan_type_id", limit: 36 t.text "search_text", size: :medium - t.string "status", default: "brainstorm", null: false t.text "summary" t.string "summary_content_sha256", limit: 64 t.datetime "summary_generated_at" t.string "title", null: false t.datetime "updated_at", null: false + t.string "visibility", default: "published", null: false + t.index ["archived_at"], name: "index_coplan_plans_on_archived_at" 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 ["folder_id"], name: "index_coplan_plans_on_folder_id" t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["search_text"], name: "index_coplan_plans_on_search_text", type: :fulltext - t.index ["status"], name: "index_coplan_plans_on_status" t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" + t.index ["visibility"], name: "index_coplan_plans_on_visibility" end create_table "coplan_references", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index ba5d06ea..e19ef79f 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -525,6 +525,18 @@ img, svg { background: var(--color-bg); } +/* Quiet text-style button for low-emphasis actions (e.g. Archive). */ +.btn--ghost { + background: transparent; + color: var(--color-text-muted); + border-color: transparent; +} + +.btn--ghost:hover { + background: var(--color-bg); + color: var(--color-text); +} + .btn--danger { background: var(--color-danger); color: var(--color-text-inverse); @@ -555,11 +567,11 @@ img, svg { letter-spacing: 0.025em; } -.badge--brainstorm { background: var(--color-status-brainstorm-bg); color: var(--color-status-brainstorm); } -.badge--considering { background: var(--color-status-considering-bg); color: var(--color-status-considering); } -.badge--developing { background: var(--color-status-developing-bg); color: var(--color-status-developing); } -.badge--live { background: var(--color-status-live-bg); color: var(--color-status-live); } -.badge--abandoned { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } +/* Plan state badges. Published-and-active plans get no badge — these mark + the exceptional states only. (The --color-status-* tokens are shared + with comment-thread badges below.) */ +.badge--draft { background: var(--color-status-brainstorm-bg); color: var(--color-status-brainstorm); } +.badge--archived { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } .badge--pending { background: var(--color-status-considering-bg); color: var(--color-status-considering); } .badge--todo { background: var(--color-status-developing-bg); color: var(--color-status-developing); } .badge--discarded { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } @@ -3154,6 +3166,31 @@ body:not(:has(.comment-toolbar)) .web-push-banner { margin-bottom: var(--space-md); } +/* Archive sits apart from the constructive actions, pushed to the far edge. */ +.plan-actions__archive { + margin-left: auto; +} + +/* Full-width state banner on the plan page (e.g. archived notice). */ +.plan-banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-sm) var(--space-md); + margin-bottom: var(--space-md); + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-status-abandoned-bg); + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +/* Archived rows stay legible but visually recede. */ +.plan-row--archived { + opacity: 0.65; +} + .plan-actions__hint { display: block; font-size: var(--text-xs); @@ -3565,11 +3602,7 @@ body:not(:has(.comment-toolbar)) .web-push-banner { letter-spacing: 0.05em; } -.plan-group__label--brainstorm { color: var(--color-status-brainstorm); } -.plan-group__label--considering { color: var(--color-status-considering); } -.plan-group__label--developing { color: var(--color-status-developing); } -.plan-group__label--live { color: var(--color-status-live); } -.plan-group__label--abandoned { color: var(--color-status-abandoned); } +.plan-group__label--draft { color: var(--color-status-brainstorm); } .plan-group__count { font-size: 0.75rem; diff --git a/engine/app/controllers/coplan/api/v1/attachments_controller.rb b/engine/app/controllers/coplan/api/v1/attachments_controller.rb index c9ba28e5..b68fb294 100644 --- a/engine/app/controllers/coplan/api/v1/attachments_controller.rb +++ b/engine/app/controllers/coplan/api/v1/attachments_controller.rb @@ -7,8 +7,10 @@ class AttachmentsController < BaseController include CoPlan::AttachmentsHelper before_action :set_plan, only: [ :index, :create, :destroy ] + # authorize_plan_access! (PlanPolicy#show?) is the single visibility + # gate: drafts 404 for everyone but their author, so attachment + # enumeration / signed blob URL minting can't leak a private draft. before_action :authorize_plan_access!, only: [ :index, :create, :destroy ] - before_action :authorize_plan_visibility!, only: [ :index, :create, :destroy ] before_action :authorize_plan_write!, only: [ :create, :destroy ] def index @@ -67,23 +69,6 @@ def destroy private - # Brainstorm plans are private drafts: only their author may see them - # (mirrors the visibility predicate used by the plans index and - # search — published OR owned by the caller). PlanPolicy#show? - # currently allows everyone, which is fine for published plans but - # would let any authenticated caller enumerate a private draft's - # attachments and mint signed blob URLs for them. Scoped to this - # controller rather than PlanPolicy so we don't change authorization - # for the rest of the app. 404 (not 403) so the plan's existence - # doesn't leak. - def authorize_plan_visibility! - return unless @plan - return if @plan.status != "brainstorm" - return if current_user && @plan.created_by_user_id == current_user.id - - render json: { error: "Plan not found" }, status: :not_found - end - def attachment_json(attachment) blob = attachment.blob url = main_app.rails_blob_path(blob, only_path: true) diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index 60f9390e..92477014 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -10,7 +10,7 @@ def index .includes(:plan_type, :created_by_user, folder: { parent: :parent }) .visible_to(current_user) .order(updated_at: :desc) - plans = plans.where(status: params[:status]) if params[:status].present? + plans = apply_index_filters(plans) plans = plans.where(folder_id: params[:folder_id]) if params[:folder_id].present? render json: plans.map { |p| plan_json(p) } end @@ -34,13 +34,21 @@ def create end end + # Plans are born published; `"visibility": "draft"` is the opt-in + # for starting private. + visibility = params[:visibility].presence || "published" + unless Plan::VISIBILITIES.include?(visibility) + return render json: { error: "visibility must be one of: #{Plan::VISIBILITIES.join(", ")}" }, status: :unprocessable_content + end + plan = nil ActiveRecord::Base.transaction do plan = Plans::Create.call( title: params[:title], content: params[:content] || "", user: current_user, - plan_type_id: plan_type&.id + plan_type_id: plan_type&.id, + visibility: visibility ) if params[:references].is_a?(Array) @@ -70,11 +78,14 @@ def update permitted = {} permitted[:title] = params[:title] if params.key?(:title) - permitted[:status] = params[:status] if params.key?(:status) + visibility_updates = visibility_params_for_update + return if performed? # visibility_params_for_update rendered an error + permitted.merge!(visibility_updates) # Snapshot before-state so LogEvent can record meaningful diffs. old_title = @plan.title - old_status = @plan.status + old_visibility = @plan.visibility + old_archived = @plan.archived? old_tag_names = @plan.tag_names old_folder_path = @plan.folder&.path @@ -105,22 +116,27 @@ def update ) end - if permitted.key?(:status) && @plan.saved_change_to_status? + if @plan.saved_change_to_visibility? && @plan.published? && old_visibility == "draft" Plans::LogEvent.call( - plan: @plan, actor: current_user, event_type: "status_changed", - before: old_status, after: @plan.status, + plan: @plan, actor: current_user, event_type: "published", + before: "draft", after: "published", + actor_type: api_author_type, actor_id: api_actor_id + ) + CoPlan::Analytics.track( + "plan_published", + user: current_user, + plan_id: @plan.id, + plan_type_id: @plan.plan_type_id, + via: "api" + ) + end + + if @plan.saved_change_to_archived_at? && @plan.archived? != old_archived + Plans::LogEvent.call( + plan: @plan, actor: current_user, + event_type: @plan.archived? ? "archived" : "unarchived", actor_type: api_author_type, actor_id: api_actor_id ) - if @plan.status == "considering" && old_status != "considering" - CoPlan::Analytics.track( - "plan_published", - user: current_user, - plan_id: @plan.id, - plan_type_id: @plan.plan_type_id, - previous_status: old_status, - via: "api" - ) - end end if @plan.saved_change_to_folder_id? @@ -201,6 +217,56 @@ def snapshot private + # Index filtering. Canonical params: `visibility` (draft|published) + # and `archived` (true opts archived plans in — they're excluded by + # default). The legacy `status` param maps onto the same axes for + # the deprecation window. + def apply_index_filters(plans) + if params[:archived].to_s == "true" + plans = plans.archived + elsif params[:status].present? + plans = case params[:status] + when "abandoned" then plans.archived + when "brainstorm" then plans.active.where(visibility: "draft") + else plans.active.where(visibility: "published") + end + return plans + else + plans = plans.active + end + plans = plans.where(visibility: params[:visibility]) if Plan::VISIBILITIES.include?(params[:visibility]) + plans + end + + # Maps update params onto the canonical visibility/archival fields. + # Accepts `visibility` + `archived` (canonical) and legacy `status`. + # Publishing is one-way: an attempt to move a published plan back to + # draft renders a 422 rather than silently unpublishing a document + # people may have already read. + def visibility_params_for_update + updates = {} + if params.key?(:status) + updates.merge!(Plan.attributes_for_legacy_status(params[:status])) + end + if params.key?(:visibility) + unless Plan::VISIBILITIES.include?(params[:visibility]) + render json: { error: "visibility must be one of: #{Plan::VISIBILITIES.join(", ")}" }, status: :unprocessable_content + return {} + end + updates[:visibility] = params[:visibility] + end + if params.key?(:archived) + updates[:archived_at] = params[:archived].to_s == "true" ? (@plan.archived_at || Time.current) : nil + end + + if updates[:visibility] == "draft" && @plan.published? + render json: { error: "Published plans cannot return to draft. Archive the plan instead." }, status: :unprocessable_content + return {} + end + updates.delete(:visibility) if updates[:visibility] == @plan.visibility + updates + end + # Resolves `folder_id` / `folder_path` update params to a Folder (or # nil to clear). `folder_path` finds-or-creates the hierarchy, which # is what lets an AI librarian agent organize plans into folders that @@ -221,7 +287,11 @@ def plan_json(plan) { id: plan.id, title: plan.title, - status: plan.status, + visibility: plan.visibility, + archived: plan.archived?, + archived_at: plan.archived_at, + # Legacy five-state field, kept for the deprecation window. + status: plan.legacy_status, current_revision: plan.current_revision, tags: plan.tag_names, folder_id: plan.folder_id, diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index a04bb425..7b741cfb 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -1,34 +1,37 @@ module CoPlan class PlansController < ApplicationController - before_action :set_plan, only: [:show, :edit, :update, :update_status, :move_to_folder, :toggle_checkbox, :history, :edit_content, :update_content, :preview] + before_action :set_plan, only: [:show, :edit, :update, :publish, :archive, :unarchive, :move_to_folder, :toggle_checkbox, :history, :edit_content, :update_content, :preview] PER_PAGE = 20 SCOPES = %w[mine all].freeze DEFAULT_SCOPE = "mine".freeze - # Display order for the main-pane status groups: active work first, - # brainstorms (collapsed by default) and abandoned plans last. - STATUS_GROUP_ORDER = %w[developing considering live brainstorm abandoned].freeze + # Display order for the main-pane groups: published work first, then the + # viewer's private drafts. Archived plans are opt-in (?filter=archived) + # and never render as a default group. + GROUP_ORDER = %w[published draft].freeze + FILTERS = %w[published draft archived].freeze # Sidebar workspace index. Two rendering modes: # - # - Grouped (default): collapsible status groups, each with its own - # "load more" turbo-frame pagination (frames carry group + status + # - Grouped (default): collapsible published/draft groups, each with its + # own "load more" turbo-frame pagination (frames carry group + filter # params back here). - # - Flat: when ?status= filters to a single status, one recency-sorted - # paginated list. + # - Flat: when ?filter= narrows to one group (or opts into archived), + # one recency-sorted paginated list. # # Turbo-frame requests are always page fetches for one of those lists # and render only the row page partial. def index @scope = SCOPES.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE + @filter = FILTERS.include?(params[:filter]) ? params[:filter] : nil load_folder_tree if params[:folder].present? @folder = @folders_by_id[params[:folder]] if @folder.nil? && !turbo_frame_request? - redirect_to plans_path(params.permit(:scope, :status, :plan_type, :tag).to_h), + redirect_to plans_path(params.permit(:scope, :filter, :plan_type, :tag).to_h), alert: "That folder no longer exists." return end @@ -44,8 +47,8 @@ def index # Stale frame fetch for a since-deleted folder: render an empty page. plans = plans.none if params[:folder].present? && @folder.nil? - if params[:status].present? || turbo_frame_request? - plans = plans.where(status: params[:status]) if params[:status].present? + if @filter.present? || turbo_frame_request? + plans = filtered_plans(plans, @filter) plans = plans.order(updated_at: :desc, id: :desc) @page = [ params[:page].to_i, 1 ].max @@ -62,22 +65,24 @@ def index page: @page, has_next_page: @has_next_page, group_key: params[:group].presence || "results", - frame_status: params[:status].presence + frame_filter: @filter }, layout: false return end else - @group_counts = plans.group(:status).count - @groups = STATUS_GROUP_ORDER.filter_map do |status| - count = @group_counts[status].to_i + active = plans.active + @group_counts = active.group(:visibility).count + @archived_count = plans.archived.count + @groups = GROUP_ORDER.filter_map do |visibility| + count = @group_counts[visibility].to_i next if count.zero? - group_plans = plans.where(status: status) + group_plans = active.where(visibility: visibility) .order(updated_at: :desc, id: :desc) .limit(PER_PAGE + 1).to_a { - status: status, + group: visibility, count: count, plans: group_plans.first(PER_PAGE), has_next_page: group_plans.size > PER_PAGE @@ -234,35 +239,44 @@ def preview render html: html, layout: false 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 - ) - if new_status == "considering" && old_status != "considering" - CoPlan::Analytics.track( - "plan_published", - user: current_user, - plan_id: @plan.id, - plan_type_id: @plan.plan_type_id, - previous_status: old_status, - via: "web" - ) - end - end - redirect_to plan_path(@plan), notice: "Status updated to #{new_status}." - else - redirect_to plan_path(@plan), alert: "Invalid status." - end + # Publishing is the one-way door out of draft: explicit, confirmed in + # the UI, and irreversible by design (archive is the tool for "done + # with this", not unpublish). + def publish + authorize!(@plan, :publish?) + @plan.update!(visibility: "published") + broadcast_plan_update(@plan) + Plans::LogEvent.call( + plan: @plan, + actor: current_user, + event_type: "published", + before: "draft", + after: "published" + ) + CoPlan::Analytics.track( + "plan_published", + user: current_user, + plan_id: @plan.id, + plan_type_id: @plan.plan_type_id, + via: "web" + ) + redirect_to plan_path(@plan), notice: "Plan published — everyone can see it now." + end + + def archive + authorize!(@plan, :archive?) + @plan.update!(archived_at: Time.current) + broadcast_plan_update(@plan) + Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "archived") + redirect_to plan_path(@plan), notice: "Plan archived. It's hidden from lists unless someone filters for archived plans." + end + + def unarchive + authorize!(@plan, :unarchive?) + @plan.update!(archived_at: nil) + broadcast_plan_update(@plan) + Plans::LogEvent.call(plan: @plan, actor: current_user, event_type: "unarchived") + redirect_to plan_path(@plan), notice: "Plan restored." end def toggle_checkbox @@ -348,6 +362,16 @@ def toggle_checkbox private + # Narrows a relation to one workspace filter. Archived plans are opt-in + # everywhere: no filter means active plans only. + def filtered_plans(plans, filter) + case filter + when "archived" then plans.archived + when "draft", "published" then plans.active.where(visibility: filter) + else plans.active + end + end + def unread_counts_for(plans) current_user.notifications.unread .where(plan_id: plans.map(&:id)) @@ -395,7 +419,9 @@ def folder_subtree_ids(folder) # which also means other users' private brainstorm plans never leak # through folder counts or tag lists (Plan.visible_to). def load_workspace_sidebar - direct_counts = scoped_plans_base + # Archived plans are hidden from default lists, so they're excluded + # from folder/tag counts too — counts always match what clicking shows. + direct_counts = scoped_plans_base.active .where.not(folder_id: nil) .group(:folder_id) .count @@ -414,7 +440,7 @@ def load_workspace_sidebar @top_tags = Tag .joins(:plan_tags) - .where(coplan_plan_tags: { plan_id: scoped_plans_base.select(:id) }) + .where(coplan_plan_tags: { plan_id: scoped_plans_base.active.select(:id) }) .group("coplan_tags.id", "coplan_tags.name") .order(Arel.sql("COUNT(*) DESC"), "coplan_tags.name ASC") .limit(8) diff --git a/engine/app/helpers/coplan/plan_events_helper.rb b/engine/app/helpers/coplan/plan_events_helper.rb index 70c017d8..f93d1345 100644 --- a/engine/app/helpers/coplan/plan_events_helper.rb +++ b/engine/app/helpers/coplan/plan_events_helper.rb @@ -7,6 +7,12 @@ module PlanEventsHelper # adds and reference removals. def render_event_summary(event) case event.event_type + when "published" + "Published — visible to everyone" + when "archived" + "Archived" + when "unarchived" + "Restored from archive" when "status_changed" safe_join([ "Status: ", diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb index 0ba29462..b667fa15 100644 --- a/engine/app/helpers/coplan/plans_helper.rb +++ b/engine/app/helpers/coplan/plans_helper.rb @@ -2,25 +2,16 @@ module CoPlan module PlansHelper include MarkdownHelper - # Short preview of the plan's content for cards on the index page. - # Once an AI summary column lands (COPLAN-24), the card view prefers - # `plan.summary` and falls back to this helper. - STATUS_HINTS = { - "brainstorm" => "Private draft — only you can see it", - "considering" => "Published — open for review and feedback", - "developing" => "Being implemented", - "live" => "Shipped and in production", - "abandoned" => "No longer pursued" - }.freeze + # State badge for a plan: drafts and archived plans announce themselves; + # published-and-active is the normal state and renders nothing. Safe in + # broadcast partials (derives from the plan alone, no current_user). + def plan_state_badge(plan) + badges = [] + badges << content_tag(:span, "Draft", class: "badge badge--draft", title: "Private draft — only the author can see it") if plan.draft? + badges << content_tag(:span, "Archived", class: "badge badge--archived", title: "Hidden from lists unless filtered for") if plan.archived? + return "".html_safe if badges.empty? - def plan_status_hint(status) - STATUS_HINTS[status] - end - - # Moving a brainstorm to any public status publishes it org-wide — - # worth a confirmation click. - def status_publishes?(plan, new_status) - plan.status == "brainstorm" && new_status != "brainstorm" + safe_join([" · ", safe_join(badges, " ")]) end def plan_content_preview(plan, limit: 200) diff --git a/engine/app/javascript/controllers/coplan/plan_groups_controller.js b/engine/app/javascript/controllers/coplan/plan_groups_controller.js index 96c3d992..7a17d1b6 100644 --- a/engine/app/javascript/controllers/coplan/plan_groups_controller.js +++ b/engine/app/javascript/controllers/coplan/plan_groups_controller.js @@ -1,8 +1,8 @@ import { Controller } from "@hotwired/stimulus" -// Collapsible status groups on the plans index. Collapsed state is +// Collapsible plan groups on the plans index. Collapsed state is // persisted per user in localStorage; groups marked with -// [data-default-collapsed] (brainstorms) start collapsed until the user +// [data-default-collapsed] (drafts) start collapsed until the user // explicitly expands them. const STORAGE_KEY = "coplan:plans:collapsed-groups" diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 5fea4bd1..7bc13908 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -1,6 +1,11 @@ module CoPlan class Plan < ApplicationRecord - STATUSES = %w[brainstorm considering developing live abandoned].freeze + VISIBILITIES = %w[draft published].freeze + + # Legacy API compatibility: the pre-2026-07 five-state `status` field. + # Accepted on writes and emitted on reads for a deprecation window; the + # canonical model is `visibility` + `archived_at`. + LEGACY_STATUSES = %w[brainstorm considering developing live abandoned].freeze # Server-side limits for file attachments. Content types are an allowlist: # anything renderable-but-scriptable (html, svg, js) is deliberately @@ -36,19 +41,26 @@ class Plan < ApplicationRecord after_initialize { self.metadata ||= {} } validates :title, presence: true - validates :status, presence: true, inclusion: { in: STATUSES } + validates :visibility, presence: true, inclusion: { in: VISIBILITIES } validate :attachments_within_limits scope :with_tag, ->(name) { joins(:tags).where(coplan_tags: { name: name }) } - # Plans `user` is allowed to see: everything published (non-brainstorm) - # plus the user's own brainstorms. Brainstorm plans are private drafts — - # any list, count, or folder content shown to a user must go through - # this scope so private brainstorm existence never leaks. + # Plans `user` is allowed to see: everything published plus the user's + # own drafts. Drafts are private — any list, count, feed, search result, + # or folder content shown to a user must go through this scope (or + # PlanPolicy#show?, which mirrors it) so private draft existence never + # leaks. This is THE visibility predicate: never test `visibility` + # inline elsewhere. scope :visible_to, ->(user) { - where.not(status: "brainstorm").or(where(created_by_user_id: user.id)) + where(visibility: "published").or(where(created_by_user_id: user.id)) } + # Archived plans are hidden from every default surface; callers opt in + # with `.archived` or by dropping the `.active` scope explicitly. + scope :active, -> { where(archived_at: nil) } + scope :archived, -> { where.not(archived_at: nil) } + after_save_commit :refresh_search_text!, if: :search_text_needs_refresh? # Sitewide search over a denormalized `search_text` column maintained by @@ -63,7 +75,10 @@ class Plan < ApplicationRecord term = sanitize_fulltext_term(query) return none if term.blank? - visible_to(user) + # Archived plans stay out of search — they remain reachable by direct + # URL and via explicit archived filters, but never resurface on their + # own. + visible_to(user).active .where("MATCH(search_text) AGAINST (? IN BOOLEAN MODE)", term) .order(Arel.sql("MATCH(search_text) AGAINST (#{connection.quote(term)} IN BOOLEAN MODE) DESC")) } @@ -109,7 +124,7 @@ def self.build_search_text(plan) end def self.ransackable_attributes(auth_object = nil) - %w[id title status plan_type_id folder_id created_by_user_id current_plan_version_id current_revision created_at updated_at] + %w[id title visibility archived_at plan_type_id folder_id created_by_user_id current_plan_version_id current_revision created_at updated_at] end def self.ransackable_associations(auth_object = nil) @@ -120,6 +135,40 @@ def to_param id end + def draft? + visibility == "draft" + end + + def published? + visibility == "published" + end + + def archived? + archived_at.present? + end + + # Legacy API compatibility (see LEGACY_STATUSES). Emits the closest + # five-state equivalent of the current visibility/archival state. + def legacy_status + return "brainstorm" if draft? + return "abandoned" if archived? + "considering" + end + + # Maps a legacy five-state status write onto the canonical fields. + # Returns the attributes to assign; raises nothing — validation of the + # mapped values happens on save. + def self.attributes_for_legacy_status(status) + case status.to_s + when "brainstorm" then { visibility: "draft", archived_at: nil } + # Archiving must never implicitly publish: a draft archived via the + # legacy API stays a (archived) draft rather than leaking. + when "abandoned" then { archived_at: Time.current } + when *LEGACY_STATUSES then { visibility: "published", archived_at: nil } + else {} + end + end + def current_content current_plan_version&.content_markdown end diff --git a/engine/app/models/coplan/plan_event.rb b/engine/app/models/coplan/plan_event.rb index 876bda5e..64ca7781 100644 --- a/engine/app/models/coplan/plan_event.rb +++ b/engine/app/models/coplan/plan_event.rb @@ -15,7 +15,12 @@ class PlanEvent < ApplicationRecord # be rendered uniformly. ACTOR_TYPES = %w[human local_agent cloud_persona system].freeze + # status_changed is retired (no new writes) but stays valid so + # pre-visibility historical rows keep rendering. EVENT_TYPES = %w[ + published + archived + unarchived status_changed title_changed plan_type_changed diff --git a/engine/app/policies/coplan/plan_policy.rb b/engine/app/policies/coplan/plan_policy.rb index e852c742..47db702a 100644 --- a/engine/app/policies/coplan/plan_policy.rb +++ b/engine/app/policies/coplan/plan_policy.rb @@ -1,15 +1,31 @@ module CoPlan class PlanPolicy < ApplicationPolicy + # THE visibility predicate. Every surface that shows a plan — lists, + # feeds, search, folder contents, library placements, profile pages — + # must answer through here (or the mirrored Plan.visible_to scope for + # set-based queries). Never test `visibility`/`archived_at` inline in + # controllers or views. def show? - true + record.published? || record.created_by_user_id == user&.id end def update? record.created_by_user_id == user.id end - def update_status? - update? + # Publishing a draft is explicit and confirmed in the UI ("publishes to + # everyone"). There is no unpublish — retracting an already-read document + # is a lie; archiving is the tool for "I'm done with this". + def publish? + update? && record.draft? + end + + def archive? + update? && !record.archived? + end + + def unarchive? + update? && record.archived? end # Editing plan content in the web UI. Same rule as metadata for now: diff --git a/engine/app/services/coplan/plans/create.rb b/engine/app/services/coplan/plans/create.rb index 6145594f..0dc54f4c 100644 --- a/engine/app/services/coplan/plans/create.rb +++ b/engine/app/services/coplan/plans/create.rb @@ -1,20 +1,23 @@ module CoPlan module Plans class Create - def self.call(title:, content:, user:, plan_type_id: nil) - new(title:, content:, user:, plan_type_id:).call + # Plans are shared by default: they're created published unless the + # caller explicitly asks for a private draft. + def self.call(title:, content:, user:, plan_type_id: nil, visibility: "published") + new(title:, content:, user:, plan_type_id:, visibility:).call end - def initialize(title:, content:, user:, plan_type_id: nil) + def initialize(title:, content:, user:, plan_type_id: nil, visibility: "published") @title = title @content = content @user = user @plan_type_id = plan_type_id + @visibility = visibility end def call plan = ActiveRecord::Base.transaction do - plan = Plan.create!(title: @title, created_by_user: @user, plan_type_id: @plan_type_id) + plan = Plan.create!(title: @title, created_by_user: @user, plan_type_id: @plan_type_id, visibility: @visibility) version = PlanVersion.create!( plan: plan, revision: 1, @@ -31,7 +34,7 @@ def call user: @user, plan_id: plan.id, plan_type_id: plan.plan_type_id, - status: plan.status, + visibility: plan.visibility, content_length: @content.to_s.length ) diff --git a/engine/app/services/coplan/plans/log_event.rb b/engine/app/services/coplan/plans/log_event.rb index d59e2716..135583dd 100644 --- a/engine/app/services/coplan/plans/log_event.rb +++ b/engine/app/services/coplan/plans/log_event.rb @@ -75,6 +75,7 @@ def actor_type def default_field_for(event_type) case event_type + when "published", "archived", "unarchived" then "visibility" when "status_changed" then "status" when "title_changed" then "title" when "plan_type_changed" then "plan_type" diff --git a/engine/app/views/coplan/agent_instructions/show.text.erb b/engine/app/views/coplan/agent_instructions/show.text.erb index ef320b9f..c2005309 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -26,7 +26,7 @@ See [Editing Plans](#editing-plans-recommended-full-content-replacement) for the "<%= @base %>/api/v1/plans" | jq . ``` -Optional query param: `?status=considering` +Optional query params: `?visibility=draft` or `?visibility=published` (drafts are only ever your own), `?archived=true` (archived plans are excluded unless you ask for them). ### Get Plan @@ -35,7 +35,7 @@ Optional query param: `?status=considering` "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Returns: `id`, `title`, `status`, `current_content` (markdown), `current_revision`, `references` (array of linked resources). +Returns: `id`, `title`, `visibility`, `archived`, `current_content` (markdown), `current_revision`, `references` (array of linked resources). ### Get Plan Snapshot (Recommended) @@ -57,7 +57,7 @@ Returns: plan metadata, `current_content`, `current_revision`, `comment_threads` "<%= @base %>/api/v1/plans" | jq . ``` -Optional fields: `plan_type` (string) — the name of a plan type to use. See [Plan Types](#plan-types) below. +Optional fields: `plan_type` (string) — the name of a plan type to use (see [Plan Types](#plan-types) below); `visibility` (string) — plans are **published to the whole org by default**; pass `"draft"` to start private instead (see [Visibility & Archiving](#visibility--archiving)). #### Diagrams @@ -85,16 +85,16 @@ Other raw HTML is stripped on render (the Markdown source is preserved), so stic ### Update Plan -Update plan metadata (title, status, tags). Only fields included in the request body are changed. +Update plan metadata (title, visibility, tags). Only fields included in the request body are changed. ```bash <%= @curl %> -X PATCH \ -H "Content-Type: application/json" \ - -d '{"status": "considering"}' \ + -d '{"visibility": "published"}' \ "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -Allowed fields: `title` (string), `status` (string), `tags` (array of strings), `references` (array — see [References](#references) below). +Allowed fields: `title` (string), `visibility` (`"published"` — see [Visibility & Archiving](#visibility--archiving)), `archived` (boolean), `tags` (array of strings), `references` (array — see [References](#references) below). ### List Tags @@ -158,19 +158,23 @@ Each folder includes `id`, `name`, `parent_id`, `path` (e.g. `"Team EBT/Q3"`), a - Folder names read like places (`Team EBT`, `Infra`, `Q3 Launch`), not labels. - You can offer to organize a user's plans: list their plans, propose a folder structure, and move plans with `folder_path` once they agree. -### Plan Statuses +### Visibility & Archiving -Plans move through a lifecycle. **Keep the status current** — update it as the plan progresses. +Plans have two orthogonal state fields — there is no multi-step lifecycle to maintain: -| Status | Meaning | When to use | -|--------|---------|-------------| -| `brainstorm` | Private draft, only visible to the author | Initial creation, not ready for anyone else to see | -| `considering` | Published, open for review and feedback | Ready for others to read and comment on | -| `developing` | Actively being implemented | Work has started based on the plan | -| `live` | Shipped, in production | Implementation is complete | -| `abandoned` | No longer pursuing | Plan was dropped or superseded | +| Field | Values | Meaning | +|-------|--------|---------| +| `visibility` | `draft` / `published` | Drafts are private to the author. Published plans are visible to the whole org. | +| `archived` | `true` / `false` | Archived plans stay readable at their URL but disappear from lists and search unless explicitly filtered for. | -When you create a plan, it starts as `brainstorm`. Promote it to `considering` when it's ready for review. Move to `developing` when implementation begins. Don't leave plans in stale states. +Rules that matter for agents: + +- **New plans are published (org-visible) from the moment they're created.** Sharing is the default. Pass `"visibility": "draft"` on create only when the user explicitly wants a private draft; publish a draft later with `PATCH {"visibility": "published"}`. Publishing is one-way (a published plan cannot return to draft; archive it instead). +- Archive (`{"archived": true}`) anything that is dead, superseded, or was created by mistake — including your own duplicates. Unarchive with `{"archived": false}`. +- **Never create a new plan to "replace" one you can edit.** Plans have full version history; edit the existing plan (see Editing Plans). If a plan genuinely supersedes another, add a reference between them and archive the old one. +- If a plan has a real rollout lifecycle worth tracking, use tags (e.g. `developing`, `live`) — status fields for that no longer exist. + +The API also accepts the legacy `status` field (`brainstorm`/`considering`/`developing`/`live`/`abandoned`) for older integrations, mapping it onto visibility/archived. New integrations should use `visibility` and `archived`. ### Plan Types diff --git a/engine/app/views/coplan/plans/_header.html.erb b/engine/app/views/coplan/plans/_header.html.erb index 8e56518d..22122a89 100644 --- a/engine/app/views/coplan/plans/_header.html.erb +++ b/engine/app/views/coplan/plans/_header.html.erb @@ -2,7 +2,7 @@

<%= plan.title %>

- <%= user_avatar(plan.created_by_user) %> <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · <%= plan.status %> + <%= user_avatar(plan.created_by_user) %> <%= plan.created_by_user.name %> · v<%= plan.current_revision %><%= plan_state_badge(plan) %> <% if plan.plan_type %> · <%= plan.plan_type.name %> <% end %> diff --git a/engine/app/views/coplan/plans/_plan_group.html.erb b/engine/app/views/coplan/plans/_plan_group.html.erb index a88d4a08..fbb3f887 100644 --- a/engine/app/views/coplan/plans/_plan_group.html.erb +++ b/engine/app/views/coplan/plans/_plan_group.html.erb @@ -1,16 +1,17 @@ -<%# A collapsible status group in the main pane. Collapsed state persists - per user in localStorage (coplan--plan-groups controller); brainstorms - start collapsed by default. The group body contains the first page of - rows plus the lazy "load more" frame chain, so collapsing a group also - stops further pages from loading. %> +<%# A collapsible group in the main pane (published work, or the viewer's + private drafts). Collapsed state persists per user in localStorage + (coplan--plan-groups controller); drafts start collapsed by default. + The group body contains the first page of rows plus the lazy "load + more" frame chain, so collapsing a group also stops further pages from + loading. %>
> + data-group-key="<%= group[:group] %>" + <%= "data-default-collapsed".html_safe if group[:group] == "draft" %>>

@@ -19,7 +20,7 @@ plan_unread_counts: @plan_unread_counts, page: 1, has_next_page: group[:has_next_page], - group_key: group[:status], - frame_status: group[:status] %> + group_key: group[:group], + frame_filter: group[:group] %>
diff --git a/engine/app/views/coplan/plans/_plan_page.html.erb b/engine/app/views/coplan/plans/_plan_page.html.erb index 0fdea7b4..f21d08ce 100644 --- a/engine/app/views/coplan/plans/_plan_page.html.erb +++ b/engine/app/views/coplan/plans/_plan_page.html.erb @@ -1,8 +1,8 @@ -<%# One page of plan rows within a list (a status group, or the flat - ?status= results list), plus the lazily-loaded next-page frame. +<%# One page of plan rows within a list (a published/draft group, or the + flat ?filter= results list), plus the lazily-loaded next-page frame. `group_key` namespaces the frame ids so multiple groups can paginate - independently on the same page; `frame_status` is the status filter the - next-page request should carry. %> + independently on the same page; `frame_filter` is the workspace filter + the next-page request should carry. %> <%= turbo_frame_tag "plans-#{group_key}-page-#{page}" do %> <% plans.each do |plan| %> <%= render "coplan/plans/plan_row", plan: plan, unread: plan_unread_counts[plan.id].to_i %> @@ -10,7 +10,7 @@ <% if has_next_page %> <% next_page_params = params.permit(:scope, :plan_type, :tag, :folder) - .merge(page: page + 1, group: group_key, status: frame_status) %> + .merge(page: page + 1, group: group_key, filter: frame_filter) %> <%= turbo_frame_tag "plans-#{group_key}-page-#{page + 1}", src: plans_path(next_page_params), loading: :lazy do %> diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index 8d714dce..797884d1 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -5,19 +5,18 @@ <% author = plan.created_by_user_id == current_user.id %> <% summary = plan.try(:summary).presence || plan_content_preview(plan) %> -
" data-plan-id="<%= plan.id %>" - data-status="<%= plan.status %>" + data-visibility="<%= plan.visibility %>" <% if author %> draggable="true" data-move-url="<%= move_to_folder_plan_path(plan) %>" data-action="dragstart->coplan--folder-dnd#dragStart dragend->coplan--folder-dnd#dragEnd" <% end %>>
- <%= link_to plan.title, plan_path(plan), class: "plan-row__title", data: { turbo_frame: "_top" } %> - <%= plan.status %> + <%= link_to plan.title, plan_path(plan), class: "plan-row__title", data: { turbo_frame: "_top" } %><%= plan_state_badge(plan) %> <% if plan.folder %> - <%= link_to plans_path(params.permit(:scope, :status, :plan_type, :tag).merge(folder: plan.folder_id)), + <%= link_to plans_path(params.permit(:scope, :filter, :plan_type, :tag).merge(folder: plan.folder_id)), class: "plan-row__folder", data: { turbo_frame: "_top" } do %> <%= folder_path_for(plan.folder) %> @@ -25,7 +24,7 @@ <% end %> <% plan.tags.each do |tag| %> <%= link_to tag.name, - plans_path(params.permit(:scope, :status, :plan_type, :folder).merge(tag: tag.name)), + plans_path(params.permit(:scope, :filter, :plan_type, :folder).merge(tag: tag.name)), class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}", data: { turbo_frame: "_top" } %> <% end %> diff --git a/engine/app/views/coplan/plans/_sidebar.html.erb b/engine/app/views/coplan/plans/_sidebar.html.erb index c498751b..8941bbd2 100644 --- a/engine/app/views/coplan/plans/_sidebar.html.erb +++ b/engine/app/views/coplan/plans/_sidebar.html.erb @@ -3,7 +3,7 @@
  • - <%= link_to plans_path(params.permit(:scope, :status, :plan_type, :tag)), + <%= link_to plans_path(params.permit(:scope, :filter, :plan_type, :tag)), class: "folder-tree__link #{'folder-tree__link--active' unless @folder}", data: { "coplan--folder-dnd-target": "folder", @@ -45,7 +45,7 @@
@@ -77,7 +87,7 @@ <% @plan_types.each do |pt| %>
  • <%= link_to pt.name, - plans_path(params.permit(:scope, :status, :tag, :folder).merge(plan_type: params[:plan_type] == pt.id ? nil : pt.id)), + plans_path(params.permit(:scope, :filter, :tag, :folder).merge(plan_type: params[:plan_type] == pt.id ? nil : pt.id)), class: "sidebar__item #{'sidebar__item--active' if params[:plan_type] == pt.id}" %>
  • <% end %> diff --git a/engine/app/views/coplan/plans/index.html.erb b/engine/app/views/coplan/plans/index.html.erb index 4b90e832..7e142f01 100644 --- a/engine/app/views/coplan/plans/index.html.erb +++ b/engine/app/views/coplan/plans/index.html.erb @@ -4,17 +4,18 @@
    <% active_filters = [] %> <% if @folder %> - <% active_filters << ["Folder: #{folder_path_for(@folder)}", plans_path(params.permit(:scope, :status, :plan_type, :tag))] %> + <% active_filters << ["Folder: #{folder_path_for(@folder)}", plans_path(params.permit(:scope, :filter, :plan_type, :tag))] %> <% end %> <% if params[:tag].present? %> - <% active_filters << ["Tag: #{params[:tag]}", plans_path(params.permit(:scope, :status, :plan_type, :folder))] %> + <% active_filters << ["Tag: #{params[:tag]}", plans_path(params.permit(:scope, :filter, :plan_type, :folder))] %> <% end %> <% if params[:plan_type].present? %> <% type_name = @plan_types&.find { |pt| pt.id == params[:plan_type] }&.name || "Type" %> - <% active_filters << ["Type: #{type_name}", plans_path(params.permit(:scope, :status, :tag, :folder))] %> + <% active_filters << ["Type: #{type_name}", plans_path(params.permit(:scope, :filter, :tag, :folder))] %> <% end %> - <% if params[:status].present? %> - <% active_filters << ["Status: #{params[:status].titleize}", plans_path(params.permit(:scope, :plan_type, :tag, :folder))] %> + <% if @filter.present? %> + <% filter_label = { "draft" => "Drafts", "published" => "Published", "archived" => "Archived" }.fetch(@filter) %> + <% active_filters << [filter_label, plans_path(params.permit(:scope, :plan_type, :tag, :folder))] %> <% end %> <% if active_filters.any? %> @@ -75,7 +76,7 @@
    <% end %> <% else %> - <%# Flat mode: filtered to a single status via ?status= %> + <%# Flat mode: narrowed to one group (or archived opt-in) via ?filter= %> <% if @plans.any? %>
    <%= render "coplan/plans/plan_page", plans: @plans, @@ -83,11 +84,16 @@ page: @page, has_next_page: @has_next_page, group_key: "results", - frame_status: params[:status].presence %> + frame_filter: @filter %>
    <% else %>
    -

    No <%= params[:status] %> plans match the current filters.

    + <% if @filter == "archived" %> +

    Nothing in the archive<%= " matches the current filters" if params[:tag].present? || params[:plan_type].present? || @folder %>.

    +

    Archiving a plan hides it from lists and search without deleting it — it will show up here.

    + <% else %> +

    No <%= @filter %> plans match the current filters.

    + <% end %>
    <% end %> <% end %> diff --git a/engine/app/views/coplan/plans/show.html.erb b/engine/app/views/coplan/plans/show.html.erb index 8f003e51..ceca5156 100644 --- a/engine/app/views/coplan/plans/show.html.erb +++ b/engine/app/views/coplan/plans/show.html.erb @@ -18,30 +18,31 @@ <%# Owner affordances live outside the broadcast-replaced #plan-header so they can be policy-gated server-side (broadcast renders have no current_user). %> -<% if allowed_to?(@plan, :edit_content?) || allowed_to?(@plan, :update_status?) %> +<% if @plan.archived? %> +
    + This plan is archived — it stays readable at this URL but is hidden from lists and search results. + <% if allowed_to?(@plan, :unarchive?) %> + <%= button_to "Restore", unarchive_plan_path(@plan), method: :patch, class: "btn btn--secondary btn--sm" %> + <% end %> +
    +<% end %> +<% if allowed_to?(@plan, :update?) %>
    + <% if allowed_to?(@plan, :publish?) %> + <%= button_to publish_plan_path(@plan), + method: :patch, + class: "btn btn--primary btn--sm", + form: { data: { turbo_confirm: "Publishing makes this plan visible to everyone in the org. Continue?" } } do %> + Publish + <% end %> + <% end %> <% if allowed_to?(@plan, :edit_content?) %> <%= link_to edit_content_plan_path(@plan), class: "btn btn--secondary btn--sm" do %>✏️ Edit content<% end %> <%= link_to edit_plan_path(@plan), class: "btn btn--secondary btn--sm" do %>Title & tags<% end %> <% end %> - <% if allowed_to?(@plan, :update_status?) %> - + <% if allowed_to?(@plan, :archive?) %> + <%= button_to "Archive", archive_plan_path(@plan), method: :patch, + class: "btn btn--ghost btn--sm plan-actions__archive" %> <% end %>
    <% end %> diff --git a/engine/app/views/coplan/search/_results.html.erb b/engine/app/views/coplan/search/_results.html.erb index 5feb194d..92c51e26 100644 --- a/engine/app/views/coplan/search/_results.html.erb +++ b/engine/app/views/coplan/search/_results.html.erb @@ -48,9 +48,11 @@ tabindex="-1"> <%= plan.title %> - <%= plan.status %> + <% if plan.draft? %> + Draft · + <% end %> <% if plan.created_by_user %> - · <%= plan.created_by_user.name %> + <%= plan.created_by_user.name %> <% end %> <% if plan.tags.any? %> · <%= plan.tags.map(&:name).first(3).join(", ") %> diff --git a/engine/app/views/coplan/welcome/_default_landing.html.erb b/engine/app/views/coplan/welcome/_default_landing.html.erb index 183e9bde..0a9f05dc 100644 --- a/engine/app/views/coplan/welcome/_default_landing.html.erb +++ b/engine/app/views/coplan/welcome/_default_landing.html.erb @@ -26,8 +26,8 @@

    Write a plan

    A plan is a Markdown design doc. Author it yourself, or have an AI agent draft one - for you using the API. Plans live through a clear lifecycle: brainstorm → considering - → developing → live. + for you using the API. Plans are shared with the org from the start — keep one + private with a draft flag until it's ready.

    diff --git a/engine/config/routes.rb b/engine/config/routes.rb index d76759dc..96b4319f 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -1,6 +1,8 @@ CoPlan::Engine.routes.draw do resources :plans, only: [:index, :show, :edit, :update] do - patch :update_status, on: :member + patch :publish, on: :member + patch :archive, on: :member + patch :unarchive, on: :member patch :toggle_checkbox, on: :member patch :move_to_folder, on: :member get :history, on: :member diff --git a/engine/db/migrate/20260717000000_replace_status_with_visibility_and_archival.rb b/engine/db/migrate/20260717000000_replace_status_with_visibility_and_archival.rb new file mode 100644 index 00000000..02fa9381 --- /dev/null +++ b/engine/db/migrate/20260717000000_replace_status_with_visibility_and_archival.rb @@ -0,0 +1,73 @@ +class ReplaceStatusWithVisibilityAndArchival < ActiveRecord::Migration[8.1] + # Migration-local model stubs so this migration never breaks as app models + # evolve. Only the columns touched here are relied upon. + class MigrationPlan < ActiveRecord::Base + self.table_name = "coplan_plans" + end + + class MigrationTag < ActiveRecord::Base + self.table_name = "coplan_tags" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlanTag < ActiveRecord::Base + self.table_name = "coplan_plan_tags" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + def up + # Plans are shared by default — private drafts are the opt-in exception, + # so "published" is the column default for new rows. + add_column :coplan_plans, :visibility, :string, null: false, default: "published" + add_column :coplan_plans, :archived_at, :datetime + add_index :coplan_plans, :visibility + add_index :coplan_plans, :archived_at + + # brainstorm was "private draft"; everything else was org-visible. + # abandoned was the de-facto archive. developing/live carried lifecycle + # info some plans genuinely have, so it is preserved as tags rather than + # destroyed. updated_at is deliberately left untouched — archival state + # is derived data, not user activity. + execute <<~SQL + UPDATE coplan_plans + SET visibility = CASE WHEN status = 'brainstorm' THEN 'draft' ELSE 'published' END, + archived_at = CASE WHEN status = 'abandoned' THEN updated_at ELSE NULL END + SQL + + %w[developing live].each do |lifecycle| + plan_ids = MigrationPlan.where(status: lifecycle).pluck(:id) + next if plan_ids.empty? + + tag = MigrationTag.find_or_create_by!(name: lifecycle) + existing = MigrationPlanTag.where(tag_id: tag.id, plan_id: plan_ids).pluck(:plan_id) + (plan_ids - existing).each do |plan_id| + MigrationPlanTag.create!(plan_id: plan_id, tag_id: tag.id) + end + end + + remove_index :coplan_plans, :status + remove_column :coplan_plans, :status + end + + def down + add_column :coplan_plans, :status, :string, null: false, default: "brainstorm" + add_index :coplan_plans, :status + + # Best-effort reversal: draft→brainstorm, archived→abandoned, otherwise + # considering (the developing/live distinction lives in tags and is not + # re-derived here). + execute <<~SQL + UPDATE coplan_plans + SET status = CASE + WHEN visibility = 'draft' THEN 'brainstorm' + WHEN archived_at IS NOT NULL THEN 'abandoned' + ELSE 'considering' + END + SQL + + remove_index :coplan_plans, :archived_at + remove_index :coplan_plans, :visibility + remove_column :coplan_plans, :archived_at + remove_column :coplan_plans, :visibility + end +end diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb index fa640241..1d56c5f8 100644 --- a/spec/factories/plans.rb +++ b/spec/factories/plans.rb @@ -2,7 +2,7 @@ factory :plan, class: "CoPlan::Plan" do created_by_user { association(:coplan_user) } sequence(:title) { |n| "Plan #{n}" } - status { "brainstorm" } + visibility { "draft" } tags { [] } metadata { {} } @@ -13,24 +13,41 @@ end end - trait :considering do - status { "considering" } + trait :draft do + visibility { "draft" } + end + + trait :published do + visibility { "published" } + end + + trait :archived do + visibility { "published" } + archived_at { Time.current } end + # Legacy status traits, mapped onto visibility/archived so the many + # existing specs that build lifecycle-era plans keep working. New specs + # should use :draft / :published / :archived directly. trait :brainstorm do - status { "brainstorm" } + visibility { "draft" } + end + + trait :considering do + visibility { "published" } end trait :developing do - status { "developing" } + visibility { "published" } end trait :live do - status { "live" } + visibility { "published" } end trait :abandoned do - status { "abandoned" } + visibility { "published" } + archived_at { Time.current } end end end From 2456eec9488f56c96d03c4035f41684add9fb1a9 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Jul 2026 16:09:44 -0500 Subject: [PATCH 02/19] Fix Phase 1 stragglers: og description, reference search, badges, broadcast helper Co-Authored-By: Claude Fable 5 --- app/assets/stylesheets/application.css | 7 +- .../coplan/api/v1/folders_controller.rb | 2 +- .../coplan/api/v1/references_controller.rb | 3 +- .../coplan/application_controller.rb | 1 + .../controllers/coplan/plans_controller.rb | 2 +- .../app/helpers/coplan/application_helper.rb | 10 +- engine/app/models/coplan/plan.rb | 2 +- engine/app/services/coplan/plans/log_event.rb | 8 +- .../coplan/comment_threads/_thread.html.erb | 4 +- .../comment_threads/_thread_popover.html.erb | 2 +- .../views/coplan/plans/_event_item.html.erb | 2 +- .../views/coplan/plans/_version_item.html.erb | 2 +- ...7010000_create_libraries_and_placements.rb | 120 ++++++++++++++++++ spec/system/folders_workspace_spec.rb | 14 +- spec/system/human_editing_spec.rb | 28 +++- 15 files changed, 173 insertions(+), 34 deletions(-) create mode 100644 engine/db/migrate/20260717010000_create_libraries_and_placements.rb diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 940b6678..2bf727b5 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -356,11 +356,8 @@ img, svg { letter-spacing: 0.025em; } -.badge--brainstorm { background: var(--color-status-brainstorm-bg); color: var(--color-status-brainstorm); } -.badge--considering { background: var(--color-status-considering-bg); color: var(--color-status-considering); } -.badge--developing { background: var(--color-status-developing-bg); color: var(--color-status-developing); } -.badge--live { background: var(--color-status-live-bg); color: var(--color-status-live); } -.badge--abandoned { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } +.badge--draft { background: var(--color-status-brainstorm-bg); color: var(--color-status-brainstorm); } +.badge--archived { background: var(--color-status-abandoned-bg); color: var(--color-status-abandoned); } .badge--success { background: var(--color-success-soft); color: var(--color-success); } .badge--warning { background: var(--color-warning-soft); color: var(--color-warning); } .badge--danger { background: var(--color-danger-soft); color: var(--color-danger); } diff --git a/engine/app/controllers/coplan/api/v1/folders_controller.rb b/engine/app/controllers/coplan/api/v1/folders_controller.rb index 3139bd7b..37fbf998 100644 --- a/engine/app/controllers/coplan/api/v1/folders_controller.rb +++ b/engine/app/controllers/coplan/api/v1/folders_controller.rb @@ -9,7 +9,7 @@ def index paths = Folder.paths_by_id(folders) # Visible-plan counts only — never leak the existence of other - # users' private brainstorm plans through folder counts. + # users' private draft plans through folder counts. counts = Plan.visible_to(current_user) .where.not(folder_id: nil) .group(:folder_id) diff --git a/engine/app/controllers/coplan/api/v1/references_controller.rb b/engine/app/controllers/coplan/api/v1/references_controller.rb index 7c9e209c..60b11583 100644 --- a/engine/app/controllers/coplan/api/v1/references_controller.rb +++ b/engine/app/controllers/coplan/api/v1/references_controller.rb @@ -63,7 +63,8 @@ def search reference_json(r).merge( plan_id: r.plan_id, plan_title: r.plan.title, - plan_status: r.plan.status + plan_visibility: r.plan.visibility, + plan_status: r.plan.legacy_status ) } end diff --git a/engine/app/controllers/coplan/application_controller.rb b/engine/app/controllers/coplan/application_controller.rb index b4e147b4..d6c29b14 100644 --- a/engine/app/controllers/coplan/application_controller.rb +++ b/engine/app/controllers/coplan/application_controller.rb @@ -8,6 +8,7 @@ def self.controller_path end helper CoPlan::ApplicationHelper + helper CoPlan::PlansHelper helper CoPlan::MarkdownHelper helper CoPlan::CommentsHelper helper CoPlan::ReferencesHelper diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 7b741cfb..8fcc402d 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -416,7 +416,7 @@ def folder_subtree_ids(folder) # Sidebar data: the folder tree with per-folder plan counts, and the # most-used tags. Counts and tag usage use the same base relation as # the main pane (scoped_plans_base) so they match what clicking shows — - # which also means other users' private brainstorm plans never leak + # which also means other users' private draft plans never leak # through folder counts or tag lists (Plan.visible_to). def load_workspace_sidebar # Archived plans are hidden from default lists, so they're excluded diff --git a/engine/app/helpers/coplan/application_helper.rb b/engine/app/helpers/coplan/application_helper.rb index b2e3328a..f41df045 100644 --- a/engine/app/helpers/coplan/application_helper.rb +++ b/engine/app/helpers/coplan/application_helper.rb @@ -39,9 +39,15 @@ def coplan_favicon_tag end def plan_og_description(plan) - status = plan.status.capitalize + state = if plan.archived? + "Archived" + elsif plan.draft? + "Draft" + else + "Plan" + end author = plan.created_by_user.name - prefix = "#{status} · by #{author}" + prefix = "#{state} · by #{author}" content = plan.current_content return prefix if content.blank? diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 7bc13908..5f95a090 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -68,7 +68,7 @@ class Plan < ApplicationRecord # support prefix matches (`foo*`) and don't trip MySQL's 50%-of-rows # natural-language threshold on small datasets. # - # Visibility: brainstorm plans are hidden from everyone except their + # Visibility: draft plans are hidden from everyone except their # author — matches the `index` action's filter. `user` is required; # the controller enforces sign-in so we don't have to handle nil here. scope :search, ->(query, user:) { diff --git a/engine/app/services/coplan/plans/log_event.rb b/engine/app/services/coplan/plans/log_event.rb index 135583dd..4a2dad8c 100644 --- a/engine/app/services/coplan/plans/log_event.rb +++ b/engine/app/services/coplan/plans/log_event.rb @@ -13,10 +13,10 @@ module Plans # Plans::LogEvent.call( # plan: plan, # actor: current_user, - # event_type: "status_changed", - # field: "status", - # before: "considering", - # after: "developing" + # event_type: "published", + # field: "visibility", + # before: "draft", + # after: "published" # ) # # For events without a meaningful before/after (e.g. a reference being diff --git a/engine/app/views/coplan/comment_threads/_thread.html.erb b/engine/app/views/coplan/comment_threads/_thread.html.erb index a50a3347..6775c5c2 100644 --- a/engine/app/views/coplan/comment_threads/_thread.html.erb +++ b/engine/app/views/coplan/comment_threads/_thread.html.erb @@ -7,9 +7,9 @@ data-thread-author-id="<%= thread.created_by_user_id %>">
    - <%= thread.status %> + <%= thread.status %> <% if thread.out_of_date? %> - out of date + out of date <% end %>
    <% if thread.anchored? %> diff --git a/engine/app/views/coplan/comment_threads/_thread_popover.html.erb b/engine/app/views/coplan/comment_threads/_thread_popover.html.erb index e0031c2d..91a0dd4f 100644 --- a/engine/app/views/coplan/comment_threads/_thread_popover.html.erb +++ b/engine/app/views/coplan/comment_threads/_thread_popover.html.erb @@ -11,7 +11,7 @@
    <%= thread.status %> <% if thread.out_of_date? %> - out of date + out of date <% end %> <%= link_to plan_path(plan, thread: thread.id), class: "thread-popover__permalink btn btn--secondary btn--sm", diff --git a/engine/app/views/coplan/plans/_event_item.html.erb b/engine/app/views/coplan/plans/_event_item.html.erb index b1d252f2..584331ec 100644 --- a/engine/app/views/coplan/plans/_event_item.html.erb +++ b/engine/app/views/coplan/plans/_event_item.html.erb @@ -8,7 +8,7 @@ <% if event.actor_user %> <%= user_avatar(event.actor_user) %> <%= event.actor_user.name %> <% else %> - <%= event.actor_type %> + <%= event.actor_type %> <% end %>
    diff --git a/engine/app/views/coplan/plans/_version_item.html.erb b/engine/app/views/coplan/plans/_version_item.html.erb index 36b97b93..27356909 100644 --- a/engine/app/views/coplan/plans/_version_item.html.erb +++ b/engine/app/views/coplan/plans/_version_item.html.erb @@ -6,7 +6,7 @@ <% if version.actor_user %> <%= user_avatar(version.actor_user) %> <%= version.actor_user.name %> <% else %> - <%= version.actor_type %> + <%= version.actor_type %> <% end %>
    <% if version.change_summary.present? %> diff --git a/engine/db/migrate/20260717010000_create_libraries_and_placements.rb b/engine/db/migrate/20260717010000_create_libraries_and_placements.rb new file mode 100644 index 00000000..8bee611a --- /dev/null +++ b/engine/db/migrate/20260717010000_create_libraries_and_placements.rb @@ -0,0 +1,120 @@ +class CreateLibrariesAndPlacements < ActiveRecord::Migration[8.1] + class MigrationFolder < ActiveRecord::Base + self.table_name = "coplan_folders" + end + + class MigrationLibrary < ActiveRecord::Base + self.table_name = "coplan_libraries" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlacement < ActiveRecord::Base + self.table_name = "coplan_plan_placements" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlan < ActiveRecord::Base + self.table_name = "coplan_plans" + end + + def up + # A library is the owner-shaped container for a folder tree. Owner is + # polymorphic: users in v1, teams (or other group types) later. + create_table :coplan_libraries, id: { type: :string, limit: 36 } do |t| + t.string :owner_type, null: false + t.string :owner_id, limit: 36, null: false + t.string :name, null: false, default: "Library" + t.timestamps + + # One library per owner (v1). Relax deliberately if/when multiple + # libraries per owner become a real product need. + t.index [ :owner_type, :owner_id ], unique: true + end + + add_column :coplan_folders, :library_id, :string, limit: 36 + add_index :coplan_folders, :library_id + + # A placement shelves a plan in a folder. It is the viewer's organization + # of the plan, not a property of the plan itself: the same plan can sit + # in many libraries at once. library_id is denormalized from the folder + # so "one spot per library" is enforceable with a unique index. + create_table :coplan_plan_placements, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :folder_id, limit: 36, null: false + t.string :library_id, limit: 36, null: false + t.string :placed_by_user_id, limit: 36 + t.timestamps + + t.index [ :plan_id, :library_id ], unique: true + t.index :folder_id + t.index :library_id + end + + # The org-global folder design (#145) never reached production, so this + # backfill only matters for development databases: each folder joins its + # creator's library, creator-less folders are dropped (dev debris), and + # plans' single folder_id becomes a placement. + MigrationFolder.reset_column_information + MigrationFolder.where(created_by_user_id: nil).delete_all + MigrationFolder.find_each do |folder| + library = MigrationLibrary.find_or_create_by!( + owner_type: "CoPlan::User", + owner_id: folder.created_by_user_id + ) + folder.update_columns(library_id: library.id) + end + + MigrationPlan.where.not(folder_id: nil).find_each do |plan| + folder = MigrationFolder.find_by(id: plan.folder_id) + next unless folder&.library_id + + MigrationPlacement.create!( + plan_id: plan.id, + folder_id: folder.id, + library_id: folder.library_id, + placed_by_user_id: plan.created_by_user_id + ) + end + + change_column_null :coplan_folders, :library_id, false + + # Folder names are unique per parent within a library now (two people + # can both have a root "Projects" folder). + remove_index :coplan_folders, column: [ :parent_id, :name ], unique: true + add_index :coplan_folders, [ :library_id, :parent_id, :name ], unique: true + + remove_index :coplan_plans, :folder_id + remove_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + remove_column :coplan_plans, :folder_id + + add_foreign_key :coplan_folders, :coplan_libraries, column: :library_id + add_foreign_key :coplan_plan_placements, :coplan_plans, column: :plan_id + add_foreign_key :coplan_plan_placements, :coplan_folders, column: :folder_id + add_foreign_key :coplan_plan_placements, :coplan_libraries, column: :library_id + add_foreign_key :coplan_plan_placements, :coplan_users, column: :placed_by_user_id + end + + def down + add_column :coplan_plans, :folder_id, :string, limit: 36 + add_index :coplan_plans, :folder_id + add_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + + # Restore each plan's author-library placement as its folder, then drop + # the placement machinery. + MigrationPlacement.reset_column_information + MigrationPlacement.find_each do |placement| + plan = MigrationPlan.find_by(id: placement.plan_id) + next unless plan && plan.created_by_user_id == placement.placed_by_user_id + + plan.update_columns(folder_id: placement.folder_id) + end + + remove_index :coplan_folders, column: [ :library_id, :parent_id, :name ], unique: true + add_index :coplan_folders, [ :parent_id, :name ], unique: true + remove_foreign_key :coplan_folders, :coplan_libraries, column: :library_id + remove_column :coplan_folders, :library_id + + drop_table :coplan_plan_placements + drop_table :coplan_libraries + end +end diff --git a/spec/system/folders_workspace_spec.rb b/spec/system/folders_workspace_spec.rb index 543ef6c6..3abdd22c 100644 --- a/spec/system/folders_workspace_spec.rb +++ b/spec/system/folders_workspace_spec.rb @@ -65,20 +65,20 @@ def sign_in(user) end end - describe "collapsible status groups" do - it "collapses brainstorms by default and persists toggles across reloads" do + describe "collapsible visibility groups" do + it "collapses drafts by default and persists toggles across reloads" do visit plans_path - # Brainstorm group is collapsed by default: header visible, rows hidden. - expect(page).to have_css('[data-group-key="brainstorm"]') + # Draft group is collapsed by default: header visible, rows hidden. + expect(page).to have_css('[data-group-key="draft"]') expect(page).not_to have_content("Secret Idea") # Expand it. - find('[data-group-key="brainstorm"] .plan-group__toggle').click + find('[data-group-key="draft"] .plan-group__toggle').click expect(page).to have_content("Secret Idea") - # Collapse developing. - find('[data-group-key="developing"] .plan-group__toggle').click + # Collapse published. + find('[data-group-key="published"] .plan-group__toggle').click expect(page).not_to have_content("Payments Plan") # State persists across a reload (localStorage). diff --git a/spec/system/human_editing_spec.rb b/spec/system/human_editing_spec.rb index 99c57fe3..f876de19 100644 --- a/spec/system/human_editing_spec.rb +++ b/spec/system/human_editing_spec.rb @@ -4,7 +4,7 @@ let(:author) { create(:coplan_user, email: "author@example.com") } let(:plan) do - p = CoPlan::Plan.create!(title: "Editable Plan", status: "considering", created_by_user: author) + p = CoPlan::Plan.create!(title: "Editable Plan", visibility: "published", created_by_user: author) version = CoPlan::PlanVersion.create!( plan: p, revision: 1, content_markdown: "# Editable Plan\n\nFirst draft body.\n", @@ -54,14 +54,27 @@ def sign_in(user) expect(page).to have_field("content", with: /Preview me/) end - it "changes status from the dropdown" do + it "publishes a draft from the plan page" do + plan.update!(visibility: "draft") visit plan_path(plan) - click_button "Move to…" - within(".dropdown__menu") { click_button "developing" } + accept_confirm { click_button "Publish" } - expect(page).to have_content("Status updated to developing.") - expect(plan.reload.status).to eq("developing") + expect(page).to have_content("Plan published — everyone can see it now.") + expect(plan.reload.visibility).to eq("published") + expect(page).not_to have_button("Publish") + end + + it "archives and restores the plan" do + visit plan_path(plan) + + click_button "Archive" + expect(page).to have_content("Plan archived.") + expect(plan.reload.archived?).to be(true) + + click_button "Restore" + expect(page).to have_content("Plan restored.") + expect(plan.reload.archived?).to be(false) end it "hides owner controls from non-authors" do @@ -72,6 +85,7 @@ def sign_in(user) visit plan_path(plan) expect(page).to have_content("Editable Plan") expect(page).not_to have_link("Edit content") - expect(page).not_to have_button("Move to…") + expect(page).not_to have_button("Archive") + expect(page).not_to have_button("Publish") end end From 7230a5db2279629c63d68da4793ccb18688bcbde Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Jul 2026 16:14:09 -0500 Subject: [PATCH 03/19] Phase 2 migration: libraries, placements, folder re-parenting Co-Authored-By: Claude Fable 5 --- ...create_libraries_and_placements.co_plan.rb | 125 ++++++++++++++++++ db/schema.rb | 37 +++++- ...7010000_create_libraries_and_placements.rb | 8 +- 3 files changed, 163 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb diff --git a/db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb b/db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb new file mode 100644 index 00000000..52b39512 --- /dev/null +++ b/db/migrate/20260717211002_create_libraries_and_placements.co_plan.rb @@ -0,0 +1,125 @@ +# This migration comes from co_plan (originally 20260717010000) +class CreateLibrariesAndPlacements < ActiveRecord::Migration[8.1] + class MigrationFolder < ActiveRecord::Base + self.table_name = "coplan_folders" + end + + class MigrationLibrary < ActiveRecord::Base + self.table_name = "coplan_libraries" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlacement < ActiveRecord::Base + self.table_name = "coplan_plan_placements" + before_create { self.id ||= SecureRandom.uuid_v7 } + end + + class MigrationPlan < ActiveRecord::Base + self.table_name = "coplan_plans" + end + + def up + # A library is the owner-shaped container for a folder tree. Owner is + # polymorphic: users in v1, teams (or other group types) later. + create_table :coplan_libraries, id: { type: :string, limit: 36 } do |t| + t.string :owner_type, null: false + t.string :owner_id, limit: 36, null: false + t.string :name, null: false, default: "Library" + t.timestamps + + # One library per owner (v1). Relax deliberately if/when multiple + # libraries per owner become a real product need. + t.index [ :owner_type, :owner_id ], unique: true + end + + add_column :coplan_folders, :library_id, :string, limit: 36 + add_index :coplan_folders, :library_id + + # A placement shelves a plan in a folder. It is the viewer's organization + # of the plan, not a property of the plan itself: the same plan can sit + # in many libraries at once. library_id is denormalized from the folder + # so "one spot per library" is enforceable with a unique index. + create_table :coplan_plan_placements, id: { type: :string, limit: 36 } do |t| + t.string :plan_id, limit: 36, null: false + t.string :folder_id, limit: 36, null: false + t.string :library_id, limit: 36, null: false + t.string :placed_by_user_id, limit: 36 + t.timestamps + + t.index [ :plan_id, :library_id ], unique: true + t.index :folder_id + t.index :library_id + end + + # The org-global folder design (#145) never reached production, so this + # backfill only matters for development databases: each folder joins its + # creator's library, creator-less folders are dropped (dev debris), and + # plans' single folder_id becomes a placement. + MigrationFolder.reset_column_information + MigrationFolder.where(created_by_user_id: nil).delete_all + MigrationFolder.find_each do |folder| + library = MigrationLibrary.find_or_create_by!( + owner_type: "CoPlan::User", + owner_id: folder.created_by_user_id + ) + folder.update_columns(library_id: library.id) + end + + MigrationPlan.where.not(folder_id: nil).find_each do |plan| + folder = MigrationFolder.find_by(id: plan.folder_id) + next unless folder&.library_id + + MigrationPlacement.create!( + plan_id: plan.id, + folder_id: folder.id, + library_id: folder.library_id, + placed_by_user_id: plan.created_by_user_id + ) + end + + change_column_null :coplan_folders, :library_id, false + + # Folder names are unique per parent within a library now (two people + # can both have a root "Projects" folder). The parent_id FK needs an + # index leading with parent_id, which the old composite provided — add + # a plain one before MySQL will let the composite go. + add_index :coplan_folders, :parent_id + remove_index :coplan_folders, column: [ :parent_id, :name ], unique: true + add_index :coplan_folders, [ :library_id, :parent_id, :name ], unique: true + + remove_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + remove_index :coplan_plans, :folder_id + remove_column :coplan_plans, :folder_id + + add_foreign_key :coplan_folders, :coplan_libraries, column: :library_id + add_foreign_key :coplan_plan_placements, :coplan_plans, column: :plan_id + add_foreign_key :coplan_plan_placements, :coplan_folders, column: :folder_id + add_foreign_key :coplan_plan_placements, :coplan_libraries, column: :library_id + add_foreign_key :coplan_plan_placements, :coplan_users, column: :placed_by_user_id + end + + def down + add_column :coplan_plans, :folder_id, :string, limit: 36 + add_index :coplan_plans, :folder_id + add_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + + # Restore each plan's author-library placement as its folder, then drop + # the placement machinery. + MigrationPlacement.reset_column_information + MigrationPlacement.find_each do |placement| + plan = MigrationPlan.find_by(id: placement.plan_id) + next unless plan && plan.created_by_user_id == placement.placed_by_user_id + + plan.update_columns(folder_id: placement.folder_id) + end + + remove_index :coplan_folders, column: [ :library_id, :parent_id, :name ], unique: true + add_index :coplan_folders, [ :parent_id, :name ], unique: true + remove_index :coplan_folders, :parent_id + remove_foreign_key :coplan_folders, :coplan_libraries, column: :library_id + remove_column :coplan_folders, :library_id + + drop_table :coplan_plan_placements + drop_table :coplan_libraries + end +end diff --git a/db/schema.rb b/db/schema.rb index 0c5193c6..4efc9ea5 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_07_17_205624) do +ActiveRecord::Schema[8.1].define(version: 2026_07_17_211002) 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" @@ -139,11 +139,23 @@ create_table "coplan_folders", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.datetime "created_at", null: false t.string "created_by_user_id", limit: 36 + t.string "library_id", limit: 36, null: false t.string "name", null: false t.string "parent_id", limit: 36 t.datetime "updated_at", null: false t.index ["created_by_user_id"], name: "index_coplan_folders_on_created_by_user_id" - t.index ["parent_id", "name"], name: "index_coplan_folders_on_parent_id_and_name", unique: true + t.index ["library_id", "parent_id", "name"], name: "index_coplan_folders_on_library_id_and_parent_id_and_name", unique: true + t.index ["library_id"], name: "index_coplan_folders_on_library_id" + t.index ["parent_id"], name: "index_coplan_folders_on_parent_id" + end + + create_table "coplan_libraries", 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", default: "Library", null: false + t.string "owner_id", limit: 36, null: false + t.string "owner_type", null: false + t.datetime "updated_at", null: false + t.index ["owner_type", "owner_id"], name: "index_coplan_libraries_on_owner_type_and_owner_id", unique: true end create_table "coplan_notifications", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| @@ -191,6 +203,19 @@ t.index ["plan_id"], name: "index_coplan_plan_events_on_plan_id" end + create_table "coplan_plan_placements", id: { type: :string, limit: 36 }, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.datetime "created_at", null: false + t.string "folder_id", limit: 36, null: false + t.string "library_id", limit: 36, null: false + t.string "placed_by_user_id", limit: 36 + t.string "plan_id", limit: 36, null: false + t.datetime "updated_at", null: false + t.index ["folder_id"], name: "index_coplan_plan_placements_on_folder_id" + t.index ["library_id"], name: "index_coplan_plan_placements_on_library_id" + t.index ["placed_by_user_id"], name: "fk_rails_ef17324b42" + t.index ["plan_id", "library_id"], name: "index_coplan_plan_placements_on_plan_id_and_library_id", unique: true + 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 @@ -249,7 +274,6 @@ t.string "created_by_user_id", limit: 36, null: false t.string "current_plan_version_id", limit: 36 t.integer "current_revision", default: 0, null: false - t.string "folder_id", limit: 36 t.json "metadata" t.string "plan_type_id", limit: 36 t.text "search_text", size: :medium @@ -262,7 +286,6 @@ t.index ["archived_at"], name: "index_coplan_plans_on_archived_at" 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 ["folder_id"], name: "index_coplan_plans_on_folder_id" t.index ["plan_type_id"], name: "index_coplan_plans_on_plan_type_id" t.index ["search_text"], name: "index_coplan_plans_on_search_text", type: :fulltext t.index ["updated_at"], name: "index_coplan_plans_on_updated_at" @@ -348,6 +371,7 @@ add_foreign_key "coplan_edit_sessions", "coplan_plan_versions", column: "plan_version_id" add_foreign_key "coplan_edit_sessions", "coplan_plans", column: "plan_id" add_foreign_key "coplan_folders", "coplan_folders", column: "parent_id" + add_foreign_key "coplan_folders", "coplan_libraries", column: "library_id" add_foreign_key "coplan_folders", "coplan_users", column: "created_by_user_id" add_foreign_key "coplan_notifications", "coplan_comment_threads", column: "comment_thread_id" add_foreign_key "coplan_notifications", "coplan_comments", column: "comment_id" @@ -356,12 +380,15 @@ 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_placements", "coplan_folders", column: "folder_id" + add_foreign_key "coplan_plan_placements", "coplan_libraries", column: "library_id" + add_foreign_key "coplan_plan_placements", "coplan_plans", column: "plan_id" + add_foreign_key "coplan_plan_placements", "coplan_users", column: "placed_by_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" - add_foreign_key "coplan_plans", "coplan_folders", column: "folder_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" diff --git a/engine/db/migrate/20260717010000_create_libraries_and_placements.rb b/engine/db/migrate/20260717010000_create_libraries_and_placements.rb index 8bee611a..e11aad7a 100644 --- a/engine/db/migrate/20260717010000_create_libraries_and_placements.rb +++ b/engine/db/migrate/20260717010000_create_libraries_and_placements.rb @@ -79,12 +79,15 @@ def up change_column_null :coplan_folders, :library_id, false # Folder names are unique per parent within a library now (two people - # can both have a root "Projects" folder). + # can both have a root "Projects" folder). The parent_id FK needs an + # index leading with parent_id, which the old composite provided — add + # a plain one before MySQL will let the composite go. + add_index :coplan_folders, :parent_id remove_index :coplan_folders, column: [ :parent_id, :name ], unique: true add_index :coplan_folders, [ :library_id, :parent_id, :name ], unique: true - remove_index :coplan_plans, :folder_id remove_foreign_key :coplan_plans, :coplan_folders, column: :folder_id + remove_index :coplan_plans, :folder_id remove_column :coplan_plans, :folder_id add_foreign_key :coplan_folders, :coplan_libraries, column: :library_id @@ -111,6 +114,7 @@ def down remove_index :coplan_folders, column: [ :library_id, :parent_id, :name ], unique: true add_index :coplan_folders, [ :parent_id, :name ], unique: true + remove_index :coplan_folders, :parent_id remove_foreign_key :coplan_folders, :coplan_libraries, column: :library_id remove_column :coplan_folders, :library_id From 4ebb925df7532e7c854ac7133fc6653ec83a59bb Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Jul 2026 16:48:55 -0500 Subject: [PATCH 04/19] Phase 2: personal libraries, placements, viewer-relative shelving Folders now live in libraries (polymorphic owner, auto-created per user), plans attach to folders via first-class placements (one shelf per library, many libraries per plan), and shelving is viewer-relative everywhere: the workspace, the move menu, and the API's folder_id / folder_path all speak in terms of the caller's own library. - Library.for(owner) materializes the per-owner library invariant - Plans::Place is the single write path for shelving (web + API), gated on the plan being listed for the actor - Drafts are unlisted, not locked: PlanPolicy#show? is open, discovery goes through PlanPolicy#listed? / Plan.visible_to only - Read-only library browsing at /libraries/:id with folder anchors; plan pages grew a "Shelved in" folder-jump strip - API: folders index accepts ?library_id= for read-only browsing, legacy status values now 422 with guidance instead of being ignored Co-Authored-By: Claude Fable 5 --- app/admin/folders.rb | 9 +- .../assets/stylesheets/coplan/application.css | 173 ++++++++++++++++++ .../coplan/api/v1/attachments_controller.rb | 6 +- .../coplan/api/v1/folders_controller.rb | 31 +++- .../coplan/api/v1/plans_controller.rb | 73 +++++--- .../controllers/coplan/folders_controller.rb | 9 +- .../coplan/libraries_controller.rb | 32 ++++ .../controllers/coplan/plans_controller.rb | 75 +++++--- engine/app/helpers/coplan/folders_helper.rb | 18 +- .../app/helpers/coplan/plan_events_helper.rb | 2 +- engine/app/helpers/coplan/plans_helper.rb | 2 +- engine/app/models/coplan/folder.rb | 53 +++--- engine/app/models/coplan/library.rb | 45 +++++ engine/app/models/coplan/plan.rb | 16 +- engine/app/models/coplan/plan_placement.rb | 45 +++++ engine/app/models/coplan/user.rb | 7 + engine/app/policies/coplan/folder_policy.rb | 12 +- engine/app/policies/coplan/library_policy.rb | 14 ++ engine/app/policies/coplan/plan_policy.rb | 18 +- engine/app/services/coplan/plans/create.rb | 2 +- engine/app/services/coplan/plans/place.rb | 86 +++++++++ .../coplan/agent_instructions/show.text.erb | 27 +-- .../views/coplan/libraries/_shelf.html.erb | 33 ++++ .../app/views/coplan/libraries/show.html.erb | 39 ++++ .../views/coplan/plans/_folder_node.html.erb | 2 +- .../views/coplan/plans/_plan_group.html.erb | 2 +- .../app/views/coplan/plans/_plan_row.html.erb | 56 +++--- .../app/views/coplan/plans/_shelves.html.erb | 32 ++++ .../app/views/coplan/plans/_sidebar.html.erb | 5 +- engine/app/views/coplan/plans/show.html.erb | 4 +- .../coplan/welcome/_default_landing.html.erb | 4 +- engine/config/routes.rb | 5 + spec/factories/folders.rb | 10 + spec/models/folder_spec.rb | 118 +++++++----- spec/models/plan_spec.rb | 8 +- .../analytics_instrumentation_spec.rb | 29 +-- spec/requests/api/v1/attachments_spec.rb | 12 +- spec/requests/api/v1/content_spec.rb | 2 +- spec/requests/api/v1/folders_spec.rb | 27 +-- spec/requests/api/v1/plans_spec.rb | 64 ++++--- spec/requests/api/v1/sessions_spec.rb | 4 +- spec/requests/plan_events_spec.rb | 34 ++-- spec/requests/plans_spec.rb | 141 +++++++------- spec/services/plans/create_spec.rb | 4 +- spec/system/folders_workspace_spec.rb | 12 +- 45 files changed, 1045 insertions(+), 357 deletions(-) create mode 100644 engine/app/controllers/coplan/libraries_controller.rb create mode 100644 engine/app/models/coplan/library.rb create mode 100644 engine/app/models/coplan/plan_placement.rb create mode 100644 engine/app/policies/coplan/library_policy.rb create mode 100644 engine/app/services/coplan/plans/place.rb create mode 100644 engine/app/views/coplan/libraries/_shelf.html.erb create mode 100644 engine/app/views/coplan/libraries/show.html.erb create mode 100644 engine/app/views/coplan/plans/_shelves.html.erb diff --git a/app/admin/folders.rb b/app/admin/folders.rb index 3a379971..01fde64f 100644 --- a/app/admin/folders.rb +++ b/app/admin/folders.rb @@ -1,11 +1,12 @@ ActiveAdmin.register CoPlan::Folder, as: "Folder" do - permit_params :name, :parent_id, :created_by_user_id + permit_params :name, :parent_id, :library_id, :created_by_user_id index do selectable_column id_column column :name column("Path") { |folder| folder.path } + column :library column :parent column :created_by_user column :created_at @@ -17,6 +18,7 @@ row :id row :name row("Path") { resource.path } + row :library row :parent row :created_by_user row :created_at @@ -35,7 +37,8 @@ table_for resource.plans.order(updated_at: :desc) do column :id column :title - column :status + column :visibility + column :archived_at column :updated_at end end @@ -44,6 +47,8 @@ form do |f| f.inputs do f.input :name + f.input :library_id, as: :select, + collection: CoPlan::Library.includes(:owner).map { |l| [ "#{l.owner.try(:name) || l.owner_id} — #{l.name}", l.id ] } f.input :parent, collection: CoPlan::Folder.order(:name).map { |folder| [folder.path, folder.id] } f.input :created_by_user, collection: CoPlan::User.order(:name).map { |u| [u.name, u.id] } end diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index e19ef79f..92dc8327 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -598,6 +598,7 @@ img, svg { .avatar--sm { width: 1.5rem; height: 1.5rem; font-size: 0.6rem; } .avatar--md { width: 2rem; height: 2rem; font-size: 0.7rem; } +.avatar--lg { width: 3rem; height: 3rem; font-size: 1rem; } .avatar--initials { background: var(--color-primary); @@ -3191,6 +3192,165 @@ body:not(:has(.comment-toolbar)) .web-push-banner { opacity: 0.65; } +/* Folder-jump strip on the plan page: which library shelves hold this + plan, plus the "add to your library" quick control. */ +.plan-shelves { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs) var(--space-sm); + margin-bottom: var(--space-md); + font-size: var(--text-sm); +} + +.plan-shelves__label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.plan-shelves__shelf { + display: inline-flex; + align-items: center; + padding: 2px 10px; + border: 1px solid var(--color-border); + border-radius: 9999px; + background: var(--color-surface); + color: var(--color-text); + text-decoration: none; + font-size: var(--text-xs); +} + +.plan-shelves__shelf:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.plan-shelves__none { + font-size: var(--text-xs); +} + +.plan-shelves__add-select { + font-size: var(--text-xs); + padding: 2px 6px; + border: 1px dashed var(--color-border); + border-radius: 9999px; + background: transparent; + color: var(--color-text-muted); + max-width: 200px; +} + +/* Library browsing page (read-only shelves) */ +.library { + max-width: 780px; + margin: 0 auto; +} + +.library__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-lg); +} + +.library__identity { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.library__title { + margin: 0; + font-size: 1.4rem; +} + +.library__subtitle { + margin: 2px 0 0; + font-size: var(--text-sm); +} + +.library__empty { + padding: var(--space-xl) 0; + text-align: center; +} + +.library-shelf { + margin-bottom: var(--space-sm); +} + +.library-shelf .library-shelf { + margin-left: var(--space-lg); + margin-bottom: 0; + margin-top: var(--space-xs); +} + +.library-shelf__summary { + display: flex; + align-items: center; + gap: var(--space-xs); + padding: var(--space-xs) var(--space-sm); + border-radius: var(--radius); + cursor: pointer; + font-weight: 600; + list-style: none; +} + +.library-shelf__summary::-webkit-details-marker { + display: none; +} + +.library-shelf__summary:hover { + background: var(--color-surface-hover); +} + +.library-shelf__count { + font-size: var(--text-xs); + font-weight: 400; +} + +.library-shelf__plans { + list-style: none; + margin: 0 0 var(--space-xs); + padding: 0 0 0 calc(var(--space-lg) + 2px); + display: flex; + flex-direction: column; + gap: 2px; +} + +.library-shelf__plan { + display: flex; + align-items: baseline; + gap: var(--space-sm); + padding: 2px 0; +} + +.library-shelf__plan-title { + text-decoration: none; + color: var(--color-text); +} + +.library-shelf__plan-title:hover { + color: var(--color-primary); + text-decoration: underline; +} + +.library-shelf__plan-meta { + font-size: var(--text-xs); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.library-shelf__empty { + margin: 0 0 var(--space-xs); + padding-left: calc(var(--space-lg) + 2px); +} + .plan-actions__hint { display: block; font-size: var(--text-xs); @@ -3338,6 +3498,19 @@ body:not(:has(.comment-toolbar)) .web-push-banner { color: var(--color-text-muted); } +.sidebar__heading-link { + float: right; + font-weight: 500; + text-transform: none; + letter-spacing: normal; + color: var(--color-text-muted); + text-decoration: none; +} + +.sidebar__heading-link:hover { + color: var(--color-primary); +} + .sidebar__list { list-style: none; margin: 0; diff --git a/engine/app/controllers/coplan/api/v1/attachments_controller.rb b/engine/app/controllers/coplan/api/v1/attachments_controller.rb index b68fb294..c90e52d6 100644 --- a/engine/app/controllers/coplan/api/v1/attachments_controller.rb +++ b/engine/app/controllers/coplan/api/v1/attachments_controller.rb @@ -7,9 +7,9 @@ class AttachmentsController < BaseController include CoPlan::AttachmentsHelper before_action :set_plan, only: [ :index, :create, :destroy ] - # authorize_plan_access! (PlanPolicy#show?) is the single visibility - # gate: drafts 404 for everyone but their author, so attachment - # enumeration / signed blob URL minting can't leak a private draft. + # authorize_plan_access! (PlanPolicy#show?): attachments follow the + # plan — drafts are unlisted, not locked, so anyone holding the + # plan's URL/id can read them too. before_action :authorize_plan_access!, only: [ :index, :create, :destroy ] before_action :authorize_plan_write!, only: [ :create, :destroy ] diff --git a/engine/app/controllers/coplan/api/v1/folders_controller.rb b/engine/app/controllers/coplan/api/v1/folders_controller.rb index 37fbf998..9f22184d 100644 --- a/engine/app/controllers/coplan/api/v1/folders_controller.rb +++ b/engine/app/controllers/coplan/api/v1/folders_controller.rb @@ -4,30 +4,44 @@ module V1 class FoldersController < BaseController before_action :set_folder, only: [ :update, :destroy ] + # Defaults to the caller's own library; pass library_id to browse + # another library's tree read-only (counts stay viewer-filtered). def index - folders = Folder.order(:name).to_a + library = if params[:library_id].present? + Library.find_by(id: params[:library_id]) + else + current_user.library + end + return render json: { error: "Library not found" }, status: :not_found unless library + + folders = library.folders.order(:name).to_a paths = Folder.paths_by_id(folders) # Visible-plan counts only — never leak the existence of other - # users' private draft plans through folder counts. - counts = Plan.visible_to(current_user) - .where.not(folder_id: nil) + # users' unlisted drafts through folder counts. + counts = PlanPlacement.where(library_id: library.id) + .visible_to(current_user) .group(:folder_id) .count render json: folders.map { |f| folder_json(f, paths: paths, counts: counts) } end + # Always creates in the caller's own library — you can't write to + # someone else's shelf. def create + library = current_user.library + parent = nil if params[:parent_id].present? - parent = Folder.find_by(id: params[:parent_id]) + parent = library.folders.find_by(id: params[:parent_id]) return render json: { error: "Unknown parent_id" }, status: :unprocessable_content unless parent end folder = Folder.create!( name: params[:name], parent: parent, + library: library, created_by_user: current_user ) render json: folder_json(folder), status: :created @@ -45,7 +59,7 @@ def update attrs[:name] = params[:name] if params.key?(:name) if params.key?(:parent_id) if params[:parent_id].present? - parent = Folder.find_by(id: params[:parent_id]) + parent = @folder.library.folders.find_by(id: params[:parent_id]) return render json: { error: "Unknown parent_id" }, status: :unprocessable_content unless parent attrs[:parent] = parent else @@ -81,14 +95,15 @@ def set_folder # `paths` and `counts` let index serialize the whole tree without # per-folder queries. `plans_count` is the folder's own visible - # plans (not including subfolders). + # placements (not including subfolders). def folder_json(folder, paths: nil, counts: nil) { id: folder.id, name: folder.name, + library_id: folder.library_id, parent_id: folder.parent_id, path: paths ? paths[folder.id] : folder.path, - plans_count: counts ? counts.fetch(folder.id, 0) : folder.plans.visible_to(current_user).count, + plans_count: counts ? counts.fetch(folder.id, 0) : folder.placements.visible_to(current_user).count, created_by: folder.created_by_user&.name, created_at: folder.created_at, updated_at: folder.updated_at diff --git a/engine/app/controllers/coplan/api/v1/plans_controller.rb b/engine/app/controllers/coplan/api/v1/plans_controller.rb index 92477014..7946ee64 100644 --- a/engine/app/controllers/coplan/api/v1/plans_controller.rb +++ b/engine/app/controllers/coplan/api/v1/plans_controller.rb @@ -7,11 +7,20 @@ class PlansController < BaseController def index plans = Plan - .includes(:plan_type, :created_by_user, folder: { parent: :parent }) + .includes(:plan_type, :created_by_user) .visible_to(current_user) .order(updated_at: :desc) plans = apply_index_filters(plans) - plans = plans.where(folder_id: params[:folder_id]) if params[:folder_id].present? + # folder_id filters by placement — any library's folder works + # (folder ids are global), and the plans themselves stay + # viewer-filtered above. + if params[:folder_id].present? + plans = plans.joins(:placements) + .where(coplan_plan_placements: { folder_id: params[:folder_id] }) + end + @viewer_placements = current_user.library.placements + .includes(folder: { parent: :parent }) + .index_by(&:plan_id) render json: plans.map { |p| plan_json(p) } end @@ -87,22 +96,27 @@ def update old_visibility = @plan.visibility old_archived = @plan.archived? old_tag_names = @plan.tag_names - old_folder_path = @plan.folder&.path - # Folder resolution (which may create folders via folder_path) and - # the plan update are one transaction: a request combining - # folder_path with an invalid attribute must not leave behind - # orphaned shared folders for a move that never happened. + # Folder resolution (which may create folders via folder_path in + # the caller's library), the placement move, and the plan update + # are one transaction: a request combining folder_path with an + # invalid attribute must not leave behind orphaned folders or a + # placement for an update that never happened. ActiveRecord::Base.transaction do if params.key?(:folder_id) || params.key?(:folder_path) folder = resolve_folder_params return if performed? # resolve_folder_params rendered an error - @plan.folder = folder + result = Plans::Place.call(plan: @plan, folder: folder, actor: current_user) + unless result.success? + render json: { error: result.error }, status: :unprocessable_content + raise ActiveRecord::Rollback + end end @plan.tag_names = params[:tags] if params.key?(:tags) @plan.update!(permitted) end + return if performed? # placement error rendered inside the transaction if @plan.saved_changes? Broadcaster.replace_to(@plan, target: "plan-header", partial: "coplan/plans/header", locals: { plan: @plan }) @@ -139,14 +153,6 @@ def update ) end - if @plan.saved_change_to_folder_id? - Plans::LogEvent.call( - plan: @plan, actor: current_user, event_type: "moved_to_folder", - before: old_folder_path, after: @plan.folder&.path, - actor_type: api_author_type, actor_id: api_actor_id - ) - end - if params.key?(:tags) new_tag_names = @plan.tag_names (new_tag_names - old_tag_names).each do |added| @@ -246,6 +252,10 @@ def apply_index_filters(plans) def visibility_params_for_update updates = {} if params.key?(:status) + unless Plan::LEGACY_STATUSES.include?(params[:status].to_s) + render json: { error: "status is a legacy field; use visibility/archived. Legacy values: #{Plan::LEGACY_STATUSES.join(", ")}" }, status: :unprocessable_content + return {} + end updates.merge!(Plan.attributes_for_legacy_status(params[:status])) end if params.key?(:visibility) @@ -268,22 +278,39 @@ def visibility_params_for_update end # Resolves `folder_id` / `folder_path` update params to a Folder (or - # nil to clear). `folder_path` finds-or-creates the hierarchy, which - # is what lets an AI librarian agent organize plans into folders that - # don't exist yet. Renders an error and returns early on bad input. + # nil to unfile). `folder_path` finds-or-creates the hierarchy in the + # caller's own library, which is what lets an agent organize a + # library into folders that don't exist yet. Renders an error and + # returns early on bad input. def resolve_folder_params if params[:folder_id].present? folder = Folder.find_by(id: params[:folder_id]) render json: { error: "Unknown folder_id" }, status: :unprocessable_content unless folder folder elsif params[:folder_path].present? - Folder.find_or_create_by_path!(params[:folder_path], created_by_user: current_user) + Folder.find_or_create_by_path!( + params[:folder_path], + library: current_user.library, + created_by_user: current_user + ) + else + nil # blank folder_id / folder_path unfiles the plan + end + end + + # folder_id/folder_path are viewer-relative: where *the caller* + # shelved this plan in their own library. One query per call — index + # batches placements up front via @viewer_placements. + def viewer_placement_for(plan) + if defined?(@viewer_placements) && @viewer_placements + @viewer_placements[plan.id] else - nil # blank folder_id / folder_path clears the folder + current_user.library.placements.find_by(plan_id: plan.id) end end def plan_json(plan) + placement = viewer_placement_for(plan) { id: plan.id, title: plan.title, @@ -294,8 +321,8 @@ def plan_json(plan) status: plan.legacy_status, current_revision: plan.current_revision, tags: plan.tag_names, - folder_id: plan.folder_id, - folder_path: plan.folder&.path, + folder_id: placement&.folder_id, + folder_path: placement&.folder&.path, 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/folders_controller.rb b/engine/app/controllers/coplan/folders_controller.rb index e425999f..f2cbbc0f 100644 --- a/engine/app/controllers/coplan/folders_controller.rb +++ b/engine/app/controllers/coplan/folders_controller.rb @@ -1,11 +1,15 @@ module CoPlan # Web-side folder creation for the plans sidebar ("New folder" form). - # Folder rename/delete happen through the API or admin UI for now. + # Folders always land in the current user's own library — there's no + # picking someone else's. Rename/delete happen through the API or admin + # UI for now. class FoldersController < ApplicationController def create + library = current_user.library + parent = nil if params.dig(:folder, :parent_id).present? - parent = Folder.find_by(id: params.dig(:folder, :parent_id)) + parent = library.folders.find_by(id: params.dig(:folder, :parent_id)) if parent.nil? # Don't silently create a root folder when the chosen parent has # since been deleted (matches the API's unknown-parent handling). @@ -18,6 +22,7 @@ def create folder = Folder.new( name: params.dig(:folder, :name), parent: parent, + library: library, created_by_user: current_user ) diff --git a/engine/app/controllers/coplan/libraries_controller.rb b/engine/app/controllers/coplan/libraries_controller.rb new file mode 100644 index 00000000..94173f86 --- /dev/null +++ b/engine/app/controllers/coplan/libraries_controller.rb @@ -0,0 +1,32 @@ +module CoPlan + # Read-only library browsing — the addressable page behind folder-jump + # discovery ("this plan was useful; what does its author keep next to + # it?"). Anyone signed in can look; what they see inside is filtered + # per-plan by Plan.visible_to, and archived plans stay hidden here like + # everywhere else. Editing a library happens in its owner's workspace, + # never on this page. + class LibrariesController < ApplicationController + def mine + redirect_to library_path(current_user.library) + end + + def show + @library = Library.find(params[:id]) + authorize!(@library, :show?) + + @owner = @library.owner + @folders = @library.folders.order(:name).to_a + @folder_children = @folders.group_by(&:parent_id) + @root_folders = @folder_children[nil] || [] + + placements = @library.placements + .visible_to(current_user) + .where(plan: Plan.active) + .includes(plan: [ :created_by_user, :plan_type, :tags ]) + .sort_by { |p| p.plan.updated_at } + .reverse + @placements_by_folder = placements.group_by(&:folder_id) + @plan_count = placements.size + end + end +end diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb index 8fcc402d..6673cc47 100644 --- a/engine/app/controllers/coplan/plans_controller.rb +++ b/engine/app/controllers/coplan/plans_controller.rb @@ -8,7 +8,7 @@ class PlansController < ApplicationController DEFAULT_SCOPE = "mine".freeze # Display order for the main-pane groups: published work first, then the - # viewer's private drafts. Archived plans are opt-in (?filter=archived) + # viewer's unlisted drafts. Archived plans are opt-in (?filter=archived) # and never render as a default group. GROUP_ORDER = %w[published draft].freeze FILTERS = %w[published draft archived].freeze @@ -37,13 +37,17 @@ def index end end - plans = scoped_plans_base.includes(:plan_type, :tags, :created_by_user, :current_plan_version, :folder) + plans = scoped_plans_base.includes(:plan_type, :tags, :created_by_user, :current_plan_version) plans = plans.where(plan_type_id: params[:plan_type]) if params[:plan_type].present? plans = plans.with_tag(params[:tag]) if params[:tag].present? # A folder filter includes its subfolders — clicking "Team EBT" shows - # everything under it. - plans = plans.where(folder_id: folder_subtree_ids(@folder)) if @folder + # everything under it. Folder ids come from the viewer's own library + # tree, so the placement join is already library-scoped. + if @folder + plans = plans.joins(:placements) + .where(coplan_plan_placements: { folder_id: folder_subtree_ids(@folder) }) + end # Stale frame fetch for a since-deleted folder: render an empty page. plans = plans.none if params[:folder].present? && @folder.nil? @@ -99,13 +103,13 @@ def index end # Web endpoint behind the sidebar drag-and-drop and the row-menu - # "Move to folder" fallback. Author-only (PlanPolicy#update?). + # "Move to folder" fallback. Shelves the plan in the current user's own + # library — any visible plan can be shelved, not just your own + # (Plans::Place enforces both sides). def move_to_folder - authorize!(@plan, :update?) - folder = nil if params[:folder_id].present? - folder = Folder.find_by(id: params[:folder_id]) + folder = current_user.library.folders.find_by(id: params[:folder_id]) unless folder respond_to do |format| format.json { render json: { error: "Unknown folder" }, status: :unprocessable_content } @@ -115,27 +119,37 @@ def move_to_folder end end - if @plan.folder_id != folder&.id - old_path = @plan.folder&.path - @plan.update!(folder: folder) - Plans::LogEvent.call( - plan: @plan, - actor: current_user, - event_type: "moved_to_folder", - before: old_path, - after: folder&.path - ) + result = Plans::Place.call(plan: @plan, folder: folder, actor: current_user) + unless result.success? + respond_to do |format| + format.json { render json: { error: result.error }, status: :unprocessable_content } + format.html { redirect_back fallback_location: plans_path, alert: result.error } + end + return end notice = folder ? "Moved “#{@plan.title}” to #{folder.path}." : "Removed “#{@plan.title}” from its folder." respond_to do |format| - format.json { render json: { folder_id: @plan.folder_id, folder_path: @plan.folder&.path, message: notice } } + format.json do + render json: { + folder_id: result.placement&.folder_id, + folder_path: result.placement&.folder&.path, + message: notice + } + end format.html { redirect_back fallback_location: plans_path, notice: notice } end end def show authorize!(@plan, :show?) + # Folder-jump discovery: every shelf this plan sits on. The plan + # itself is already authorized above, and placements inherit the + # plan's visibility — a shelf never reveals more than the plan does. + @shelf_placements = @plan.placements + .includes(:library, folder: { parent: :parent }) + .sort_by { |p| p.created_at } + @my_folders = current_user.library.folders.order(:name).to_a @threads = @plan.comment_threads.with_kept_comments.includes(:comments, :created_by_user).order(:created_at) @references = @plan.references.order(reference_type: :asc, created_at: :desc) @attachments = @plan.attachments_attachments.includes(:blob).order(created_at: :desc) @@ -384,17 +398,23 @@ def unread_counts_for(plans) # always match what clicking through shows. def scoped_plans_base if @scope == "mine" - Plan.where(created_by_user: current_user) + # The workspace is your plans *and* your placements — a plan you + # shelved from someone else belongs on your operating surface too. + base = Plan.visible_to(current_user) + base.where(created_by_user_id: current_user.id) + .or(base.where(id: current_user.library.placements.select(:plan_id))) else - # Brainstorm plans are private drafts — never show other users'. + # Draft plans are private — never show other users'. Plan.visible_to(current_user) end end - # One query for the whole folder tree; everything else (children map, - # subtree ids, expanded state, aggregate counts) is derived in memory. + # One query for the viewer's whole library tree; everything else + # (children map, subtree ids, expanded state, aggregate counts) is + # derived in memory. def load_folder_tree - @folders = Folder.order(:name).to_a + @library = current_user.library + @folders = @library.folders.order(:name).to_a @folders_by_id = @folders.index_by(&:id) @folder_children = @folders.group_by(&:parent_id) @root_folders = @folder_children[nil] || [] @@ -416,14 +436,15 @@ def folder_subtree_ids(folder) # Sidebar data: the folder tree with per-folder plan counts, and the # most-used tags. Counts and tag usage use the same base relation as # the main pane (scoped_plans_base) so they match what clicking shows — - # which also means other users' private draft plans never leak + # which also means other users’ unlisted drafts never surface # through folder counts or tag lists (Plan.visible_to). def load_workspace_sidebar # Archived plans are hidden from default lists, so they're excluded # from folder/tag counts too — counts always match what clicking shows. direct_counts = scoped_plans_base.active - .where.not(folder_id: nil) - .group(:folder_id) + .joins(:placements) + .where(coplan_plan_placements: { library_id: @library.id }) + .group("coplan_plan_placements.folder_id") .count # Displayed counts include subfolders, matching what clicking the # folder shows. diff --git a/engine/app/helpers/coplan/folders_helper.rb b/engine/app/helpers/coplan/folders_helper.rb index b58be898..bcd4b9d2 100644 --- a/engine/app/helpers/coplan/folders_helper.rb +++ b/engine/app/helpers/coplan/folders_helper.rb @@ -1,9 +1,10 @@ module CoPlan module FoldersHelper - # [[path, id, depth], ...] for every folder, sorted by path — used by - # the "New folder" parent select and each row's "Move to folder" menu. - # Memoized per request and seeded from the controller-loaded tree - # (@folders) when available, so rendering many rows doesn't re-query. + # [[path, id, depth], ...] for every folder in the viewer's library, + # sorted by path — used by the "New folder" parent select and each + # row's "Move to folder" menu. Memoized per request and seeded from + # the controller-loaded tree (@folders) when available, so rendering + # many rows doesn't re-query. def folder_select_options @_folder_select_options ||= folder_paths_by_id .map { |id, path| [ path, id, path.count("/") + 1 ] } @@ -15,10 +16,15 @@ def folder_path_for(folder) folder_paths_by_id[folder.id] || folder.path end - private + # Where the current user shelved this plan in their own library (nil + # when unfiled). One placements query per request, not per row. + def viewer_folder_id(plan) + @_viewer_folder_ids ||= current_user.library.placements.pluck(:plan_id, :folder_id).to_h + @_viewer_folder_ids[plan.id] + end def folder_paths_by_id - @_folder_paths_by_id ||= CoPlan::Folder.paths_by_id(@folders || CoPlan::Folder.order(:name).to_a) + @_folder_paths_by_id ||= CoPlan::Folder.paths_by_id(@folders || current_user.library.folders.order(:name).to_a) end end end diff --git a/engine/app/helpers/coplan/plan_events_helper.rb b/engine/app/helpers/coplan/plan_events_helper.rb index f93d1345..3c03212d 100644 --- a/engine/app/helpers/coplan/plan_events_helper.rb +++ b/engine/app/helpers/coplan/plan_events_helper.rb @@ -8,7 +8,7 @@ module PlanEventsHelper def render_event_summary(event) case event.event_type when "published" - "Published — visible to everyone" + "Published — listed for everyone" when "archived" "Archived" when "unarchived" diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb index b667fa15..6a14b0cc 100644 --- a/engine/app/helpers/coplan/plans_helper.rb +++ b/engine/app/helpers/coplan/plans_helper.rb @@ -7,7 +7,7 @@ module PlansHelper # broadcast partials (derives from the plan alone, no current_user). def plan_state_badge(plan) badges = [] - badges << content_tag(:span, "Draft", class: "badge badge--draft", title: "Private draft — only the author can see it") if plan.draft? + badges << content_tag(:span, "Draft", class: "badge badge--draft", title: "Unlisted draft — anyone with the link can read it, but it stays out of lists and search") if plan.draft? badges << content_tag(:span, "Archived", class: "badge badge--archived", title: "Hidden from lists unless filtered for") if plan.archived? return "".html_safe if badges.empty? diff --git a/engine/app/models/coplan/folder.rb b/engine/app/models/coplan/folder.rb index 68fad339..45a64aa5 100644 --- a/engine/app/models/coplan/folder.rb +++ b/engine/app/models/coplan/folder.rb @@ -1,12 +1,13 @@ module CoPlan - # A shared, org-wide location for plans — Dropbox Paper / Google Docs style. - # Folders form a small hierarchy (max MAX_DEPTH levels) and each plan lives - # in at most one folder (Plan#folder_id). Tags remain the cross-cutting - # labels; folders answer "where does this plan live?". + # A shelf in a library. Folders form a small hierarchy (max MAX_DEPTH + # levels) inside exactly one library, and hold plans via placements — + # a plan sits in at most one folder per library, but can be shelved in + # many libraries at once. Tags remain the cross-cutting labels; folders + # answer "where did this library's owner file this plan?". # - # Anyone signed in can create folders and move their own plans into or out - # of any folder. Rename/delete is limited to the folder's creator or an - # admin (see FolderPolicy). + # Folders belong to their library, never directly to a user — write + # access is the library's call (Library#writable_by?), which is what + # lets a future team library reuse all of this unchanged. class Folder < ApplicationRecord MAX_DEPTH = 3 @@ -14,18 +15,20 @@ class Folder < ApplicationRecord # (e.g. "Team EBT/Q3"), so it can't appear in a folder name. NAME_FORMAT = %r{\A[^/]+\z} + belongs_to :library, class_name: "CoPlan::Library", inverse_of: :folders belongs_to :parent, class_name: "CoPlan::Folder", optional: true, inverse_of: :children has_many :children, class_name: "CoPlan::Folder", foreign_key: :parent_id, inverse_of: :parent, dependent: nil belongs_to :created_by_user, class_name: "CoPlan::User", optional: true - has_many :plans, class_name: "CoPlan::Plan", foreign_key: :folder_id, - inverse_of: :folder, dependent: nil + has_many :placements, class_name: "CoPlan::PlanPlacement", inverse_of: :folder, dependent: nil + has_many :plans, class_name: "CoPlan::Plan", through: :placements validates :name, presence: true, - uniqueness: { scope: :parent_id, case_sensitive: false }, + uniqueness: { scope: [ :library_id, :parent_id ], case_sensitive: false }, format: { with: NAME_FORMAT, message: "cannot contain \"/\"" }, length: { maximum: 100 } validate :parent_cannot_create_cycle + validate :parent_must_share_library validate :depth_within_limit before_destroy :ensure_empty @@ -60,12 +63,13 @@ def path end # Finds or creates the folder hierarchy for a "/"-separated path like - # "Team EBT/Q3". This is what lets an AI librarian agent organize plans - # without pre-creating folders. Raises ActiveRecord::RecordInvalid when - # the path is too deep or a segment is invalid. Returns nil for a blank - # path. Lookup is case-insensitive (matching the uniqueness validation); - # creation preserves the given casing. - def self.find_or_create_by_path!(path, created_by_user: nil) + # "Team EBT/Q3" inside one library. This is what lets an agent organize + # a library without pre-creating folders. Raises + # ActiveRecord::RecordInvalid when the path is too deep or a segment is + # invalid. Returns nil for a blank path. Lookup is case-insensitive + # (matching the uniqueness validation); creation preserves the given + # casing. + def self.find_or_create_by_path!(path, library:, created_by_user: nil) segments = path.to_s.split("/").map(&:strip).reject(&:blank?) return nil if segments.empty? @@ -73,8 +77,8 @@ def self.find_or_create_by_path!(path, created_by_user: nil) # MAX_DEPTH) doesn't leave half-created hierarchy behind. transaction do segments.reduce(nil) do |parent, name| - where(parent_id: parent&.id).where("LOWER(name) = ?", name.downcase).first || - create!(name: name, parent: parent, created_by_user: created_by_user) + library.folders.where(parent_id: parent&.id).where("LOWER(name) = ?", name.downcase).first || + create!(name: name, parent: parent, library: library, created_by_user: created_by_user) end end end @@ -96,11 +100,11 @@ def self.paths_by_id(folders = order(:name).to_a) end def self.ransackable_attributes(_auth_object = nil) - %w[id name parent_id created_by_user_id created_at updated_at] + %w[id name library_id parent_id created_by_user_id created_at updated_at] end def self.ransackable_associations(_auth_object = nil) - %w[parent children plans created_by_user] + %w[library parent children placements plans created_by_user] end private @@ -125,6 +129,13 @@ def parent_cannot_create_cycle end end + def parent_must_share_library + return if parent.nil? + return if parent.library_id == library_id + + errors.add(:parent, "must belong to the same library") + end + def depth_within_limit return if parent.nil? # Skip when a cycle error is already present — depth would loop. @@ -137,7 +148,7 @@ def depth_within_limit end def ensure_empty - if plans.exists? + if placements.exists? errors.add(:base, "Cannot delete a folder that contains plans — move the plans out first") throw :abort end diff --git a/engine/app/models/coplan/library.rb b/engine/app/models/coplan/library.rb new file mode 100644 index 00000000..5a4d9db9 --- /dev/null +++ b/engine/app/models/coplan/library.rb @@ -0,0 +1,45 @@ +module CoPlan + # A library is the owner-shaped container for a folder tree and the + # placements filed into it. Every user always has one — it's an invariant, + # not a feature: `Library.for(owner)` materializes it on first touch, so + # "user without a library" is not a state that exists anywhere else in + # the app. + # + # Ownership is polymorphic on purpose. Users are the only owner type + # today, but a team library later is a new owner type on this same model, + # not a new system — so no query or policy outside this class should + # assume owner == user. Write policy lives here (`writable_by?`), which + # is exactly where "who may file things into this library" belongs. + class Library < ApplicationRecord + belongs_to :owner, polymorphic: true + has_many :folders, class_name: "CoPlan::Folder", dependent: :destroy + has_many :placements, class_name: "CoPlan::PlanPlacement", dependent: :destroy + + validates :name, presence: true, length: { maximum: 100 } + validates :owner_id, uniqueness: { scope: :owner_type } + + def self.for(owner) + find_or_create_by!(owner: owner) + rescue ActiveRecord::RecordNotUnique + # Two requests materialized the same owner's library at once — the + # unique [owner_type, owner_id] index makes the loser retry the read. + find_by!(owner: owner) + end + + # Only the owner writes to a personal library. A future team library + # answers this with membership instead — callers just ask the library. + def writable_by?(user) + return false unless user + + owner_type == "CoPlan::User" && owner_id == user.id + end + + def self.ransackable_attributes(_auth_object = nil) + %w[id owner_type owner_id name created_at updated_at] + end + + def self.ransackable_associations(_auth_object = nil) + %w[owner folders placements] + end + end +end diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb index 5f95a090..6343f536 100644 --- a/engine/app/models/coplan/plan.rb +++ b/engine/app/models/coplan/plan.rb @@ -22,7 +22,8 @@ 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 - belongs_to :folder, optional: true, inverse_of: :plans + has_many :placements, class_name: "CoPlan::PlanPlacement", inverse_of: :plan, dependent: :destroy + has_many :libraries, through: :placements has_many :plan_versions, -> { order(revision: :asc) }, dependent: :destroy has_many :plan_events, dependent: :destroy has_many :plan_collaborators, dependent: :destroy @@ -46,11 +47,12 @@ class Plan < ApplicationRecord scope :with_tag, ->(name) { joins(:tags).where(coplan_tags: { name: name }) } - # Plans `user` is allowed to see: everything published plus the user's - # own drafts. Drafts are private — any list, count, feed, search result, - # or folder content shown to a user must go through this scope (or - # PlanPolicy#show?, which mirrors it) so private draft existence never - # leaks. This is THE visibility predicate: never test `visibility` + # Plans `user` discovers on their own: everything published plus the + # user's own drafts. Drafts are unlisted, not locked — direct URLs work + # for anyone (PlanPolicy#show?), but every list, count, feed, search + # result, or shelf shown to a user must go through this scope (or + # PlanPolicy#listed?, which mirrors it) so drafts never surface + # uninvited. This is THE discovery predicate: never test `visibility` # inline elsewhere. scope :visible_to, ->(user) { where(visibility: "published").or(where(created_by_user_id: user.id)) @@ -124,7 +126,7 @@ def self.build_search_text(plan) end def self.ransackable_attributes(auth_object = nil) - %w[id title visibility archived_at plan_type_id folder_id created_by_user_id current_plan_version_id current_revision created_at updated_at] + %w[id title visibility archived_at 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) diff --git a/engine/app/models/coplan/plan_placement.rb b/engine/app/models/coplan/plan_placement.rb new file mode 100644 index 00000000..aa8a29d7 --- /dev/null +++ b/engine/app/models/coplan/plan_placement.rb @@ -0,0 +1,45 @@ +module CoPlan + # A placement shelves a plan in a library folder. It is the library + # owner's organization of the plan, not a property of the plan itself: + # the same plan can sit in many libraries at once, and shelving someone + # else's published plan is a first-class action — a placement, never a + # copy or a move. + # + # Placements carry their own metadata (who placed it, when) — they're a + # first-class attachment, not a bare join row. Visibility is inherited + # from the plan: a placement is visible iff the underlying plan is + # visible to the viewer (see .visible_to), whoever's library it sits in. + class PlanPlacement < ApplicationRecord + belongs_to :plan, class_name: "CoPlan::Plan", inverse_of: :placements + belongs_to :folder, class_name: "CoPlan::Folder", inverse_of: :placements + belongs_to :library, class_name: "CoPlan::Library", inverse_of: :placements + belongs_to :placed_by_user, class_name: "CoPlan::User", optional: true + + before_validation :inherit_library_from_folder + + # One spot per library: a plan sits in exactly one folder of a given + # library (re-shelving moves it, it doesn't duplicate it). + validates :plan_id, uniqueness: { scope: :library_id } + validate :folder_must_belong_to_library + + # THE visibility rule for placements: defer entirely to the plan's + # predicate. Every surface that lists placements (library browsing, + # folder-jump, workspace) goes through this scope. + scope :visible_to, ->(user) { where(plan: Plan.visible_to(user)) } + + private + + # library_id is denormalized from the folder so "one spot per library" + # is enforceable with a unique index; callers only pick a folder. + def inherit_library_from_folder + self.library_id ||= folder&.library_id + end + + def folder_must_belong_to_library + return if folder.nil? || library_id.nil? + return if folder.library_id == library_id + + errors.add(:folder, "must belong to the placement's library") + end + end +end diff --git a/engine/app/models/coplan/user.rb b/engine/app/models/coplan/user.rb index 851a0974..140f5785 100644 --- a/engine/app/models/coplan/user.rb +++ b/engine/app/models/coplan/user.rb @@ -18,6 +18,13 @@ class User < ApplicationRecord after_initialize { self.metadata ||= {} } after_initialize { self.notification_preferences ||= {} } + # Every user always has a library — it's an invariant, materialized on + # first touch. Never read the association directly; this accessor is + # what guarantees "user without a library" isn't a state that exists. + def library + @library ||= Library.for(self) + end + def self.ransackable_attributes(auth_object = nil) %w[id external_id name username email admin avatar_url title team created_at updated_at] end diff --git a/engine/app/policies/coplan/folder_policy.rb b/engine/app/policies/coplan/folder_policy.rb index a3bfdd4b..641ac6d0 100644 --- a/engine/app/policies/coplan/folder_policy.rb +++ b/engine/app/policies/coplan/folder_policy.rb @@ -1,18 +1,20 @@ module CoPlan - # Folders are shared/org-wide: any signed-in user can see them and create - # new ones. Rename/re-parent/delete is limited to the folder's creator or - # an admin so shared structure doesn't get reshuffled by accident. + # Folders live inside a library, so every write defers to the library's + # own policy (Library#writable_by?) — only the owner reshapes a personal + # library's tree, with an admin override for cleanup. Reading is open: + # anyone may browse a library's folder structure; the plans inside are + # filtered per-viewer by Plan.visible_to. class FolderPolicy < ApplicationPolicy def index? true end def create? - true + record.library&.writable_by?(user) || false end def update? - record.created_by_user_id == user.id || admin? + create? || admin? end def destroy? diff --git a/engine/app/policies/coplan/library_policy.rb b/engine/app/policies/coplan/library_policy.rb new file mode 100644 index 00000000..0bfd9f7a --- /dev/null +++ b/engine/app/policies/coplan/library_policy.rb @@ -0,0 +1,14 @@ +module CoPlan + # Libraries are browsable by anyone signed in — what a viewer actually + # sees inside one is decided per-plan by Plan.visible_to, never here. + # Writing (creating folders, shelving plans) is the library's call. + class LibraryPolicy < ApplicationPolicy + def show? + true + end + + def update? + record.writable_by?(user) + end + end +end diff --git a/engine/app/policies/coplan/plan_policy.rb b/engine/app/policies/coplan/plan_policy.rb index 47db702a..e75e4ced 100644 --- a/engine/app/policies/coplan/plan_policy.rb +++ b/engine/app/policies/coplan/plan_policy.rb @@ -1,11 +1,19 @@ module CoPlan class PlanPolicy < ApplicationPolicy - # THE visibility predicate. Every surface that shows a plan — lists, - # feeds, search, folder contents, library placements, profile pages — - # must answer through here (or the mirrored Plan.visible_to scope for - # set-based queries). Never test `visibility`/`archived_at` inline in - # controllers or views. + # Drafts are unlisted, not locked: anyone who has the URL may read the + # plan (share a link to get early feedback). What "draft" withholds is + # discovery — lists, feeds, search, counts, shelves — which is decided + # by `listed?` / Plan.visible_to, never here. def show? + true + end + + # THE discovery predicate, mirroring the Plan.visible_to scope. Every + # surface that *surfaces* a plan — lists, feeds, search, folder + # contents, library placements, profile pages — must answer through + # here (or the scope for set-based queries). Never test + # `visibility`/`archived_at` inline in controllers or views. + def listed? record.published? || record.created_by_user_id == user&.id end diff --git a/engine/app/services/coplan/plans/create.rb b/engine/app/services/coplan/plans/create.rb index 0dc54f4c..55a89613 100644 --- a/engine/app/services/coplan/plans/create.rb +++ b/engine/app/services/coplan/plans/create.rb @@ -2,7 +2,7 @@ module CoPlan module Plans class Create # Plans are shared by default: they're created published unless the - # caller explicitly asks for a private draft. + # caller explicitly asks for an unlisted draft. def self.call(title:, content:, user:, plan_type_id: nil, visibility: "published") new(title:, content:, user:, plan_type_id:, visibility:).call end diff --git a/engine/app/services/coplan/plans/place.rb b/engine/app/services/coplan/plans/place.rb new file mode 100644 index 00000000..0e9afadc --- /dev/null +++ b/engine/app/services/coplan/plans/place.rb @@ -0,0 +1,86 @@ +module CoPlan + module Plans + # Shelves a plan in (or removes it from) one folder of a library — + # the single write path for placements, shared by the web workspace + # and the API so upsert semantics and the audit trail never diverge. + # + # A plan sits in at most one folder per library: placing it again + # moves the placement; a nil folder unfiles it. Placing someone + # else's plan is first-class — the plan itself is untouched, only + # the actor's shelf changes. + class Place + Result = Struct.new(:placement, :error, keyword_init: true) do + def success? = error.nil? + end + + def self.call(plan:, folder:, actor:, library: nil) + new(plan:, folder:, actor:, library:).call + end + + def initialize(plan:, folder:, actor:, library: nil) + @plan = plan + @folder = folder + @actor = actor + @library = library || folder&.library || actor.library + end + + def call + unless @library.writable_by?(@actor) + return Result.new(error: "You can only organize your own library") + end + if @folder && @folder.library_id != @library.id + return Result.new(error: "Folder belongs to a different library") + end + # Shelving requires the plan to be listable for you — an unlisted + # draft someone linked you can be read, but filing it onto a + # browsable shelf would surface what its author hasn't published. + unless PlanPolicy.new(@actor, @plan).listed? + return Result.new(error: "Only published plans (or your own drafts) can be shelved") + end + + placement = @library.placements.find_by(plan_id: @plan.id) + old_path = placement&.folder&.path + + if @folder.nil? + return Result.new(placement: nil) if placement.nil? + + placement.destroy! + log_move(old_path, nil) + return Result.new(placement: nil) + end + + if placement + return Result.new(placement:) if placement.folder_id == @folder.id + + placement.update!(folder: @folder, placed_by_user: @actor) + else + placement = @library.placements.create!( + plan: @plan, folder: @folder, placed_by_user: @actor + ) + end + log_move(old_path, @folder.path) + Result.new(placement:) + rescue ActiveRecord::RecordInvalid => e + Result.new(error: e.record.errors.full_messages.join(", ")) + end + + private + + # The audit trail lives on the plan, but only for the author's own + # library — someone else curating their shelf isn't an event in the + # plan's history. + def log_move(old_path, new_path) + return unless @plan.created_by_user_id == @actor.id + return if old_path == new_path + + LogEvent.call( + plan: @plan, + actor: @actor, + event_type: "moved_to_folder", + before: old_path, + after: new_path + ) + end + 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 c2005309..583afa4b 100644 --- a/engine/app/views/coplan/agent_instructions/show.text.erb +++ b/engine/app/views/coplan/agent_instructions/show.text.erb @@ -116,20 +116,20 @@ Tags categorize plans for discovery. Use them to help people find related plans. - 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. -### Folders +### Libraries & Folders -Folders are shared, hierarchical **locations** for plans (max 3 levels deep). Each plan lives in at most one folder — think team folders with project subfolders. Tags remain the cross-cutting labels; use folders for "where does this plan live?" and tags for "what is it about?". +Every user has a personal **library**: a private-to-write, public-to-browse folder tree (max 3 levels deep). Folders are shelves in *your* library — filing a plan is a **placement** on your shelf, not a property of the plan, so the same plan can sit in many people's libraries at once. You can shelve anyone's published plan, not just your own. Tags remain the cross-cutting labels; use folders for "where do I keep this?" and tags for "what is it about?". -**List folders:** +**List folders** (defaults to your library; pass `?library_id=` to browse someone else's read-only): ```bash <%= @curl %> \ "<%= @base %>/api/v1/folders" | jq . ``` -Each folder includes `id`, `name`, `parent_id`, `path` (e.g. `"Team EBT/Q3"`), and `plans_count`. +Each folder includes `id`, `name`, `library_id`, `parent_id`, `path` (e.g. `"Team EBT/Q3"`), and `plans_count` (only plans you can see). -**Create a folder:** +**Create a folder** (always in your own library): ```bash <%= @curl %> -X POST \ @@ -138,9 +138,9 @@ Each folder includes `id`, `name`, `parent_id`, `path` (e.g. `"Team EBT/Q3"`), a "<%= @base %>/api/v1/folders" | jq . ``` -`parent_id` is optional — omit it for a top-level folder. Rename with `PATCH /api/v1/folders/:id` (`{"name": "..."}`); delete with `DELETE /api/v1/folders/:id` (only empty folders can be deleted, and only by their creator or an admin). +`parent_id` is optional — omit it for a top-level folder. Rename with `PATCH /api/v1/folders/:id` (`{"name": "..."}`); delete with `DELETE /api/v1/folders/:id` (only empty folders, only in your own library). -**Move a plan into a folder** (plan author only): +**Shelve a plan in a folder:** ```bash <%= @curl %> -X PATCH \ @@ -149,14 +149,15 @@ Each folder includes `id`, `name`, `parent_id`, `path` (e.g. `"Team EBT/Q3"`), a "<%= @base %>/api/v1/plans/$PLAN_ID" | jq . ``` -- `folder_path` finds or creates the whole hierarchy — the easiest way to organize plans. -- Alternatively pass `folder_id` with an existing folder's ID. -- Pass an empty `folder_id` (`{"folder_id": ""}`) to move a plan out of its folder. +- `folder_path` finds or creates the whole hierarchy in your library — the easiest way to organize. +- Alternatively pass `folder_id` with an existing folder's ID from your library. +- Pass an empty `folder_id` (`{"folder_id": ""}`) to take a plan off your shelf. +- `folder_id`/`folder_path` in plan responses are **yours**: where *you* shelved that plan, `null` if you haven't. **Guidelines:** - Check `GET /api/v1/folders` before creating new folders — reuse the existing structure. - Folder names read like places (`Team EBT`, `Infra`, `Q3 Launch`), not labels. -- You can offer to organize a user's plans: list their plans, propose a folder structure, and move plans with `folder_path` once they agree. +- You can offer to organize a user's library: list their plans, propose a folder structure, and shelve plans with `folder_path` once they agree. ### Visibility & Archiving @@ -164,12 +165,12 @@ Plans have two orthogonal state fields — there is no multi-step lifecycle to m | Field | Values | Meaning | |-------|--------|---------| -| `visibility` | `draft` / `published` | Drafts are private to the author. Published plans are visible to the whole org. | +| `visibility` | `draft` / `published` | Drafts are **unlisted, not locked**: anyone with the plan's URL/id can read it, but it never appears in anyone else's lists, search, or shelves. Published plans are listed for the whole org. | | `archived` | `true` / `false` | Archived plans stay readable at their URL but disappear from lists and search unless explicitly filtered for. | Rules that matter for agents: -- **New plans are published (org-visible) from the moment they're created.** Sharing is the default. Pass `"visibility": "draft"` on create only when the user explicitly wants a private draft; publish a draft later with `PATCH {"visibility": "published"}`. Publishing is one-way (a published plan cannot return to draft; archive it instead). +- **New plans are published (org-listed) from the moment they're created.** Sharing is the default. Pass `"visibility": "draft"` on create only when the user explicitly wants an unlisted draft (shareable by link); publish it later with `PATCH {"visibility": "published"}`. Publishing is one-way (a published plan cannot return to draft; archive it instead). - Archive (`{"archived": true}`) anything that is dead, superseded, or was created by mistake — including your own duplicates. Unarchive with `{"archived": false}`. - **Never create a new plan to "replace" one you can edit.** Plans have full version history; edit the existing plan (see Editing Plans). If a plan genuinely supersedes another, add a reference between them and archive the old one. - If a plan has a real rollout lifecycle worth tracking, use tags (e.g. `developing`, `live`) — status fields for that no longer exist. diff --git a/engine/app/views/coplan/libraries/_shelf.html.erb b/engine/app/views/coplan/libraries/_shelf.html.erb new file mode 100644 index 00000000..b809836f --- /dev/null +++ b/engine/app/views/coplan/libraries/_shelf.html.erb @@ -0,0 +1,33 @@ +<%# One folder of a browsed library: its visible plans, then subfolders. + Uses @folder_children / @placements_by_folder from LibrariesController. + Open by default — a library page is for reading the whole shelf. %> +<% placements = @placements_by_folder[folder.id] || [] %> +<% children = (@folder_children[folder.id] || []).sort_by { |f| f.name.downcase } %> + +
    + + + <%= folder.name %> + <%= placements.size %> + + + <% if placements.any? %> +
      + <% placements.each do |placement| %> + <% plan = placement.plan %> +
    • + <%= link_to plan.title, plan_path(plan), class: "library-shelf__plan-title" %><%= plan_state_badge(plan) %> + + by <%= plan.created_by_user.name %> · updated <%= time_ago_in_words(plan.updated_at) %> ago + +
    • + <% end %> +
    + <% elsif children.empty? %> +

    Empty shelf.

    + <% end %> + + <% children.each do |child| %> + <%= render "coplan/libraries/shelf", folder: child, depth: depth + 1 %> + <% end %> +
    diff --git a/engine/app/views/coplan/libraries/show.html.erb b/engine/app/views/coplan/libraries/show.html.erb new file mode 100644 index 00000000..6581f123 --- /dev/null +++ b/engine/app/views/coplan/libraries/show.html.erb @@ -0,0 +1,39 @@ +<% owner_name = @owner.respond_to?(:name) ? @owner.name : @library.name %> +<% mine = @library.writable_by?(current_user) %> + +
    +
    +
    + <% if @owner.is_a?(CoPlan::User) %> + <%= user_avatar(@owner, size: "lg") %> + <% end %> +
    +

    <%= mine ? "Your library" : "#{owner_name}’s library" %>

    +

    + <%= pluralize(@plan_count, "plan") %> across <%= pluralize(@folders.size, "folder") %> + <% unless mine %> · curated by <%= owner_name %><% end %> +

    +
    +
    + <% if mine %> + <%= link_to "Organize in workspace", plans_path, class: "btn btn--secondary btn--sm" %> + <% end %> +
    + + <% if @folders.empty? %> +
    +

    Nothing on these shelves yet.

    + <% if mine %> +

    Create folders in your <%= link_to "workspace", plans_path %> sidebar and drag plans onto them — yours or anyone’s published work.

    + <% else %> +

    <%= owner_name %> hasn’t organized any plans into folders yet.

    + <% end %> +
    + <% else %> +
    + <% @root_folders.sort_by { |f| f.name.downcase }.each do |folder| %> + <%= render "coplan/libraries/shelf", folder: folder, depth: 1 %> + <% end %> +
    + <% end %> +
    diff --git a/engine/app/views/coplan/plans/_folder_node.html.erb b/engine/app/views/coplan/plans/_folder_node.html.erb index 3935f21a..f28fb187 100644 --- a/engine/app/views/coplan/plans/_folder_node.html.erb +++ b/engine/app/views/coplan/plans/_folder_node.html.erb @@ -5,7 +5,7 @@ <% children = (@folder_children[folder.id] || []).sort_by { |f| f.name.downcase } %> <% active = @folder&.id == folder.id %> <% folder_link = capture do %> - <%= link_to plans_path(params.permit(:scope, :status, :plan_type, :tag).merge(folder: folder.id)), + <%= link_to plans_path(params.permit(:scope, :filter, :plan_type, :tag).merge(folder: folder.id)), class: "folder-tree__link #{'folder-tree__link--active' if active}", data: { "coplan--folder-dnd-target": "folder", diff --git a/engine/app/views/coplan/plans/_plan_group.html.erb b/engine/app/views/coplan/plans/_plan_group.html.erb index fbb3f887..9b93fb9e 100644 --- a/engine/app/views/coplan/plans/_plan_group.html.erb +++ b/engine/app/views/coplan/plans/_plan_group.html.erb @@ -1,5 +1,5 @@ <%# A collapsible group in the main pane (published work, or the viewer's - private drafts). Collapsed state persists per user in localStorage + the viewer's unlisted drafts). Collapsed state persists per user in localStorage (coplan--plan-groups controller); drafts start collapsed by default. The group body contains the first page of rows plus the lazy "load more" frame chain, so collapsing a group also stops further pages from diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index 797884d1..c5ee4b87 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -1,25 +1,23 @@ -<%# Compact plan row for the workspace index. Only the plan's author can - move it between folders, so only author rows are draggable and get the - "Move to folder" row menu (server-rendered gating — this page doesn't - broadcast). %> -<% author = plan.created_by_user_id == current_user.id %> +<%# Compact plan row for the workspace index. Shelving is viewer-relative: + any row can be dragged into (or re-filed within) the current user's own + library, whoever wrote the plan — the folder shown is where *you* filed + it (server-rendered per viewer — this page doesn't broadcast). %> <% summary = plan.try(:summary).presence || plan_content_preview(plan) %> +<% my_folder_id = viewer_folder_id(plan) %>
    " data-plan-id="<%= plan.id %>" data-visibility="<%= plan.visibility %>" - <% if author %> - draggable="true" - data-move-url="<%= move_to_folder_plan_path(plan) %>" - data-action="dragstart->coplan--folder-dnd#dragStart dragend->coplan--folder-dnd#dragEnd" - <% end %>> + draggable="true" + data-move-url="<%= move_to_folder_plan_path(plan) %>" + data-action="dragstart->coplan--folder-dnd#dragStart dragend->coplan--folder-dnd#dragEnd">
    <%= link_to plan.title, plan_path(plan), class: "plan-row__title", data: { turbo_frame: "_top" } %><%= plan_state_badge(plan) %> - <% if plan.folder %> - <%= link_to plans_path(params.permit(:scope, :filter, :plan_type, :tag).merge(folder: plan.folder_id)), + <% if my_folder_id %> + <%= link_to plans_path(params.permit(:scope, :filter, :plan_type, :tag).merge(folder: my_folder_id)), class: "plan-row__folder", data: { turbo_frame: "_top" } do %> - <%= folder_path_for(plan.folder) %> + <%= folder_paths_by_id[my_folder_id] %> <% end %> <% end %> <% plan.tags.each do |tag| %> @@ -34,23 +32,21 @@ <% end %> <%= time_ago_in_words(plan.updated_at) %> ago <%= user_avatar(plan.created_by_user) %> - <% if author %> -
    - -
    - <%# turbo_frame: _top — rows render inside pagination frames, but the - move redirect (and its flash) must replace the whole page. %> - <%= form_with url: move_to_folder_plan_path(plan), method: :patch, - class: "plan-row__move-form", data: { turbo_frame: "_top" } do |f| %> - - <%= f.select :folder_id, - options_for_select([["No folder", ""]] + folder_select_options.map { |path, id, _| [path, id] }, plan.folder_id), - {}, class: "plan-row__move-select", id: "move-folder-#{plan.id}" %> - <%= f.submit "Move", class: "btn btn--sm" %> - <% end %> -
    -
    - <% end %> +
    + +
    + <%# turbo_frame: _top — rows render inside pagination frames, but the + move redirect (and its flash) must replace the whole page. %> + <%= form_with url: move_to_folder_plan_path(plan), method: :patch, + class: "plan-row__move-form", data: { turbo_frame: "_top" } do |f| %> + + <%= f.select :folder_id, + options_for_select([["No folder", ""]] + folder_select_options.map { |path, id, _| [path, id] }, my_folder_id), + {}, class: "plan-row__move-select", id: "move-folder-#{plan.id}" %> + <%= f.submit "Move", class: "btn btn--sm" %> + <% end %> +
    +
    <% if summary.present? %>

    <%= summary %>

    diff --git a/engine/app/views/coplan/plans/_shelves.html.erb b/engine/app/views/coplan/plans/_shelves.html.erb new file mode 100644 index 00000000..33d4c597 --- /dev/null +++ b/engine/app/views/coplan/plans/_shelves.html.erb @@ -0,0 +1,32 @@ +<%# Folder-jump discovery strip: every library shelf this plan sits on, + plus a quick "shelve it yourself" control. Lives outside the broadcast + #plan-header region — it's viewer-relative (needs current_user). %> +<% my_placement = placements.find { |p| p.library.writable_by?(current_user) } %> + +
    + + + Shelved in + + + <% if placements.any? %> + <% placements.each do |placement| %> + <% owner = placement.library.owner %> + <% owner_label = placement.library.writable_by?(current_user) ? "your library" : "#{owner.respond_to?(:name) ? owner.name : placement.library.name}’s" %> + <%= link_to library_path(placement.library, anchor: "folder-#{placement.folder_id}"), class: "plan-shelves__shelf" do %> + <%= owner_label %> · <%= placement.folder.path %> + <% end %> + <% end %> + <% else %> + nobody’s library yet + <% end %> + + <% if my_placement.nil? && my_folders.any? %> + <%= form_with url: move_to_folder_plan_path(plan), method: :patch, class: "plan-shelves__add-form" do |f| %> + <%= f.select :folder_id, + options_for_select([["Add to your library…", ""]] + CoPlan::Folder.paths_by_id(my_folders).sort_by { |_id, path| path.downcase }.map { |id, path| [path, id] }), + {}, class: "plan-shelves__add-select", aria: { label: "Add to your library" }, + onchange: "if(this.value) this.form.requestSubmit()" %> + <% end %> + <% end %> +
    diff --git a/engine/app/views/coplan/plans/_sidebar.html.erb b/engine/app/views/coplan/plans/_sidebar.html.erb index 8941bbd2..3a3b8b20 100644 --- a/engine/app/views/coplan/plans/_sidebar.html.erb +++ b/engine/app/views/coplan/plans/_sidebar.html.erb @@ -1,6 +1,9 @@
    diff --git a/engine/app/views/coplan/plans/_header.html.erb b/engine/app/views/coplan/plans/_header.html.erb index 22122a89..85692b09 100644 --- a/engine/app/views/coplan/plans/_header.html.erb +++ b/engine/app/views/coplan/plans/_header.html.erb @@ -2,7 +2,7 @@

    <%= plan.title %>

    - <%= user_avatar(plan.created_by_user) %> <%= plan.created_by_user.name %> · v<%= plan.current_revision %><%= plan_state_badge(plan) %> + <%= user_avatar(plan.created_by_user) %> <%= profile_link(plan.created_by_user) %> · v<%= plan.current_revision %><%= plan_state_badge(plan) %> <% if plan.plan_type %> · <%= plan.plan_type.name %> <% end %> diff --git a/engine/app/views/coplan/plans/_plan_row.html.erb b/engine/app/views/coplan/plans/_plan_row.html.erb index c5ee4b87..3797f135 100644 --- a/engine/app/views/coplan/plans/_plan_row.html.erb +++ b/engine/app/views/coplan/plans/_plan_row.html.erb @@ -31,7 +31,7 @@ "><%= unread %> <% end %> <%= time_ago_in_words(plan.updated_at) %> ago - <%= user_avatar(plan.created_by_user) %> + <%= link_to user_avatar(plan.created_by_user), profile_path_for(plan.created_by_user), title: plan.created_by_user.name, data: { turbo_frame: "_top" } %>
    diff --git a/engine/app/views/coplan/profiles/show.html.erb b/engine/app/views/coplan/profiles/show.html.erb new file mode 100644 index 00000000..971a1422 --- /dev/null +++ b/engine/app/views/coplan/profiles/show.html.erb @@ -0,0 +1,69 @@ +<% content_for(:title, "#{@profile.name} — CoPlan") %> +<% mine = @user == current_user %> + +
    +
    +
    + <% if @profile.avatar_url.present? %> + <%= @profile.name %> + <% else %> + <%= @profile.name.split.map { |w| w[0] }.first(2).join.upcase %> + <% end %> +
    +

    <%= @profile.name %>

    +

    + <%= [@profile.title, @profile.team].compact_blank.join(" · ") %> + <% if @user.username.present? %> + @<%= @user.username %> + <% end %> +

    +

    + <%= pluralize(@plans.size, "published plan") %> + <% if @shelved_count.positive? %> · <%= pluralize(@shelved_count, "plan") %> on their shelves<% end %> + <% if @profile.profile_url.present? %> + · <%= link_to "View in directory ↗", @profile.profile_url, target: "_blank", rel: "noopener", class: "profile__directory-link" %> + <% end %> +

    +
    +
    + <% if mine %> + <%= link_to "Your workspace", plans_path, class: "btn btn--secondary btn--sm" %> + <% end %> +
    + +
    +
    +

    Published plans

    + <% if @plans.empty? %> +

    Nothing published yet.

    + <% else %> +
      + <% @plans.each do |plan| %> +
    • + <%= link_to plan.title, plan_path(plan), class: "profile__plan-title" %> + + <% if plan.plan_type %><%= plan.plan_type.name %><% end %> + updated <%= time_ago_in_words(plan.updated_at) %> ago + +
    • + <% end %> +
    + <% end %> +
    + +
    +

    + <%= link_to library_path(@library), class: "profile__library-link" do %>Library →<% end %> +

    + <% if @root_folders.empty? %> +

    <%= mine ? "You haven’t" : "#{@profile.name} hasn’t" %> organized any shelves yet.

    + <% else %> +
    + <% @root_folders.sort_by { |f| f.name.downcase }.each do |folder| %> + <%= render "coplan/libraries/shelf", folder: folder, depth: 1 %> + <% end %> +
    + <% end %> +
    +
    +
    diff --git a/engine/config/routes.rb b/engine/config/routes.rb index cd981148..d73eb40f 100644 --- a/engine/config/routes.rb +++ b/engine/config/routes.rb @@ -40,6 +40,11 @@ resources :libraries, only: [:show] get "library", to: "libraries#mine", as: :my_library + # Profile pages — the front door to a person's library. :id is a + # username or user id; usernames may contain dots, so the constraint + # keeps Rails from peeling ".l" off "hampton.l" as a format. + get "people/:id", to: "profiles#show", as: :profile, constraints: { id: %r{[^/]+} } + namespace :api do namespace :v1 do resources :tags, only: [:index] diff --git a/engine/lib/coplan/configuration.rb b/engine/lib/coplan/configuration.rb index 7d94448a..1cd491ef 100644 --- a/engine/lib/coplan/configuration.rb +++ b/engine/lib/coplan/configuration.rb @@ -70,6 +70,30 @@ class Configuration # } attr_accessor :user_search + # Lambda for enriching profile pages from the host's people directory + # (LDAP, a People API, ...). Receives a CoPlan::User, returns a hash + # with any of :name, :avatar_url, :title, :team, :profile_url — + # values present override the local coplan_users columns, and + # :profile_url adds a "view in directory" link out to the canonical + # people page. Return nil (or omit keys) to fall back to local data. + # + # Called on the request thread when rendering a profile; exceptions + # are swallowed and reported via `error_reporter`, so a flaky + # directory degrades to the minimal local profile instead of a 500. + # Hosts should cache inside the lambda if their directory is slow. + # + # Example: + # config.directory_profile = ->(user) { + # person = PeopleApi.lookup(email: user.email) + # { + # avatar_url: person.photo_url, + # title: person.job_title, + # team: person.org_name, + # profile_url: person.canonical_url + # } + # } + attr_accessor :directory_profile + def initialize @authenticate = nil @ai_base_url = "https://api.openai.com/v1" diff --git a/spec/requests/profiles_spec.rb b/spec/requests/profiles_spec.rb new file mode 100644 index 00000000..575c240f --- /dev/null +++ b/spec/requests/profiles_spec.rb @@ -0,0 +1,97 @@ +require "rails_helper" + +RSpec.describe "Profiles", type: :request do + let(:viewer) { create(:coplan_user, name: "Vera Viewer") } + let!(:author) { create(:coplan_user, name: "Ada Author", username: "ada.a", title: "Engineer", team: "Payments") } + + before { sign_in_as(viewer) } + + describe "GET /people/:id" do + it "renders the profile by username, dots included" do + get profile_path("ada.a") + expect(response).to have_http_status(:ok) + expect(response.body).to include("Ada Author") + expect(response.body).to include("Engineer") + expect(response.body).to include("Payments") + end + + it "renders the profile by user id" do + get profile_path(author.id) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Ada Author") + end + + it "404s for an unknown person" do + get profile_path("nobody-here") + expect(response).to have_http_status(:not_found) + end + + it "lists published plans but never drafts or archived plans" do + create(:plan, :considering, created_by_user: author, title: "Public Work") + create(:plan, :draft, created_by_user: author, title: "Secret Draft") + create(:plan, :considering, created_by_user: author, title: "Old Work", archived_at: 1.day.ago) + + get profile_path(author.id) + expect(response.body).to include("Public Work") + expect(response.body).not_to include("Secret Draft") + expect(response.body).not_to include("Old Work") + end + + it "hides your own drafts on your own profile too — a profile is a public shelf" do + create(:plan, :draft, created_by_user: viewer, title: "My Own Draft") + + get profile_path(viewer.id) + expect(response.body).not_to include("My Own Draft") + end + + it "shows the library shelves with only publicly listed placements" do + folder = create(:folder, name: "Favorites", created_by_user: author) + shown = create(:plan, :considering, created_by_user: author, title: "Shelved Public") + hidden = create(:plan, :draft, created_by_user: author, title: "Shelved Draft") + CoPlan::Plans::Place.call(plan: shown, folder: folder, actor: author) + CoPlan::Plans::Place.call(plan: hidden, folder: folder, actor: author) + + get profile_path(author.id) + expect(response.body).to include("Favorites") + expect(response.body).to include("Shelved Public") + expect(response.body).not_to include("Shelved Draft") + end + + describe "directory adapter" do + after { CoPlan.configuration.directory_profile = nil } + + it "overrides local fields and adds a directory link" do + CoPlan.configuration.directory_profile = ->(user) { + { title: "Staff Engineer", profile_url: "https://people.example.com/ada" } + } + + get profile_path(author.id) + expect(response.body).to include("Staff Engineer") + expect(response.body).not_to include(">Engineer<") + expect(response.body).to include("https://people.example.com/ada") + expect(response.body).to include("View in directory") + end + + it "falls back to the local profile when the hook raises" do + reported = [] + allow(CoPlan.configuration).to receive(:error_reporter).and_return(->(e, ctx) { reported << e }) + CoPlan.configuration.directory_profile = ->(user) { raise "directory down" } + + get profile_path(author.id) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Ada Author") + expect(response.body).to include("Engineer") + expect(reported.size).to eq(1) + end + end + end + + describe "author links" do + it "links the plan header author name to their profile" do + plan = create(:plan, :considering, created_by_user: author, title: "Linked Plan") + + get plan_path(plan) + expect(response.body).to include(profile_path("ada.a")) + end + end +end From 809ebd73efaa550c42a8157a24db0f2b194997d4 Mon Sep 17 00:00:00 2001 From: Hampton Lintorn-Catlin Date: Fri, 17 Jul 2026 17:02:47 -0500 Subject: [PATCH 06/19] Phase 4: Home activity feed, people search, three-surface nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home (/home) is the new org-facing landing: a feed of publicly listed plan activity rolled up per plan per day (agent editing loops hit rev 29 in days; per-edit entries would drown it), built by CoPlan::HomeFeed from versions, publish events, and comments over a 14-day window. Drafts and archived plans never appear. Search now finds people too — a person result lands on their profile, which is the road to their library. The nav grew primary links (Home / Workspace / Library), the signed-in root redirect goes to Home, and your name in the nav links to your profile. "All plans" stays demoted to a workspace sidebar filter. Co-Authored-By: Claude Fable 5 --- .../assets/stylesheets/coplan/application.css | 146 ++++++++++++++++++ .../app/controllers/coplan/home_controller.rb | 12 ++ .../controllers/coplan/search_controller.rb | 15 ++ .../controllers/coplan/welcome_controller.rb | 7 +- engine/app/models/coplan/home_feed.rb | 77 +++++++++ engine/app/views/coplan/home/show.html.erb | 58 +++++++ .../app/views/coplan/search/_modal.html.erb | 2 +- .../app/views/coplan/search/_results.html.erb | 28 +++- engine/app/views/coplan/search/index.html.erb | 3 +- .../views/layouts/coplan/application.html.erb | 14 +- engine/config/routes.rb | 2 + spec/requests/home_spec.rb | 52 +++++++ spec/requests/search_spec.rb | 11 +- spec/requests/welcome_spec.rb | 8 +- 14 files changed, 420 insertions(+), 15 deletions(-) create mode 100644 engine/app/controllers/coplan/home_controller.rb create mode 100644 engine/app/models/coplan/home_feed.rb create mode 100644 engine/app/views/coplan/home/show.html.erb create mode 100644 spec/requests/home_spec.rb diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index 29fe5a74..0d7d70fe 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -347,6 +347,20 @@ img, svg { text-decoration: none; } +.site-nav__links a.site-nav__link--active { + color: var(--color-text); + font-weight: 600; +} + +.site-nav__profile-link { + color: inherit; + text-decoration: none; +} + +.site-nav__profile-link:hover { + color: var(--color-text); +} + .site-nav__right { display: flex; align-items: center; @@ -3111,6 +3125,19 @@ body:not(:has(.comment-toolbar)) .web-push-banner { text-decoration: none; } +.search-modal__result--person { + flex-direction: row; + align-items: center; + gap: var(--space-sm); +} + +.search-modal__result-text { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + .search-modal__result-title { font-weight: 500; font-size: var(--text-md); @@ -3469,6 +3496,125 @@ body:not(:has(.comment-toolbar)) .web-push-banner { margin-left: auto; } +/* Home — the org-facing activity feed */ +.home { + max-width: 760px; + margin: 0 auto; +} + +.home__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-md); + margin-bottom: var(--space-xl); +} + +.home__title { + margin: 0; + font-size: 1.5rem; +} + +.home__subtitle { + margin: 4px 0 0; + font-size: var(--text-sm); +} + +.home__empty { + padding: var(--space-xl) 0; + text-align: center; +} + +.home__day { + margin-bottom: var(--space-xl); +} + +.home__day-heading { + font-size: var(--text-sm); + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--color-text-muted); + margin: 0 0 var(--space-sm); +} + +.home__feed { + list-style: none; + margin: 0; + padding: 0; +} + +.home__item { + display: flex; + align-items: flex-start; + gap: var(--space-md); + padding: var(--space-md) 0; + border-bottom: 1px solid var(--color-border); +} + +.home__item:last-child { + border-bottom: none; +} + +.home__item-avatar { + flex-shrink: 0; + padding-top: 2px; +} + +.home__item-body { + min-width: 0; + flex: 1; +} + +.home__item-line { + display: flex; + align-items: baseline; + gap: var(--space-sm); + flex-wrap: wrap; +} + +.home__item-title { + font-weight: 500; + color: var(--color-text); + text-decoration: none; +} + +.home__item-title:hover { + color: var(--color-primary); + text-decoration: underline; +} + +.home__item-activity { + font-size: var(--text-xs); + color: var(--color-text-muted); + white-space: nowrap; +} + +.home__item-activity--published { + color: var(--color-primary); + font-weight: 600; +} + +.home__item-meta { + font-size: var(--text-xs); + margin-top: 2px; +} + +.home__item-tag { + color: var(--color-text-muted); + text-decoration: none; + margin-left: var(--space-xs); +} + +.home__item-tag:hover { + color: var(--color-primary); +} + +.home__item-time { + font-size: var(--text-xs); + white-space: nowrap; + padding-top: 3px; +} + .plan-actions__hint { display: block; font-size: var(--text-xs); diff --git a/engine/app/controllers/coplan/home_controller.rb b/engine/app/controllers/coplan/home_controller.rb new file mode 100644 index 00000000..da635a7b --- /dev/null +++ b/engine/app/controllers/coplan/home_controller.rb @@ -0,0 +1,12 @@ +module CoPlan + # Home — the org-facing "what's happening" surface. A per-plan-per-day + # activity feed over published work, plus the sitewide search in the nav + # as the other discovery tool. Your own working list lives in the + # Workspace (/plans); Home is everyone's. + class HomeController < ApplicationController + def show + @items = HomeFeed.build + @items_by_date = @items.group_by(&:date) + end + end +end diff --git a/engine/app/controllers/coplan/search_controller.rb b/engine/app/controllers/coplan/search_controller.rb index 81227891..a8cacf01 100644 --- a/engine/app/controllers/coplan/search_controller.rb +++ b/engine/app/controllers/coplan/search_controller.rb @@ -13,6 +13,7 @@ module CoPlan # unauthenticated callers. class SearchController < ApplicationController MAX_RESULTS = 20 + MAX_PEOPLE = 5 def index @query = params[:q].to_s.strip @@ -25,6 +26,19 @@ def index [] end + # Search finds people too — landing on a profile is how you get to + # someone's library. Local-table match; the directory adapter enriches + # the profile itself, not the search. + @people = if @query.present? + sanitized = User.sanitize_sql_like(@query) + User.where("name LIKE :q OR username LIKE :q OR email LIKE :q", q: "%#{sanitized}%") + .order(:name) + .limit(MAX_PEOPLE) + .to_a + else + [] + end + # Only persist explicit navigations as recent searches — not every # typeahead `frame=results` request fired on each keystroke. Otherwise # typing "roadmap" would log r, ro, roa, … and evict actual recents. @@ -38,6 +52,7 @@ def index render partial: "coplan/search/results", layout: false, locals: { query: @query, results: @results, + people: @people, recent_queries: @recent_queries } end diff --git a/engine/app/controllers/coplan/welcome_controller.rb b/engine/app/controllers/coplan/welcome_controller.rb index 462c570b..8b9f671d 100644 --- a/engine/app/controllers/coplan/welcome_controller.rb +++ b/engine/app/controllers/coplan/welcome_controller.rb @@ -2,8 +2,9 @@ module CoPlan # Renders the public landing page (mounted at "/welcome" and at "/"). # # Behavior at "/" (root): - # * Signed-in users who already have at least one plan are redirected to the - # plans index — they know what CoPlan is and don't need the intro. + # * Signed-in users who already have at least one plan are redirected to + # Home (the activity feed) — they know what CoPlan is and don't need the + # intro. # * Everyone else (signed-in users with no plans yet, or anyone hitting the # page anonymously) sees the landing partial configured via # `CoPlan.configuration.landing_page_partial`. @@ -24,7 +25,7 @@ class WelcomeController < ApplicationController def show if signed_in? && current_user.created_plans.exists? && params[:force].blank? - redirect_to plans_path and return + redirect_to home_path and return end @landing_partial = CoPlan.configuration.landing_page_partial diff --git a/engine/app/models/coplan/home_feed.rb b/engine/app/models/coplan/home_feed.rb new file mode 100644 index 00000000..4a39212f --- /dev/null +++ b/engine/app/models/coplan/home_feed.rb @@ -0,0 +1,77 @@ +module CoPlan + # The Home activity feed: what happened to publicly listed plans lately, + # rolled up per plan per day. Agent editing loops push a plan through + # dozens of revisions in hours — per-edit entries would drown the feed, + # but "Payments Plan · 29 edits · 4 comments · today" reads in a glance. + # + # Only publicly listed work appears (Plan.publicly_listed): drafts and + # archived plans never show up on Home, whoever is looking. + class HomeFeed + WINDOW = 14.days + MAX_ITEMS = 40 + + Item = Struct.new(:plan, :date, :published, :edits, :comments, :last_activity_at, keyword_init: true) do + # One human phrase for the day's activity, e.g. "published · 3 edits". + def summary_parts + parts = [] + parts << "published" if published + parts << "#{edits} #{edits == 1 ? "edit" : "edits"}" if edits.positive? + parts << "#{comments} #{comments == 1 ? "comment" : "comments"}" if comments.positive? + parts + end + end + + # Returns Items sorted by most recent activity, newest first. + def self.build(now: Time.current) + since = now - WINDOW + listed = Plan.publicly_listed.select(:id) + + rollups = Hash.new do |h, k| + h[k] = { published: false, edits: 0, comments: 0, last_at: nil } + end + note = lambda do |plan_id, at| + rollup = rollups[[ plan_id, at.to_date ]] + rollup[:last_at] = at if rollup[:last_at].nil? || at > rollup[:last_at] + rollup + end + + # Revision 1 is the plan coming into existence — for born-published + # plans that IS the publish moment, so it reads as "published". + PlanVersion.where(plan_id: listed).where(created_at: since..) + .pluck(:plan_id, :created_at, :revision) + .each do |plan_id, at, revision| + rollup = note.call(plan_id, at) + revision == 1 ? rollup[:published] = true : rollup[:edits] += 1 + end + + PlanEvent.where(plan_id: listed, event_type: "published").where(created_at: since..) + .pluck(:plan_id, :created_at) + .each { |plan_id, at| note.call(plan_id, at)[:published] = true } + + Comment.kept.joins(:comment_thread) + .where(coplan_comment_threads: { plan_id: listed }) + .where(coplan_comments: { created_at: since.. }) + .pluck("coplan_comment_threads.plan_id", "coplan_comments.created_at") + .each { |plan_id, at| note.call(plan_id, at)[:comments] += 1 } + + top = rollups.sort_by { |_key, rollup| -rollup[:last_at].to_i }.first(MAX_ITEMS) + plans = Plan.where(id: top.map { |(plan_id, _date), _| plan_id }.uniq) + .includes(:created_by_user, :plan_type, :tags) + .index_by(&:id) + + top.filter_map do |(plan_id, date), rollup| + plan = plans[plan_id] + next unless plan + + Item.new( + plan: plan, + date: date, + published: rollup[:published], + edits: rollup[:edits], + comments: rollup[:comments], + last_activity_at: rollup[:last_at] + ) + end + end + end +end diff --git a/engine/app/views/coplan/home/show.html.erb b/engine/app/views/coplan/home/show.html.erb new file mode 100644 index 00000000..0ac9d2a0 --- /dev/null +++ b/engine/app/views/coplan/home/show.html.erb @@ -0,0 +1,58 @@ +<% content_for(:title, "Home — CoPlan") %> + +
    +
    +
    +

    What’s happening

    +

    Published plans across the org, rolled up by day. Your own work lives in the <%= link_to "workspace", plans_path %>.

    +
    + <%= link_to "Your workspace", plans_path, class: "btn btn--secondary btn--sm" %> +
    + + <% if @items.empty? %> +
    +

    No published activity in the last two weeks.

    +

    When someone publishes, edits, or discusses a plan, it shows up here.

    +
    + <% else %> + <% @items_by_date.each do |date, items| %> +
    +

    + <% if date == Date.current %>Today + <% elsif date == Date.current - 1 %>Yesterday + <% else %><%= date.strftime("%A, %B %-d") %><% end %> +

    +
      + <% items.each do |item| %> + <% plan = item.plan %> +
    • + + <%= link_to user_avatar(plan.created_by_user, size: "md"), profile_path_for(plan.created_by_user), title: plan.created_by_user.name %> + +
      +
      + <%= link_to plan.title, plan_path(plan), class: "home__item-title" %> + <% if item.summary_parts.any? %> + "> + <%= item.summary_parts.join(" · ") %> + + <% end %> +
      +
      + by <%= profile_link(plan.created_by_user) %> + <% if plan.plan_type %> · <%= plan.plan_type.name %><% end %> + <% plan.tags.first(3).each do |tag| %> + <%= link_to "##{tag.name}", plans_path(scope: "all", tag: tag.name), class: "home__item-tag" %> + <% end %> +
      +
      + + <%= time_ago_in_words(item.last_activity_at) %> ago + +
    • + <% end %> +
    +
    + <% end %> + <% end %> +
    diff --git a/engine/app/views/coplan/search/_modal.html.erb b/engine/app/views/coplan/search/_modal.html.erb index ce5f98bc..c6332a7a 100644 --- a/engine/app/views/coplan/search/_modal.html.erb +++ b/engine/app/views/coplan/search/_modal.html.erb @@ -23,7 +23,7 @@ diff --git a/engine/app/views/coplan/search/_results.html.erb b/engine/app/views/coplan/search/_results.html.erb index 92c51e26..e2fd4046 100644 --- a/engine/app/views/coplan/search/_results.html.erb +++ b/engine/app/views/coplan/search/_results.html.erb @@ -8,6 +8,7 @@ The keyboard-nav Stimulus controller (`coplan--search`) looks for `[data-search-result]` items; keep that hook intact when changing markup. %> <% frame_id = local_assigns.fetch(:frame_id, "search-results") %> +<% people = local_assigns.fetch(:people, []) %> <%# `target="_top"` so clicks on result anchors inside this frame navigate the whole page rather than trying to load the plan page into the frame (which would render "Content missing" since no matching frame exists @@ -35,9 +36,32 @@ <% else %>
    Type to search plans.
    <% end %> - <% elsif results.empty? %> -
    No plans match “<%= query %>”.
    + <% elsif results.empty? && people.empty? %> +
    Nothing matches “<%= query %>”.
    <% else %> + <% if people.any? %> + + + <% if results.any? %><% end %> + <% end %>
      <% results.each do |plan| %>
    • diff --git a/engine/app/views/coplan/search/index.html.erb b/engine/app/views/coplan/search/index.html.erb index 107df13e..82340622 100644 --- a/engine/app/views/coplan/search/index.html.erb +++ b/engine/app/views/coplan/search/index.html.erb @@ -8,7 +8,7 @@
    - +
    @@ -16,6 +16,7 @@ <%= render partial: "coplan/search/results", locals: { query: @query, results: @results, + people: @people, recent_queries: @recent_queries, frame_id: "search-page-results" } %> diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index 8b4b87df..672173bf 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -30,12 +30,20 @@ <%= coplan_environment_badge %> <% end %> <% if signed_in? %> +
    diff --git a/engine/app/views/coplan/plans/_attachments.html.erb b/engine/app/views/coplan/plans/_attachments.html.erb index 8604d833..ee87dd53 100644 --- a/engine/app/views/coplan/plans/_attachments.html.erb +++ b/engine/app/views/coplan/plans/_attachments.html.erb @@ -2,15 +2,20 @@
    <% if can_edit %>
    - <%= form_with url: plan_attachments_path(plan), method: :post, multipart: true, class: "attachments-upload" do |f| %> - <%= f.file_field :files, multiple: true, name: "files[]", required: true, class: "attachments-upload__input", - accept: CoPlan::Plan::ATTACHMENT_CONTENT_TYPES.join(",") %> - <%= f.submit "Upload", class: "btn btn--primary btn--sm" %> + <%# One designed dropzone instead of the native file input: click to + browse or drag files on; choosing files uploads immediately. %> + <%= form_with url: plan_attachments_path(plan), method: :post, multipart: true, + class: "attachments-upload", data: { controller: "coplan--dropzone" } do |f| %> + + <%= f.file_field :files, multiple: true, name: "files[]", class: "dropzone__input", + accept: CoPlan::Plan::ATTACHMENT_CONTENT_TYPES.join(","), + data: { "coplan--dropzone-target": "input", action: "change->coplan--dropzone#changed" } %> <% end %> -

    - Up to <%= CoPlan::Plan::ATTACHMENT_MAX_BYTES / 1.megabyte %> MB per file. - Images, PDF, text/markdown/CSV/JSON, and ZIP files. -

    <% end %> @@ -48,7 +53,7 @@ data-coplan--clipboard-text-value="<%= attachment_markdown_snippet(blob, blob_path) %>"> <% if can_edit %> diff --git a/engine/app/views/coplan/plans/_plan_group.html.erb b/engine/app/views/coplan/plans/_plan_group.html.erb index 9b93fb9e..6c930432 100644 --- a/engine/app/views/coplan/plans/_plan_group.html.erb +++ b/engine/app/views/coplan/plans/_plan_group.html.erb @@ -11,7 +11,16 @@

    diff --git a/engine/app/views/coplan/plans/_shelves.html.erb b/engine/app/views/coplan/plans/_shelves.html.erb index 33d4c597..b7ef3482 100644 --- a/engine/app/views/coplan/plans/_shelves.html.erb +++ b/engine/app/views/coplan/plans/_shelves.html.erb @@ -1,32 +1,36 @@ <%# Folder-jump discovery strip: every library shelf this plan sits on, - plus a quick "shelve it yourself" control. Lives outside the broadcast - #plan-header region — it's viewer-relative (needs current_user). %> + rendered Drive-style — a quiet folder chip with just the folder name. + Whose library it is lives in the tooltip (and the link target). Lives + outside the broadcast #plan-header region — it's viewer-relative + (needs current_user). %> <% my_placement = placements.find { |p| p.library.writable_by?(current_user) } %> -
    - - - Shelved in - - - <% if placements.any? %> +<% if placements.any? || my_folders.any? %> +
    <% placements.each do |placement| %> + <% mine = placement.library.writable_by?(current_user) %> <% owner = placement.library.owner %> - <% owner_label = placement.library.writable_by?(current_user) ? "your library" : "#{owner.respond_to?(:name) ? owner.name : placement.library.name}’s" %> - <%= link_to library_path(placement.library, anchor: "folder-#{placement.folder_id}"), class: "plan-shelves__shelf" do %> - <%= owner_label %> · <%= placement.folder.path %> + <% owner_name = owner.respond_to?(:name) ? owner.name : placement.library.name %> + <% title = mine ? "In your library — #{placement.folder.path}" : "In #{owner_name}’s library — #{placement.folder.path}" %> + <%# Your own shelf opens the folder in your workspace; someone else's + opens their read-only library at that shelf. %> + <% href = mine ? plans_path(folder: placement.folder_id) : library_path(placement.library, anchor: "folder-#{placement.folder_id}") %> + <%= link_to href, class: "plan-shelves__chip#{' plan-shelves__chip--theirs' unless mine}", title: title do %> + + <%= placement.folder.name %> + <% unless mine %> + <%= owner_name.split.first %> + <% end %> <% end %> <% end %> - <% else %> - nobody’s library yet - <% end %> - <% if my_placement.nil? && my_folders.any? %> - <%= form_with url: move_to_folder_plan_path(plan), method: :patch, class: "plan-shelves__add-form" do |f| %> - <%= f.select :folder_id, - options_for_select([["Add to your library…", ""]] + CoPlan::Folder.paths_by_id(my_folders).sort_by { |_id, path| path.downcase }.map { |id, path| [path, id] }), - {}, class: "plan-shelves__add-select", aria: { label: "Add to your library" }, - onchange: "if(this.value) this.form.requestSubmit()" %> + <% if my_placement.nil? && my_folders.any? %> + <%= form_with url: move_to_folder_plan_path(plan), method: :patch, class: "plan-shelves__add-form" do |f| %> + <%= f.select :folder_id, + options_for_select([["+ Add to folder", ""]] + CoPlan::Folder.paths_by_id(my_folders).sort_by { |_id, path| path.downcase }.map { |id, path| [path, id] }), + {}, class: "plan-shelves__add-select", aria: { label: "Add to a folder in your library" }, + onchange: "if(this.value) this.form.requestSubmit()" %> + <% end %> <% end %> - <% end %> -
    +
    +<% end %> diff --git a/engine/app/views/coplan/plans/_sidebar.html.erb b/engine/app/views/coplan/plans/_sidebar.html.erb index 3a3b8b20..c08a6cac 100644 --- a/engine/app/views/coplan/plans/_sidebar.html.erb +++ b/engine/app/views/coplan/plans/_sidebar.html.erb @@ -1,8 +1,9 @@