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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,4 +43,4 @@ def update
def custom_fields_params
params.require(:custom_field).permit(*CommunityCustomFields::CUSTOM_FIELDS.keys)
end
end
end
61 changes: 61 additions & 0 deletions app/models/community_custom_fields/topic_status_change.rb
Original file line number Diff line number Diff line change
@@ -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)
#
8 changes: 3 additions & 5 deletions config/routes.rb
Original file line number Diff line number Diff line change
@@ -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
mount ::CommunityCustomFields::Engine, at: "/admin/plugins/community-custom-fields"
end
17 changes: 17 additions & 0 deletions db/migrate/20260616011522_create_topic_status_changes.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion lib/community_custom_fields/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
end
36 changes: 26 additions & 10 deletions plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -26,19 +26,21 @@ 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|
Topic.register_custom_field_type(name, type)
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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
2 changes: 1 addition & 1 deletion spec/system/core_features_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
before { enable_current_plugin }

it_behaves_like "having working core features"
end
end
Loading