Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
5.0 KiB
Ticket Activity Conventions
Author: Robert Isaacs · Date: 2026-05-25
This document captures the conventions any new ticket mutation path must follow to participate in the unified ticket activity timeline. It complements PRD.md.
Where to write activity rows
Use the shared helper:
import {
TICKET_ACTIVITY_ACTOR,
TICKET_ACTIVITY_ENTITY,
TICKET_ACTIVITY_EVENT,
TICKET_ACTIVITY_SOURCE,
writeTicketActivity,
} from '@alga-psa/shared/lib/ticketActivity';
Write inside the same transaction as the underlying ticket/comment/document mutation. This keeps activity rows atomically consistent with the data they describe.
await writeTicketActivity(trx, {
tenant,
ticketId,
eventType: TICKET_ACTIVITY_EVENT.STATUS_CHANGED,
entityType: TICKET_ACTIVITY_ENTITY.TICKET,
entityId: ticketId,
actor: { actorType: TICKET_ACTIVITY_ACTOR.USER, userId: user.user_id },
source: TICKET_ACTIVITY_SOURCE.UI,
changes: curatedDiff,
details: { ...optionalMetadata },
});
Explicit tenant — required
writeTicketActivity never reads app.current_tenant. This is intentional so it works inside withAdminTransaction() paths (inbound email, workflow runner) which do not set the GUC.
If you are tempted to "let the helper figure out tenant," stop and pass it explicitly.
Field diffs — use the curated helper
For ticket updates, use buildCuratedTicketDiffWithLabels (or buildCuratedTicketDiff if you already have labels resolved). The helper:
- Returns only the user-meaningful fields listed in
CURATED_TICKET_FIELDS. - Skips no-op updates (deep-equal old and new).
- Optionally attaches
oldLabel/newLabelresolved against the relevant lookup table.
If no curated fields changed, the helper returns {}. Use hasCuratedChanges() and skip the activity write — the UI does not want noise from updates that only touched updated_at or internal denormalized flags.
If you need to log a new field, add it to CURATED_TICKET_FIELDS (in shared/lib/ticketActivity/types.ts). Do not bypass the helper.
Safe metadata rules
details and changes are JSONB. They are read by the timeline UI and may be returned through internal APIs. They must NOT contain:
- Raw inbound email bodies (text/html). Store only
messageId,threadId,from,subject,provider,receivedAt. - Full old/new comment body content for edits. Store only edit-metadata (e.g.,
{ edited: true, is_internal: false }). - Secrets, API tokens, or PII not already visible on the ticket.
Comment edits are metadata-only by design. If a future feature needs body history, it should live in a separate body-snapshot table, not in ticket_audit_logs.
Event-type vocabulary
Use the constants in TICKET_ACTIVITY_EVENT. Names mirror the existing domain events in packages/event-schemas/src/schemas/domain/ticketEventSchemas.ts where one exists. If you need a new event, add it to the constant set rather than inventing strings at call sites; the UI formatter dispatches on these names.
When picking the event for a curated-diff update, prefer the most specific match:
- single status_id change →
TICKET_STATUS_CHANGED(orTICKET_CLOSED/TICKET_REOPENEDwhen transitioning theis_closedboundary) - single priority_id →
TICKET_PRIORITY_CHANGED - single assigned_to →
TICKET_ASSIGNED/TICKET_UNASSIGNED - single board_id →
TICKET_BOARD_MOVED - single response_state →
TICKET_RESPONSE_STATE_CHANGED - multiple curated fields →
TICKET_UPDATED
Actor and source
actor.actorTypeshould be the actor classification, not the channel. A request originating from the REST API isAPI; an inbound email parsed to a contact isEMAIL_SENDER; a bundle-master reopen triggered by a child reply isSYSTEM.sourceis the origin channel and is independent of actor type. For example, an inbound-email reply usesactor=EMAIL_SENDERandsource=INBOUND_EMAIL.
Failure semantics
Activity writes are intended to fail fast inside the surrounding transaction (NFR-03). If you need a path to tolerate write failure as best-effort, wrap your call in try/catch and document the reason at the call site. Do not change the helper.
Display-name enrichment failures are best-effort: the helper logs and falls back to the actor's IDs without throwing.
Read paths
readTicketActivity(knex, tenant, ticketId)— activity rows only, ordered newest-first.buildUnifiedTicketTimeline(knex, tenant, ticketId)— activity + comments merged chronologically (internal-only in v1; do NOT call from the client portal).getTicketTimelineEntries(server action) — production entry point used by the MSP UI. Enforces internalticket:readpermission and blocks client portal users.
Generic audit_logs is untouched
The legacy packages/db/src/lib/auditLog.ts (and its server/src/lib/logging/auditLog.ts copy) remain in place and continue to serve RBAC-style logging. Do not consolidate the two in v1. They have different scope, schema, and tenant-context contract than ticket_audit_logs.