diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..653663e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +A Discourse plugin that adds custom fields to topics so Discourse can be used as a support/ticketing platform. It is currently **backend-only** — the `assets/javascripts` and `test/javascripts` directories are empty placeholders. Almost all logic lives in `plugin.rb`. + +## Architecture + +### `CUSTOM_FIELDS` registry (`plugin.rb`) +The `CommunityCustomFields::CUSTOM_FIELDS` hash (name → type) is the single source of truth. Adding a field there automatically: registers its type on `Topic`, preloads it on `TopicList`, exposes it via the `topic_view` serializer, and makes it permittable in the controller's strong params. Add a field in one place only. + +### The support-ticket state machine (`plugin.rb` event handlers) +The non-obvious core is the `topic_created` and `post_created` handlers, which maintain ticket state on topic custom fields. Understand these before editing: + +- **`status`** moves through `"new"` → `"open"` → `"snoozed"`/`"closed"` and back. `topic_created` seeds `status = "new"`. Valid values are the `CommunityCustomFields::STATUSES` list. +- **`waiting_since` / `waiting_id`** track the customer who is waiting on a reply. Set when a non-admin posts; cleared when an admin posts a regular reply. +- **`post_type`** drives branching: `1` = regular reply, `4` = whisper (staff-only note). Other post types are ignored. The first post (`post_number == 1`) is skipped because `topic_created` already handled it. +- **Admin regular reply** (type 1): clears `waiting_*`. +- **Admin whisper** (type 4): does *not* clear `waiting_*`, but can reopen a `snoozed`/`closed` topic. +- **Customer reply** (non-admin): sets `waiting_*`, reopens `snoozed`, and reopens `closed` — with a **1-month rule**: if the topic was closed more than a month ago (or had no `last_assigned_to_id`), it reopens as `"new"`; otherwise it reopens as `"open"` and is reassigned to the last assignee. +- `user.id <= 0` (system users) and non-`"regular"` archetypes (e.g. PMs) are skipped. + +### Controller (`app/controllers/community_custom_fields/custom_fields_controller.rb`) +Admin-only `PUT` endpoint to set custom fields on a topic. Mounted at `/admin/plugins/community-custom-fields/:topic_id` (see `config/routes.rb`). Uses `Topic.unscoped.find` so it can update topics that are otherwise filtered out (e.g. deleted/closed). It validates any incoming `status` against `CommunityCustomFields::STATUSES` (rejecting unknown values with `422`) and records a status change (see below). + +### Status-change history (`TopicStatusChange`) +Every status transition is logged to the `community_custom_fields_topic_status_changes` table (model: `app/models/community_custom_fields/topic_status_change.rb`). `TopicStatusChange.record` is the single writer — it no-ops unless the topic's current `status` differs from the passed `from_status`. It's called from two places, and `topic_created` is intentionally *not* recorded (no initial `"new"` row): + +- **Controller** (`source: "api_update"`) — passes the acting admin as `user_id`. +- **`post_created`** (`source: "post_creation"`) — passes the triggering `post_id`. + +Row columns: +- **`from_status` / `to_status`** — the transition; `from_status` is null when the topic had no prior status. +- **`assignee_id`** — the assignee *before* the change (attributes the change to whoever owned the ticket during the status being left). +- **`user_id`** (api_update) / **`post_id`** (post_creation) — what triggered the change; only one is set per row. +- **`duration`** — seconds spent in the status being left. Measured from the prior recorded change; for a topic with no table entry yet, from when the current status was set (its `topic_custom_fields` row); otherwise from `topic.created_at`. +- **`source`** — `"api_update"` or `"post_creation"`. + +## Commands + +Tests are **Discourse system specs** and cannot run standalone from this repo — they run inside a Discourse host app with this plugin symlinked into `plugins/`. From the Discourse core root: + +```bash +LOAD_PLUGINS=1 bin/rspec plugins/community-custom-fields/spec/system/core_features_spec.rb +``` + +Linting uses Discourse's shared configs (`@discourse/lint-configs`). Install with `pnpm install` (pnpm 9.x, Node ≥ 22 required), then: + +```bash +pnpm eslint . # JS +pnpm ember-template-lint . # Ember templates +pnpm stylelint "**/*.scss" # styles +pnpm prettier --check . # formatting +bundle exec rubocop # Ruby (rubocop-discourse, stree-compatible) +bundle exec stree check . # Ruby formatting (syntax_tree, print-width 100) +``` + +CI (`.github/workflows/discourse-plugin.yml`) runs the shared `discourse/.github` plugin workflow on push to `main` and on PRs. diff --git a/app/controllers/community_custom_fields/custom_fields_controller.rb b/app/controllers/community_custom_fields/custom_fields_controller.rb index 1f8fea7..6c01476 100644 --- a/app/controllers/community_custom_fields/custom_fields_controller.rb +++ b/app/controllers/community_custom_fields/custom_fields_controller.rb @@ -8,12 +8,32 @@ class CommunityCustomFields::CustomFieldsController < ::ApplicationController def update topic = Topic.unscoped.find(params[:topic_id]) - topic.custom_fields.merge!(custom_fields_params) + fields = custom_fields_params + + if fields.key?("status") && !CommunityCustomFields::STATUSES.include?(fields["status"]) + return render json: { error: "Invalid status: #{fields["status"].inspect}" }, status: 422 + end + + previous_status = topic.custom_fields["status"] + previous_assignee_id = topic.custom_fields["assignee_id"] + previous_status_at = + TopicCustomField.where(topic_id: topic.id, name: "status").pick(:created_at) + topic.custom_fields.merge!(fields) if topic.save_custom_fields + CommunityCustomFields::TopicStatusChange.record( + topic: topic, + from_status: previous_status, + source: "api_update", + assignee_id: previous_assignee_id, + user_id: current_user.id, + previous_status_at: previous_status_at, + ) topic.touch render json: success_json else - Rails.logger.error("Failed to save custom fields for topic #{topic.id}: #{topic.errors.full_messages}") + Rails.logger.error( + "Failed to save custom fields for topic #{topic.id}: #{topic.errors.full_messages}", + ) render json: { error: topic.errors.full_messages }, status: 422 end end @@ -23,4 +43,4 @@ def update def custom_fields_params params.require(:custom_field).permit(*CommunityCustomFields::CUSTOM_FIELDS.keys) end -end \ No newline at end of file +end diff --git a/app/models/community_custom_fields/topic_status_change.rb b/app/models/community_custom_fields/topic_status_change.rb new file mode 100644 index 0000000..959d5a8 --- /dev/null +++ b/app/models/community_custom_fields/topic_status_change.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module CommunityCustomFields + class TopicStatusChange < ActiveRecord::Base + self.table_name = "community_custom_fields_topic_status_changes" + + belongs_to :topic, class_name: "::Topic" + belongs_to :assignee, class_name: "::User", optional: true + belongs_to :user, class_name: "::User", optional: true + belongs_to :post, class_name: "::Post", optional: true + + def self.record( + topic:, + from_status:, + source:, + assignee_id:, + user_id: nil, + post_id: nil, + previous_status_at: nil + ) + to_status = topic.custom_fields["status"] + return if to_status.blank? || to_status == from_status + + last_change = where(topic_id: topic.id).order(:id).last + started_at = last_change&.created_at || previous_status_at || topic.created_at + duration = (Time.current - started_at).to_i + + create!( + topic_id: topic.id, + from_status: from_status, + to_status: to_status, + source: source, + assignee_id: assignee_id, + user_id: user_id, + post_id: post_id, + duration: duration, + ) + end + end +end + +# == Schema Information +# +# Table name: community_custom_fields_topic_status_changes +# +# id :bigint not null, primary key +# duration :bigint not null +# from_status :string +# source :string not null +# to_status :string not null +# created_at :datetime not null +# assignee_id :integer +# post_id :integer +# topic_id :integer not null +# user_id :integer +# +# Indexes +# +# idx_on_assignee_id_bc22060231 (assignee_id) +# index_community_custom_fields_topic_status_changes_on_topic_id (topic_id) +# diff --git a/config/routes.rb b/config/routes.rb index 9f1ba92..5f7a4bc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true -CommunityCustomFields::Engine.routes.draw do - put '/:topic_id' => 'custom_fields#update' -end +CommunityCustomFields::Engine.routes.draw { put "/:topic_id" => "custom_fields#update" } Discourse::Application.routes.draw do - mount ::CommunityCustomFields::Engine, at: '/admin/plugins/community-custom-fields' -end \ No newline at end of file + mount ::CommunityCustomFields::Engine, at: "/admin/plugins/community-custom-fields" +end diff --git a/db/migrate/20260616011522_create_topic_status_changes.rb b/db/migrate/20260616011522_create_topic_status_changes.rb new file mode 100644 index 0000000..70b0f5d --- /dev/null +++ b/db/migrate/20260616011522_create_topic_status_changes.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateTopicStatusChanges < ActiveRecord::Migration[7.2] + def change + create_table :community_custom_fields_topic_status_changes do |t| + t.integer :topic_id, null: false + t.integer :assignee_id + t.string :from_status + t.string :to_status, null: false + t.string :source, null: false + t.datetime :created_at, null: false + end + + add_index :community_custom_fields_topic_status_changes, :topic_id + add_index :community_custom_fields_topic_status_changes, :assignee_id + end +end diff --git a/db/migrate/20260724172111_add_trigger_and_duration_to_topic_status_changes.rb b/db/migrate/20260724172111_add_trigger_and_duration_to_topic_status_changes.rb new file mode 100644 index 0000000..70a93fd --- /dev/null +++ b/db/migrate/20260724172111_add_trigger_and_duration_to_topic_status_changes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AddTriggerAndDurationToTopicStatusChanges < ActiveRecord::Migration[7.2] + def up + add_column :community_custom_fields_topic_status_changes, :user_id, :integer + add_column :community_custom_fields_topic_status_changes, :post_id, :integer + add_column :community_custom_fields_topic_status_changes, :duration, :bigint + + # Backfill existing rows: seconds spent in the status being left, measured + # from the prior change for the topic (or the topic's creation). + execute <<~SQL + UPDATE community_custom_fields_topic_status_changes sc + SET duration = GREATEST( + TRUNC( + EXTRACT(EPOCH FROM (sc.created_at - COALESCE(prev.prev_created_at, t.created_at, sc.created_at))) + )::bigint, + 0 + ) + FROM ( + SELECT id, topic_id, + LAG(created_at) OVER (PARTITION BY topic_id ORDER BY id) AS prev_created_at + FROM community_custom_fields_topic_status_changes + ) prev + LEFT JOIN topics t ON t.id = prev.topic_id + WHERE sc.id = prev.id; + SQL + + change_column_null :community_custom_fields_topic_status_changes, :duration, false + end + + def down + remove_column :community_custom_fields_topic_status_changes, :duration + remove_column :community_custom_fields_topic_status_changes, :post_id + remove_column :community_custom_fields_topic_status_changes, :user_id + end +end diff --git a/lib/community_custom_fields/engine.rb b/lib/community_custom_fields/engine.rb index 685ff68..b71c97b 100644 --- a/lib/community_custom_fields/engine.rb +++ b/lib/community_custom_fields/engine.rb @@ -10,4 +10,4 @@ class Engine < ::Rails::Engine Rails.autoloaders.main.eager_load_dir(scheduled_job_dir) if Dir.exist?(scheduled_job_dir) end end -end \ No newline at end of file +end diff --git a/plugin.rb b/plugin.rb index 9c36a63..1e92381 100644 --- a/plugin.rb +++ b/plugin.rb @@ -13,7 +13,7 @@ module ::CommunityCustomFields CUSTOM_FIELDS = { assignee_id: :integer, first_assigned_to_id: :integer, - first_assigned_at: :datetime, + first_assigned_at: :datetime, last_assigned_to_id: :integer, last_assigned_at: :datetime, account_name: :string, @@ -26,11 +26,13 @@ module ::CommunityCustomFields closed_at: :datetime, snoozed_until: :datetime, waiting_since: :datetime, - waiting_id: :integer + waiting_id: :integer, } + + STATUSES = %w[new open snoozed closed] end -require_relative 'lib/community_custom_fields/engine.rb' +require_relative "lib/community_custom_fields/engine.rb" after_initialize do CommunityCustomFields::CUSTOM_FIELDS.each do |name, type| @@ -38,7 +40,7 @@ module ::CommunityCustomFields end TopicList.preloaded_custom_fields.merge(CommunityCustomFields::CUSTOM_FIELDS.keys) - + add_to_serializer(:topic_view, :custom_fields) do object.topic.custom_fields.slice(*CommunityCustomFields::CUSTOM_FIELDS.keys) end @@ -62,9 +64,13 @@ module ::CommunityCustomFields next if user.id <= 0 next unless post.post_type == 1 || post.post_type == 4 next if post.post_number == 1 - + topic = post.topic topic.custom_fields[:status] ||= "new" + previous_status = topic.custom_fields[:status] + previous_assignee_id = topic.custom_fields[:assignee_id] + previous_status_at = + TopicCustomField.where(topic_id: topic.id, name: "status").pick(:created_at) if user.admin && post.post_type == 1 topic.custom_fields[:waiting_since] = nil @@ -86,11 +92,11 @@ module ::CommunityCustomFields topic.custom_fields[:assignee_id] = topic.custom_fields[:last_assigned_to_id] topic.custom_fields[:last_assigned_at] = Time.current.iso8601 end - + topic.custom_fields[:outcome] = nil topic.custom_fields[:closed_at] = nil end - else + else if user.id != topic.custom_fields[:waiting_id].to_i topic.custom_fields[:waiting_since] = Time.current.iso8601 topic.custom_fields[:waiting_id] = user.id @@ -105,19 +111,29 @@ module ::CommunityCustomFields # this handles an edge case where `closed_at` was never set topic.custom_fields[:closed_at] ||= Time.current.iso8601 - if topic.custom_fields[:last_assigned_to_id].nil? || Time.iso8601(topic.custom_fields[:closed_at]) < 1.month.ago.iso8601 + if topic.custom_fields[:last_assigned_to_id].nil? || + Time.iso8601(topic.custom_fields[:closed_at]) < 1.month.ago.iso8601 topic.custom_fields[:status] = "new" else topic.custom_fields[:status] = "open" topic.custom_fields[:assignee_id] = topic.custom_fields[:last_assigned_to_id] topic.custom_fields[:last_assigned_at] = Time.current.iso8601 end - + topic.custom_fields[:outcome] = nil topic.custom_fields[:closed_at] = nil end end - + topic.save_custom_fields + + CommunityCustomFields::TopicStatusChange.record( + topic: topic, + from_status: previous_status, + source: "post_creation", + assignee_id: previous_assignee_id, + post_id: post.id, + previous_status_at: previous_status_at, + ) end end diff --git a/spec/system/core_features_spec.rb b/spec/system/core_features_spec.rb index 72ab0bf..db86db2 100644 --- a/spec/system/core_features_spec.rb +++ b/spec/system/core_features_spec.rb @@ -4,4 +4,4 @@ before { enable_current_plugin } it_behaves_like "having working core features" -end \ No newline at end of file +end