Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

13 KiB

PRD — Threaded Comment Responses

  • Slug: 2026-05-13-threaded-comments
  • Date: 2026-05-13
  • Status: Draft

Summary

Add replies to comments on tickets and project tasks. Replies render as an inline indented tree under their root (hybrid "Nested + collapsible drawer" model from the design handoff). Any reply can spawn its own sub-thread; visual indent caps at depth 4, data depth is unlimited. Every top-level comment becomes the head of a first-class comment_threads record that carries the email-thread identity (RFC Message-ID + References chain) so inbound and outbound email can flow correctly into and out of individual threads instead of being collapsed at the ticket level.

Problem

  1. Comments on tickets/tasks are a single chronological list. Two simultaneous conversations on a ticket (e.g. an internal investigation + a client-facing correspondence) interleave, which makes it hard to follow context.
  2. The ticket is currently the email "thread": every inbound email matching a ticket becomes a flat comment, with no way to route subsequent emails to the specific sub-conversation they answer.
  3. Outbound emails from comments use reply tokens but don't set RFC In-Reply-To/References, so mail clients can't thread our messages.

Goals

  • Reply to any comment on a ticket or project task; replies indent under the parent.
  • Recursive sub-threads with a per-thread collapse and "Open in drawer" action.
  • Internal/client visibility, resolution markers, edit/delete, reactions all continue to work.
  • Inbound email lands in the specific thread it answers (via reply token, then In-Reply-To, then References, then provider thread id; falls back to a new top-level thread on the matched ticket).
  • Outbound emails from a thread carry proper RFC threading headers, accumulate References, and reissue a thread-scoped reply token.
  • Existing comments migrate cleanly (each becomes a single-comment thread; UI unchanged for them).

Non-goals

  • Notification UI changes (Slack / email templates).
  • @mention picker upgrades.
  • New resolution-state UI (e.g. "Mark thread resolved").
  • The other reply modes from the prototype (Flat, Single-level only, Deep nesting only, Quote-reply, Side-panel-only) — those were design exploration; production ships the Hybrid model only. The Tweaks panel is prototype-only and is not part of production.
  • Density toggle (always "comfortable").
  • Project-task email integration (tasks don't accept inbound email today; out of scope here).
  • Thread-level resolution semantics, "mark thread answered", and any thread state beyond what's needed for the UI.

Users and Primary Flows

Personas:

  • Internal MSP technician (full read/write across internal + client-facing comments).
  • Client contact (read/write own client-facing comments).

Primary flows:

  1. Reply inline. Tech hovers a comment → clicks Reply → inline composer opens beneath the comment → submits → reply renders indented under the parent. A small thread bar appears above the children: "2 replies · Collapse".
  2. Collapse / Expand. Tech clicks Collapse → children hide; bar now shows "2 replies · last May 12, 9:24 AM" with Expand and Open in drawer.
  3. Open in drawer. Clicking Open in drawer opens a side panel with the root + all replies, focused composer at the bottom. Closing returns to inline view.
  4. Reply to a reply (sub-thread). Replies can themselves be replied to; each gains its own thread bar with dashed border. Indent caps at depth 4 visually.
  5. Inbound email. Client replies to our outbound email; the email matches the originating thread by In-Reply-To; a new comment lands as a child of the latest comment in that thread.
  6. Outbound email. When a tech replies in a thread, the outgoing email sets In-Reply-To: <latest outbound Message-ID for this thread>, appends to References, and issues a fresh reply token tied to the new comment.

UX / UI Notes

Source of truth: /tmp/design_extract/comment-responses/ handoff bundle (saved from Claude Design). Key visual decisions copied verbatim:

  • Comments card chrome unchanged from today's TicketConversation (24px card radius, indigo tabs, "Add Comment" purple button, BlockNote composer with Mark-as-Internal / Mark-as-Resolution switches).
  • Reply button added to the per-comment hover action row (Pencil, Trash, now also CornerUpLeft "Reply").
  • Thread bar is a pill at left-margin 24px above the children. Background #F9FAFB, indigo text #4F46E5 for Collapse / Open in drawer. Sub-thread bars (depth ≥ 1) use a dashed border and white background.
  • Drawer uses the existing Radix-based Drawer component from packages/ui; width 480px; slides from right; overlay rgba(15, 23, 42, 0.25).
  • Internal visibility indicators remain: amber Lock icon + amber 3px edge stripe. No tint mode.
  • Tab counts are recomputed at the thread level (a thread shows up in "Internal" if its root is internal; in "Resolution" if any of its comments is a resolution).
  • Sort order (oldest/newest first) toggles thread order by last_activity_at, not within-thread reply order — replies inside a thread are always chronological.
  • Depth cap: CSS class depth-4 stops increasing margin-left. Data is unlimited.

Requirements

Functional Requirements

  1. New comment_threads table (polymorphic between ticket / project task, exactly one of ticket_id/project_task_id set).
  2. comments and project_task_comments gain thread_id (NOT NULL after backfill) and parent_comment_id (nullable).
  3. Backfill: every existing comment becomes its own single-comment thread with parent_comment_id = NULL.
  4. Creating a comment without parent_comment_id creates a new comment_threads row; the new comment is its root.
  5. Creating a comment with parent_comment_id inherits thread_id from the parent and increments reply_count + bumps last_activity_at on that thread.
  6. Deleting a leaf comment hard-deletes and decrements thread reply_count. Deleting a root with children soft-deletes (note replaced with "[deleted]", a deleted_at set) so the tree stays well-formed.
  7. Tab filtering operates on threads:
    • All — all threads.
    • Client — threads whose root is non-internal.
    • Internal — threads whose root is internal.
    • Resolution — threads containing any is_resolution comment.
  8. Internal flag of a reply defaults to the parent's is_internal value in the inline composer.
  9. "Mark as Resolution" is shown only at the top-level composer, not in inline reply composer.
  10. Inbound email resolution (in order; first match wins):
    1. Reply token (email_reply_tokens → derive thread from token's comment_id)
    2. In-Reply-To header → look up in email_sending_logs.rfc_message_id → derive comment_thread_id
    3. References[] chain — walk from end to start
    4. email_provider_thread_id exact match on comment_threads
    5. Ticket-level fallback: existing tickets.email_metadata match → create a new top-level thread on that ticket (preserves today's behavior)
  11. Outbound email:
    • New top-level reply: generate fresh RFC Message-ID, store in comment_threads.email_message_id; no In-Reply-To.
    • In-thread reply: In-Reply-To: <latest outbound RFC Message-ID for this thread>; append same to References.
    • Issue a reply token scoped to the new outbound comment.
    • Persist row in email_sending_logs with comment_thread_id, rfc_message_id, reply_token_hash.
  12. Project tasks support the same UI threading, no email logic. comment_threads.is_internal = false for tasks (no internal/client distinction in task comments today).
  13. Visual indent depth caps at 4; data has no cap.
  14. Existing reactions, edit, delete, internal/resolution markers continue to work per comment.

Non-functional Requirements

  • Migrations safe on Citus (match patterns used by neighbors in server/migrations/).
  • Backfill is idempotent and chunked so it runs cleanly on large tenants.
  • Thread-level queries hit indexes: (tenant, ticket_id, last_activity_at), (tenant, project_task_id, last_activity_at), (tenant, email_message_id).
  • All comment writes remain inside withTransaction(); reply_count and last_activity_at are maintained in the same transaction.

Data / API / Integrations

Schema

CREATE TABLE comment_threads (
  tenant uuid NOT NULL,
  thread_id uuid NOT NULL DEFAULT gen_random_uuid(),
  ticket_id uuid NULL,
  project_task_id uuid NULL,
  root_comment_id uuid NOT NULL,
  is_internal boolean NOT NULL DEFAULT false,
  reply_count integer NOT NULL DEFAULT 0,
  last_activity_at timestamptz NOT NULL DEFAULT now(),
  email_message_id text NULL,
  email_references text[] NOT NULL DEFAULT '{}',
  email_provider_thread_id text NULL,
  created_at timestamptz NOT NULL DEFAULT now(),
  created_by uuid NULL,
  PRIMARY KEY (tenant, thread_id),
  CHECK ((ticket_id IS NOT NULL)::int + (project_task_id IS NOT NULL)::int = 1),
  FOREIGN KEY (tenant, ticket_id) REFERENCES tickets (tenant, ticket_id) ON DELETE CASCADE,
  FOREIGN KEY (tenant, project_task_id) REFERENCES project_tasks (tenant, task_id) ON DELETE CASCADE
);
CREATE INDEX comment_threads_ticket_idx ON comment_threads (tenant, ticket_id, last_activity_at DESC);
CREATE INDEX comment_threads_task_idx ON comment_threads (tenant, project_task_id, last_activity_at DESC);
CREATE INDEX comment_threads_email_msgid_idx ON comment_threads (tenant, email_message_id) WHERE email_message_id IS NOT NULL;

comments adds:

  • thread_id uuid NOT NULL (after backfill)
  • parent_comment_id uuid NULL
  • FK (tenant, thread_id) → comment_threads
  • FK (tenant, parent_comment_id) → comments (self-FK)
  • deleted_at timestamptz NULL (for soft-delete of roots-with-children)

project_task_comments adds the same set.

email_sending_logs (already has thread_id — rename to email_provider_thread_id for clarity if safe; add new column comment_thread_id uuid NULLcomment_threads.thread_id).

TypeScript Interfaces

  • New: ICommentThread in packages/types/src/interfaces/commentThread.interface.ts.
  • Extend IComment and IProjectTaskComment with thread_id, parent_comment_id, deleted_at.

Server Actions (signatures)

createComment(comment: IComment & { parent_comment_id?: string }) — resolves thread, increments counters, publishes event with thread_id + parent_comment_id in payload. Same for createTaskComment.

Security / Permissions

  • Reply visibility inherits root: a reply on a client-visible root cannot have is_internal = true at the data layer (model rejects). UI inherits parent's flag as default but allows toggling within rules. Conversely a reply on an internal root must be internal.
  • Project-task assertOwnCommentOrInternalUser rule applies to replies (client can edit/delete own replies; internal users can do anything).
  • Inbound-email-derived comments inherit the thread's is_internal flag (clients can only reply to client-visible threads).

Observability

Out of scope per project conventions for this PR (no new metrics/logging beyond what the existing comment system already does).

Rollout / Migration

  1. Ship migrations behind a single deploy.
  2. Backfill runs as part of the migration sequence. Chunked over comments and project_task_comments.
  3. NOT NULL enforcement migration runs after backfill (separate migration file for safer rollout).
  4. No feature flag — the UI change is rendered for everyone post-deploy. Existing comments render as single-comment threads (no thread bar, no visual change).

Open Questions

  • Exact name to use for the existing email_sending_logs.thread_id (which today refers to provider thread id). Decide on rename vs. add new column during implementation.
  • Whether comment_threads.email_references should be capped in length (long mail clients can produce dozens of References entries).

Acceptance Criteria (Definition of Done)

  • All migrations apply cleanly on a fresh DB and on a copy of a production-shape dataset.
  • Existing tickets and project tasks render identically to today (single-comment threads, no thread bar).
  • Replying to a comment in a ticket produces an indented child + thread bar; collapse / expand / open-in-drawer all work.
  • Sub-thread (reply to a reply) renders dashed-bordered sub-thread bar.
  • Tab counts at the thread granularity match the rules in Functional Requirements §7.
  • Inbound email lands in the right thread for each of the 5 resolution paths.
  • Outbound email from a thread carries correct In-Reply-To and References headers.
  • Mirror behaviors verified on project tasks (excluding email).
  • Unit + integration + Playwright e2e tests in tests.json pass.