PSA/shared/lib/ticketActivity/writeTicketActivity.ts
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

168 lines
5.2 KiB
TypeScript

/**
* Ticket activity helper. Persists rows to `ticket_audit_logs` using an
* explicit tenant (does NOT depend on the `app.current_tenant` GUC) so it
* works inside both normal request transactions and admin transactions used
* by inbound email / workflow paths.
*
* Failure semantics:
* - The write itself fails fast. Callers that want best-effort behavior must
* wrap the call themselves; v1 prefers strong consistency in the same
* transaction as the underlying ticket/comment mutation.
* - Display-name enrichment is optional. Callers may pass a pre-resolved
* `actor.displayName`; if absent, we fall back to a best-effort lookup
* and ignore lookup failures.
*/
import type { Knex } from 'knex';
import { v4 as uuidv4 } from 'uuid';
import {
TICKET_ACTIVITY_ACTOR,
type TicketActivityChanges,
type WriteTicketActivityInput,
} from './types';
function toIso(value: string | Date | undefined): string {
if (!value) return new Date().toISOString();
if (value instanceof Date) return value.toISOString();
return value;
}
function isEmptyChanges(changes: TicketActivityChanges | undefined): boolean {
if (!changes) return true;
return Object.keys(changes).length === 0;
}
async function resolveUserDisplayName(
knex: Knex,
tenant: string,
userId: string,
): Promise<string | null> {
try {
const row = await knex('users')
.where({ tenant, user_id: userId })
.first(['first_name', 'last_name', 'email']);
if (!row) return null;
const first = (row.first_name ?? '').trim();
const last = (row.last_name ?? '').trim();
const full = [first, last].filter(Boolean).join(' ').trim();
return full.length > 0 ? full : (row.email ?? null);
} catch (err) {
// Display-name enrichment is best-effort; never fail the activity write
// because of an enrichment lookup error.
console.warn('[ticketActivity] failed to resolve user display name', {
userId,
tenant,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
async function resolveContactDisplayName(
knex: Knex,
tenant: string,
contactId: string,
): Promise<string | null> {
try {
const row = await knex('contacts')
.where({ tenant, contact_name_id: contactId })
.first(['full_name', 'email']);
if (!row) return null;
const full = (row.full_name ?? '').toString().trim();
return full.length > 0 ? full : (row.email ?? null);
} catch (err) {
console.warn('[ticketActivity] failed to resolve contact display name', {
contactId,
tenant,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
/**
* Insert one ticket activity row.
*
* @param knex Knex instance OR Transaction. Pass the active transaction when
* logging inside a ticket/comment mutation so the activity row is
* rolled back atomically on failure.
* @param input The activity payload. `tenant` and `ticketId` are required;
* this helper never reads `app.current_tenant` and is safe in
* admin transactions.
*/
export async function writeTicketActivity(
knex: Knex | Knex.Transaction,
input: WriteTicketActivityInput,
): Promise<string> {
if (!input.tenant) {
throw new Error('writeTicketActivity requires an explicit tenant');
}
if (!input.ticketId) {
throw new Error('writeTicketActivity requires a ticketId');
}
if (!input.eventType) {
throw new Error('writeTicketActivity requires an eventType');
}
if (!input.entityType) {
throw new Error('writeTicketActivity requires an entityType');
}
if (!input.actor?.actorType) {
throw new Error('writeTicketActivity requires actor.actorType');
}
if (!input.source) {
throw new Error('writeTicketActivity requires a source');
}
const auditId = uuidv4();
const occurredAt = toIso(input.occurredAt);
// Best-effort display name resolution if the caller didn't supply one.
let displayName = input.actor.displayName ?? null;
if (!displayName) {
if (
input.actor.actorType === TICKET_ACTIVITY_ACTOR.USER &&
input.actor.userId
) {
displayName = await resolveUserDisplayName(
knex as Knex,
input.tenant,
input.actor.userId,
);
} else if (
(input.actor.actorType === TICKET_ACTIVITY_ACTOR.CONTACT ||
input.actor.actorType === TICKET_ACTIVITY_ACTOR.EMAIL_SENDER) &&
input.actor.contactId
) {
displayName = await resolveContactDisplayName(
knex as Knex,
input.tenant,
input.actor.contactId,
);
}
}
const changes = isEmptyChanges(input.changes) ? {} : input.changes ?? {};
const details = input.details ?? {};
await knex('ticket_audit_logs').insert({
tenant: input.tenant,
audit_id: auditId,
ticket_id: input.ticketId,
event_type: input.eventType,
entity_type: input.entityType,
entity_id: input.entityId ?? null,
actor_type: input.actor.actorType,
actor_user_id: input.actor.userId ?? null,
actor_contact_id: input.actor.contactId ?? null,
actor_display_name: displayName,
source: input.source,
occurred_at: occurredAt,
changes: JSON.stringify(changes),
details: JSON.stringify(details),
created_at: new Date().toISOString(),
});
return auditId;
}