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

2170 lines
75 KiB
TypeScript

import { z } from 'zod';
import type { Knex } from 'knex';
import { v4 as uuidv4 } from 'uuid';
import { deleteEntityWithValidation, isEnterprise } from '@alga-psa/core';
import { ensureDefaultContractForClientIfBillingConfigured } from '../../../../billingClients/defaultContract';
import { getActionRegistryV2 } from '../../registries/actionRegistry';
import { withWorkflowJsonSchemaMetadata } from '../../jsonSchemaMetadata';
import { ClientModel } from '../../../../models/clientModel';
import { buildClientArchivedPayload, buildClientCreatedPayload, buildClientUpdatedPayload } from '../../../streams/domainEventBuilders/clientEventBuilders';
import { buildInteractionLoggedPayload, buildNoteCreatedPayload } from '../../../streams/domainEventBuilders/crmInteractionNoteEventBuilders';
import {
uuidSchema,
isoDateTimeSchema,
actionProvidedKey,
withTenantTransaction,
requirePermission,
writeRunAudit,
throwActionError,
rethrowAsStandardError,
parseJsonMaybe,
type TenantTxContext,
} from './shared';
const WORKFLOW_PICKER_HINTS = {
client: 'Search clients',
ticket: 'Search tickets',
contact: 'Search contacts',
'client-location': 'Search locations',
} as const;
const withWorkflowPicker = <T extends z.ZodTypeAny>(
schema: T,
description: string,
kind: keyof typeof WORKFLOW_PICKER_HINTS,
dependencies?: string[]
): T =>
withWorkflowJsonSchemaMetadata(schema, description, {
'x-workflow-picker-kind': kind,
'x-workflow-picker-dependencies': dependencies,
'x-workflow-picker-fixed-value-hint': WORKFLOW_PICKER_HINTS[kind],
'x-workflow-picker-allow-dynamic-reference': true,
});
const CLIENT_TABLE_ALLOWED_FIELDS = new Set([
'client_name',
'client_type',
'url',
'billing_email',
'notes',
'properties',
'default_currency_code',
'parent_client_id',
'contract_line_id',
'is_default',
'is_inactive',
'region_code',
'tax_id_number',
'is_tax_exempt',
'tax_exemption_certificate',
'payment_terms',
'preferred_payment_method',
'auto_invoice',
'invoice_delivery_method',
'billing_cycle',
'timezone',
'account_manager_id',
'billing_contact_id',
]);
const LOCATION_TABLE_ALLOWED_FIELDS = new Set([
'location_name',
'address_line1',
'address_line2',
'city',
'state_province',
'postal_code',
'country_code',
'country_name',
'phone',
'email',
'is_default',
'is_billing_address',
'is_shipping_address',
'is_active',
]);
const clientSummarySchema = z.object({
client_id: uuidSchema,
client_name: z.string(),
client_type: z.string().nullable().optional(),
url: z.string().nullable(),
billing_email: z.string().nullable().optional(),
is_inactive: z.boolean(),
properties: z.record(z.unknown()).nullable(),
updated_at: isoDateTimeSchema.optional(),
});
const contactSummarySchema = z.object({
contact_name_id: uuidSchema,
full_name: z.string().nullable(),
email: z.string().nullable(),
phone: z.string().nullable(),
client_id: uuidSchema.nullable(),
});
const tagResultSchema = z.object({
tag_id: uuidSchema,
tag_text: z.string(),
mapping_id: uuidSchema.optional(),
});
const nullableUuidSchema = z.union([uuidSchema, z.null()]);
type ClientRow = Record<string, any> & { client_id: string };
type ContractRow = { contract_id: string };
type ClientContractAssignmentRow = { client_contract_id: string; contract_id: string };
const clientCreateInputSchema = z.object({
client_name: z.string().min(1),
client_type: z.enum(['company', 'individual']).optional(),
url: z.string().url().optional(),
phone_no: z.string().optional(),
email: z.string().email().optional(),
address: z.string().optional(),
address_2: z.string().optional(),
city: z.string().optional(),
state: z.string().optional(),
zip: z.string().optional(),
country: z.string().optional(),
default_currency_code: z.string().optional(),
notes: z.string().optional(),
properties: z.record(z.unknown()).optional(),
parent_client_id: nullableUuidSchema.optional(),
contract_line_id: nullableUuidSchema.optional(),
is_default: z.boolean().optional(),
tags: z.array(z.string()).optional(),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
});
const clientUpdatePatchSchema = z
.object({
client_name: z.string().min(1).optional(),
client_type: z.enum(['company', 'individual']).nullable().optional(),
url: z.string().url().nullable().optional(),
phone_no: z.string().nullable().optional(),
email: z.string().email().nullable().optional(),
address: z.string().nullable().optional(),
address_2: z.string().nullable().optional(),
city: z.string().nullable().optional(),
state: z.string().nullable().optional(),
zip: z.string().nullable().optional(),
country: z.string().nullable().optional(),
default_currency_code: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
properties: z.record(z.unknown()).nullable().optional(),
parent_client_id: nullableUuidSchema.optional(),
contract_line_id: nullableUuidSchema.optional(),
is_default: z.boolean().nullable().optional(),
is_inactive: z.boolean().optional(),
})
.superRefine((value, refinementCtx) => {
const keys = Object.keys(value);
if (keys.length === 0) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
message: 'patch must include at least one editable field',
});
return;
}
const hasDefinedValue = keys.some((key) => (value as Record<string, unknown>)[key] !== undefined);
if (!hasDefinedValue) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
message: 'patch must include at least one defined value',
});
}
});
const normalizeTagText = (value: string): string => value.trim();
const uniqueNormalizedTags = (tags: string[] | undefined): string[] => {
if (!Array.isArray(tags)) return [];
const seen = new Set<string>();
const out: string[] = [];
for (const value of tags) {
const normalized = normalizeTagText(String(value));
if (!normalized) continue;
const lower = normalized.toLowerCase();
if (seen.has(lower)) continue;
seen.add(lower);
out.push(normalized);
}
return out;
};
const generateTagColors = (text: string): { backgroundColor: string; textColor: string } => {
let hash = 0;
for (let i = 0; i < text.length; i += 1) {
hash = text.charCodeAt(i) + ((hash << 5) - hash);
}
const hue = Math.abs(hash) % 360;
const saturation = 70;
const lightness = 85;
const hslToHex = (h: number, s: number, l: number): string => {
const normalizedLightness = l / 100;
const a = (s * Math.min(normalizedLightness, 1 - normalizedLightness)) / 100;
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = normalizedLightness - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
};
return {
backgroundColor: hslToHex(hue, saturation, lightness),
textColor: '#2C3E50',
};
};
const parseClientProperties = (value: unknown): Record<string, unknown> | null => {
const parsed = parseJsonMaybe(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
return parsed as Record<string, unknown>;
};
const clientToSummary = (row: Record<string, unknown>) =>
clientSummarySchema.parse({
client_id: row.client_id,
client_name: row.client_name,
client_type: (row.client_type as string | null | undefined) ?? null,
url: (row.url as string | null | undefined) ?? null,
billing_email: (row.billing_email as string | null | undefined) ?? null,
is_inactive: Boolean(row.is_inactive),
properties: parseClientProperties(row.properties),
updated_at:
typeof row.updated_at === 'string'
? row.updated_at
: row.updated_at instanceof Date
? row.updated_at.toISOString()
: undefined,
});
async function ensureClientExists(ctx: any, tx: TenantTxContext, clientId: string): Promise<ClientRow> {
const client = await tx.trx('clients').where({ tenant: tx.tenantId, client_id: clientId }).first();
if (!client) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Client not found',
details: { client_id: clientId },
});
}
return client as ClientRow;
}
async function ensureTicketExists(ctx: any, tx: TenantTxContext, ticketId: string): Promise<Record<string, any>> {
const ticket = await tx.trx('tickets').where({ tenant: tx.tenantId, ticket_id: ticketId }).first();
if (!ticket) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Ticket not found',
details: { ticket_id: ticketId },
});
}
return ticket;
}
async function getTableColumns(tx: TenantTxContext, tableName: string): Promise<Set<string>> {
const rows = await tx.trx('information_schema.columns')
.select('column_name')
.where({ table_schema: 'public', table_name: tableName });
return new Set(rows.map((row: { column_name: string }) => row.column_name));
}
function pickExistingFields(
data: Record<string, unknown>,
availableColumns: Set<string>,
allowedFields: Set<string>
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data)) {
if (!allowedFields.has(key)) continue;
if (!availableColumns.has(key)) continue;
if (value === undefined) continue;
out[key] = value;
}
return out;
}
async function deactivateClientUsersForClient(tx: TenantTxContext, clientId: string): Promise<void> {
const contacts = await tx.trx('contacts')
.where({ tenant: tx.tenantId, client_id: clientId })
.select('contact_name_id');
await tx.trx('contacts').where({ tenant: tx.tenantId, client_id: clientId }).update({ is_inactive: true });
const contactIds = contacts
.map((row: { contact_name_id?: string | null }) => row.contact_name_id)
.filter((value: string | null | undefined): value is string => Boolean(value));
if (contactIds.length > 0) {
await tx.trx('users')
.where({ tenant: tx.tenantId, user_type: 'client' })
.whereIn('contact_id', contactIds)
.update({ is_inactive: true });
}
}
async function upsertDefaultClientLocation(
tx: TenantTxContext,
clientId: string,
input: {
address?: string | null;
address_2?: string | null;
city?: string | null;
state?: string | null;
zip?: string | null;
country?: string | null;
email?: string | null;
phone_no?: string | null;
},
options: { createIfMissing: boolean }
): Promise<void> {
const locationColumns = await getTableColumns(tx, 'client_locations');
const locationPatch = pickExistingFields(
{
address_line1: input.address,
address_line2: input.address_2,
city: input.city,
state_province: input.state,
postal_code: input.zip,
country_name: input.country,
email: input.email,
phone: input.phone_no,
is_default: true,
is_billing_address: true,
is_shipping_address: true,
is_active: true,
updated_at: new Date().toISOString(),
},
locationColumns,
new Set([...LOCATION_TABLE_ALLOWED_FIELDS, 'updated_at'])
);
const hasAnyValue =
input.address !== undefined ||
input.address_2 !== undefined ||
input.city !== undefined ||
input.state !== undefined ||
input.zip !== undefined ||
input.country !== undefined ||
input.email !== undefined ||
input.phone_no !== undefined;
if (!hasAnyValue) return;
let location = await tx.trx('client_locations')
.where({ tenant: tx.tenantId, client_id: clientId, is_default: true })
.first();
if (!location) {
if (!options.createIfMissing) {
return;
}
const insertRow = pickExistingFields(
{
location_id: uuidv4(),
tenant: tx.tenantId,
client_id: clientId,
location_name: 'Main Office',
address_line1: input.address ?? '',
address_line2: input.address_2 ?? null,
city: input.city ?? '',
state_province: input.state ?? null,
postal_code: input.zip ?? null,
country_code: input.country ? String(input.country).slice(0, 2).toUpperCase() : 'US',
country_name: input.country ?? 'United States',
phone: input.phone_no ?? null,
email: input.email ?? null,
is_default: true,
is_billing_address: true,
is_shipping_address: true,
is_active: true,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
},
locationColumns,
new Set([...LOCATION_TABLE_ALLOWED_FIELDS, 'location_id', 'tenant', 'client_id', 'created_at', 'updated_at'])
);
if (Object.keys(insertRow).length > 0) {
await tx.trx('client_locations').insert(insertRow);
}
return;
}
if (Object.keys(locationPatch).length > 0) {
await tx.trx('client_locations')
.where({ tenant: tx.tenantId, client_id: clientId, location_id: location.location_id })
.update(locationPatch);
}
}
async function ensureClientTagMappings(
tx: TenantTxContext,
clientId: string,
tags: string[]
): Promise<{
added: Array<z.infer<typeof tagResultSchema>>;
existing: Array<z.infer<typeof tagResultSchema>>;
}> {
const normalizedTags = uniqueNormalizedTags(tags);
if (normalizedTags.length === 0) {
return { added: [], existing: [] };
}
const added: Array<z.infer<typeof tagResultSchema>> = [];
const existing: Array<z.infer<typeof tagResultSchema>> = [];
const tagDefinitionColumns = await getTableColumns(tx, 'tag_definitions');
const tagMappingColumns = await getTableColumns(tx, 'tag_mappings');
for (const tagText of normalizedTags) {
const { backgroundColor, textColor } = generateTagColors(tagText);
const definitionRow = pickExistingFields(
{
tenant: tx.tenantId,
tag_id: uuidv4(),
tag_text: tagText,
tagged_type: 'client',
background_color: backgroundColor,
text_color: textColor,
created_at: new Date().toISOString(),
},
tagDefinitionColumns,
new Set([
'tenant',
'tag_id',
'tag_text',
'tagged_type',
'background_color',
'text_color',
'created_at',
])
);
await tx.trx('tag_definitions')
.insert(definitionRow)
.onConflict(['tenant', 'tag_text', 'tagged_type'])
.ignore();
const definition = await tx.trx('tag_definitions')
.where({
tenant: tx.tenantId,
tag_text: tagText,
tagged_type: 'client',
})
.first();
if (!definition?.tag_id) {
throw new Error(`Failed to resolve tag definition for \"${tagText}\"`);
}
const mappingId = uuidv4();
const mappingRow = pickExistingFields(
{
tenant: tx.tenantId,
mapping_id: mappingId,
tag_id: definition.tag_id,
tagged_id: clientId,
tagged_type: 'client',
created_by: tx.actorUserId,
created_at: new Date().toISOString(),
},
tagMappingColumns,
new Set(['tenant', 'mapping_id', 'tag_id', 'tagged_id', 'tagged_type', 'created_by', 'created_at'])
);
const insertedMappings = await tx.trx('tag_mappings')
.insert(mappingRow)
.onConflict(['tenant', 'tag_id', 'tagged_id'])
.ignore()
.returning('mapping_id');
if (insertedMappings.length > 0) {
added.push(
tagResultSchema.parse({
tag_id: definition.tag_id,
tag_text: definition.tag_text,
mapping_id: typeof mappingRow.mapping_id === 'string' ? mappingRow.mapping_id : undefined,
})
);
continue;
}
const mapping = await tx.trx('tag_mappings')
.where({
tenant: tx.tenantId,
tag_id: definition.tag_id,
tagged_id: clientId,
tagged_type: 'client',
})
.first();
existing.push(
tagResultSchema.parse({
tag_id: definition.tag_id,
tag_text: definition.tag_text,
mapping_id: typeof mapping?.mapping_id === 'string' ? mapping.mapping_id : undefined,
})
);
}
return { added, existing };
}
async function deleteFromTableIfExists(
trx: Knex.Transaction,
tableName: string,
where: Record<string, unknown>
): Promise<void> {
const exists = await trx.schema.hasTable(tableName);
if (!exists) return;
await trx(tableName).where(where).delete();
}
async function getExistingPublicTables(
trx: Knex.Transaction,
tableNames: string[]
): Promise<Set<string>> {
const rows = await trx('information_schema.tables')
.select('table_name')
.where({ table_schema: 'public' })
.whereIn('table_name', tableNames);
return new Set((rows as Array<{ table_name: string }>).map((row) => row.table_name));
}
async function cleanupDefaultContractsForDeletedClient(
trx: Knex.Transaction,
tenant: string,
clientId: string
): Promise<void> {
const existingTables = await getExistingPublicTables(trx, ['contracts', 'client_contracts', 'invoice_charges']);
if (!existingTables.has('contracts') || !existingTables.has('client_contracts')) {
return;
}
const defaultContracts = await trx('contracts')
.where({
tenant,
owner_client_id: clientId,
is_system_managed_default: true,
})
.select('contract_id') as ContractRow[];
const assignmentsForClient = await trx('client_contracts')
.where({ tenant, client_id: clientId })
.select('client_contract_id', 'contract_id') as ClientContractAssignmentRow[];
const assignmentsById = new Map<string, string>();
for (const assignment of assignmentsForClient) {
assignmentsById.set(assignment.client_contract_id, assignment.contract_id);
}
const invoicedDefaultContractIds = new Set<string>();
if (assignmentsById.size > 0 && existingTables.has('invoice_charges')) {
const invoiceRows = await trx('invoice_charges')
.where({ tenant })
.whereIn('client_contract_id', [...assignmentsById.keys()])
.distinct('client_contract_id') as Array<{ client_contract_id: string }>;
for (const row of invoiceRows) {
const contractId = assignmentsById.get(row.client_contract_id);
if (contractId) {
invoicedDefaultContractIds.add(contractId);
}
}
}
await trx('client_contracts')
.where({ tenant, client_id: clientId })
.delete();
for (const contract of defaultContracts) {
const countRow = await trx('client_contracts')
.where({ tenant, contract_id: contract.contract_id })
.count<{ count?: string }>('client_contract_id as count')
.first();
const assignmentCount = Number(countRow?.count ?? 0);
if (assignmentCount > 0) {
continue;
}
if (invoicedDefaultContractIds.has(contract.contract_id)) {
await trx('contracts')
.where({ tenant, contract_id: contract.contract_id })
.update({
status: 'archived',
is_active: false,
updated_at: trx.fn.now(),
});
} else {
await trx('contracts')
.where({ tenant, contract_id: contract.contract_id })
.delete();
}
}
}
async function cleanupClientDeleteArtifacts(
trx: Knex.Transaction,
tenant: string,
clientId: string
): Promise<void> {
await cleanupDefaultContractsForDeletedClient(trx, tenant, clientId);
await deleteFromTableIfExists(trx, 'client_billing_settings', { tenant, client_id: clientId });
await deleteFromTableIfExists(trx, 'client_billing_cycles', { tenant, client_id: clientId });
await deleteFromTableIfExists(trx, 'client_tax_settings', { tenant, client_id: clientId });
await deleteFromTableIfExists(trx, 'client_tax_rates', { tenant, client_id: clientId });
await deleteFromTableIfExists(trx, 'client_locations', { tenant, client_id: clientId });
await deleteFromTableIfExists(trx, 'client_payment_customers', { tenant, client_id: clientId });
await deleteFromTableIfExists(trx, 'tag_mappings', {
tenant,
tagged_type: 'client',
tagged_id: clientId,
});
}
async function cleanupClientNotesDocument(
trx: Knex.Transaction,
tenant: string,
clientId: string
): Promise<void> {
const clientRecord = await trx('clients')
.where({ client_id: clientId, tenant })
.select('notes_document_id')
.first();
if (!clientRecord?.notes_document_id) {
return;
}
await deleteFromTableIfExists(trx, 'document_block_content', {
tenant,
document_id: clientRecord.notes_document_id,
});
await deleteFromTableIfExists(trx, 'document_associations', {
tenant,
document_id: clientRecord.notes_document_id,
});
await deleteFromTableIfExists(trx, 'documents', {
tenant,
document_id: clientRecord.notes_document_id,
});
}
async function cleanupEntraReferencesBeforeClientDelete(
trx: Knex.Transaction,
tenantId: string,
clientId: string
): Promise<void> {
if (!isEnterprise) {
return;
}
const tableNames = [
'entra_sync_run_tenants',
'entra_contact_links',
'entra_contact_reconciliation_queue',
'entra_client_tenant_mappings',
];
const existingTables = await getExistingPublicTables(trx, tableNames);
if (existingTables.size === 0) {
return;
}
const now = trx.fn.now();
if (existingTables.has('entra_sync_run_tenants')) {
await trx('entra_sync_run_tenants')
.where({ tenant: tenantId, client_id: clientId })
.update({ client_id: null, updated_at: now });
}
if (existingTables.has('entra_contact_links')) {
await trx('entra_contact_links')
.where({ tenant: tenantId, client_id: clientId })
.update({ client_id: null, updated_at: now });
}
if (existingTables.has('entra_contact_reconciliation_queue')) {
await trx('entra_contact_reconciliation_queue')
.where({ tenant: tenantId, client_id: clientId })
.update({ client_id: null, updated_at: now });
}
if (existingTables.has('entra_client_tenant_mappings')) {
const activeMappings = await trx('entra_client_tenant_mappings')
.where({
tenant: tenantId,
client_id: clientId,
is_active: true,
})
.select('managed_tenant_id') as Array<{ managed_tenant_id: string }>;
if (activeMappings.length > 0) {
await trx('entra_client_tenant_mappings')
.where({
tenant: tenantId,
client_id: clientId,
is_active: true,
})
.update({
is_active: false,
updated_at: now,
});
const unmappedRows = activeMappings.map((mapping) => ({
tenant: tenantId,
managed_tenant_id: mapping.managed_tenant_id,
client_id: null,
mapping_state: 'unmapped',
confidence_score: null,
is_active: true,
decided_by: null,
decided_at: now,
created_at: now,
updated_at: now,
}));
await trx('entra_client_tenant_mappings').insert(unmappedRows);
}
await trx('entra_client_tenant_mappings')
.where({ tenant: tenantId, client_id: clientId })
.update({ client_id: null, updated_at: now });
}
}
async function getDefaultInteractionStatusId(ctx: any, tx: TenantTxContext): Promise<string> {
const status = await tx.trx('statuses')
.where({
tenant: tx.tenantId,
status_type: 'interaction',
is_default: true,
})
.first();
if (!status?.status_id) {
throwActionError(ctx, {
category: 'ActionError',
code: 'INTERNAL_ERROR',
message: 'No default interaction status found',
});
}
return status.status_id;
}
async function appendClientNoteBlock(
tx: TenantTxContext,
client: Record<string, any>,
body: string
): Promise<{ document_id: string; created_document: boolean; updated_at: string }> {
const nowIso = new Date().toISOString();
const contentBlock = {
type: 'paragraph',
content: [{ type: 'text', text: body }],
};
if (client.notes_document_id) {
const existing = await tx.trx('document_block_content')
.where({ tenant: tx.tenantId, document_id: client.notes_document_id })
.first();
const existingBlocks = Array.isArray(existing?.block_data)
? existing.block_data
: typeof existing?.block_data === 'string'
? (() => {
try {
return JSON.parse(existing.block_data);
} catch {
return [];
}
})()
: [];
const nextBlocks = [...(Array.isArray(existingBlocks) ? existingBlocks : []), contentBlock];
if (existing) {
await tx.trx('document_block_content')
.where({ tenant: tx.tenantId, document_id: client.notes_document_id })
.update({ block_data: JSON.stringify(nextBlocks), updated_at: nowIso });
} else {
await tx.trx('document_block_content').insert({
content_id: uuidv4(),
tenant: tx.tenantId,
document_id: client.notes_document_id,
block_data: JSON.stringify(nextBlocks),
created_at: nowIso,
updated_at: nowIso,
});
}
await tx.trx('documents')
.where({ tenant: tx.tenantId, document_id: client.notes_document_id })
.update({ updated_at: nowIso, edited_by: tx.actorUserId });
return {
document_id: client.notes_document_id,
created_document: false,
updated_at: nowIso,
};
}
const documentId = uuidv4();
const documentType = await tx.trx('document_types')
.where({ tenant: tx.tenantId })
.orderBy('type_name', 'asc')
.first();
const documentInsert: Record<string, unknown> = {
tenant: tx.tenantId,
document_id: documentId,
document_name: `${client.client_name ?? 'Client'} Notes`,
created_by: tx.actorUserId,
user_id: tx.actorUserId,
updated_at: nowIso,
entered_at: nowIso,
};
const documentColumns = await getTableColumns(tx, 'documents');
if (documentColumns.has('type_id')) {
documentInsert.type_id = documentType?.type_id ?? null;
}
if (documentColumns.has('shared_type_id')) {
documentInsert.shared_type_id = null;
}
await tx.trx('documents').insert(documentInsert);
await tx.trx('document_block_content').insert({
content_id: uuidv4(),
tenant: tx.tenantId,
document_id: documentId,
block_data: JSON.stringify([contentBlock]),
created_at: nowIso,
updated_at: nowIso,
});
await tx.trx('document_associations')
.insert({
association_id: uuidv4(),
tenant: tx.tenantId,
document_id: documentId,
entity_id: client.client_id,
entity_type: 'client',
created_at: nowIso,
})
.onConflict(['tenant', 'document_id', 'entity_id', 'entity_type'])
.ignore();
await tx.trx('clients')
.where({ tenant: tx.tenantId, client_id: client.client_id })
.update({ notes_document_id: documentId, updated_at: nowIso });
return {
document_id: documentId,
created_document: true,
updated_at: nowIso,
};
}
const maybeWorkflowActor = (userId: string): { actorType: 'USER'; actorUserId: string } =>
({ actorType: 'USER', actorUserId: userId });
async function publishWorkflowDomainEvent(params: {
eventType: string;
payload: Record<string, unknown>;
tenantId: string;
occurredAt: string;
actorUserId: string;
idempotencyKey: string;
}): Promise<void> {
try {
const publishers = (await import('@alga-psa/event-bus/publishers')) as unknown as {
publishWorkflowEvent?: (value: {
eventType: string;
payload: Record<string, unknown>;
ctx: {
tenantId: string;
occurredAt: string;
actor: { actorType: 'USER'; actorUserId: string };
};
idempotencyKey: string;
}) => Promise<unknown>;
};
if (!publishers.publishWorkflowEvent) return;
await publishers.publishWorkflowEvent({
eventType: params.eventType,
payload: params.payload,
ctx: {
tenantId: params.tenantId,
occurredAt: params.occurredAt,
actor: maybeWorkflowActor(params.actorUserId),
},
idempotencyKey: params.idempotencyKey,
});
} catch {
// Best-effort publication; action persistence/audit remains source of truth.
}
}
export function registerClientActions(): void {
const registry = getActionRegistryV2();
// ---------------------------------------------------------------------------
// A09 — clients.find
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.find',
version: 1,
inputSchema: z
.object({
client_id: withWorkflowPicker(uuidSchema.optional(), 'Client id', 'client'),
name: z.string().optional().describe('Exact client name (case-insensitive)'),
external_ref: z.string().optional().describe('External reference (stored in clients.properties.external_ref)'),
include_primary_contact: z.boolean().default(false),
on_not_found: z.enum(['return_null', 'error']).default('return_null'),
})
.refine((val) => Boolean(val.client_id || val.name || val.external_ref), { message: 'client_id, name, or external_ref required' })
.refine((val) => !val.external_ref || /^[A-Za-z0-9._:-]+$/.test(String(val.external_ref)), { message: 'external_ref has invalid format' }),
outputSchema: z.object({
client: clientSummarySchema.nullable(),
primary_contact: contactSummarySchema.nullable(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Find Client', category: 'Business Operations', description: 'Find a client by id, name, or external ref' },
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'read' });
const startedAt = Date.now();
let client: any = null;
let matchedBy: 'client_id' | 'name' | 'external_ref' | null = null;
if (input.client_id) {
client = await ClientModel.getClientById(input.client_id, tx.tenantId, tx.trx);
matchedBy = 'client_id';
} else if (input.name) {
const name = String(input.name).trim();
client = await tx.trx('clients')
.where({ tenant: tx.tenantId })
.andWhereRaw('lower(client_name) = ?', [name.toLowerCase()])
.first();
matchedBy = 'name';
} else if (input.external_ref) {
client = await tx.trx('clients')
.where({ tenant: tx.tenantId })
.andWhereRaw(`(properties->>'external_ref') = ?`, [input.external_ref])
.first();
matchedBy = 'external_ref';
}
if (!client) {
if (input.on_not_found === 'error') {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Client not found',
details: { matched_by: matchedBy },
});
}
return { client: null, primary_contact: null };
}
const parsedClient = clientToSummary(client);
let primaryContact: any = null;
if (input.include_primary_contact) {
primaryContact = await tx.trx('contacts')
.where({ tenant: tx.tenantId, client_id: client.client_id })
.orderBy('is_inactive', 'asc')
.orderBy('created_at', 'asc')
.first();
}
const parsedPrimaryContact = primaryContact
? contactSummarySchema.parse({
contact_name_id: primaryContact.contact_name_id,
full_name: primaryContact.full_name ?? null,
email: primaryContact.email ?? null,
phone: primaryContact.phone ?? null,
client_id: primaryContact.client_id ?? null,
})
: null;
ctx.logger?.info('workflow_action:clients.find', {
duration_ms: Date.now() - startedAt,
matched_by: matchedBy,
include_primary_contact: input.include_primary_contact,
});
return { client: parsedClient, primary_contact: parsedPrimaryContact };
}),
});
// ---------------------------------------------------------------------------
// A10 — clients.search
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.search',
version: 1,
inputSchema: z.object({
query: z.string().min(1).describe('Search query'),
filters: z
.object({
include_inactive: z.boolean().optional(),
tags: z.array(z.string()).optional(),
sort_by: z.enum(['name', 'updated_at']).optional(),
sort_order: z.enum(['asc', 'desc']).optional(),
})
.optional(),
page: z.number().int().positive().default(1),
page_size: z.number().int().positive().max(100).default(25),
}),
outputSchema: z.object({
clients: z.array(clientSummarySchema),
first_client: clientSummarySchema.nullable(),
page: z.number().int(),
page_size: z.number().int(),
total: z.number().int(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Search Clients', category: 'Business Operations', description: 'Search clients by name' },
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'read' });
const startedAt = Date.now();
const minQueryLen = Number(process.env.WORKFLOW_CLIENT_SEARCH_MIN_QUERY_LEN ?? 2);
const rawQuery = String(input.query ?? '').trim();
if (rawQuery.length < minQueryLen) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: `query must be at least ${minQueryLen} characters`,
});
}
const escaped = rawQuery.replace(/[%_\\]/g, (m) => `\\${m}`);
const pattern = `%${escaped}%`;
const page = input.page ?? 1;
const pageSize = input.page_size ?? 25;
const offset = (page - 1) * pageSize;
const filters = input.filters ?? {};
let base = tx.trx('clients').where({ tenant: tx.tenantId });
if (!filters.include_inactive) {
base = base.where(function onlyActive() {
this.where('is_inactive', false).orWhereNull('is_inactive');
});
}
base = base.andWhereRaw(`client_name ILIKE ? ESCAPE '\\\\'`, [pattern]);
if (filters.tags?.length) {
base = base
.join('tag_mappings as tm', function joinTagMappings() {
this.on('tm.tenant', 'clients.tenant').andOn('tm.tagged_id', 'clients.client_id');
})
.join('tag_definitions as td', function joinTagDefs() {
this.on('td.tenant', 'tm.tenant').andOn('td.tag_id', 'tm.tag_id');
})
.where('tm.tagged_type', 'client')
.whereIn('td.tag_text', filters.tags);
}
const countRow = await base.clone().clearSelect().clearOrder().countDistinct({ count: 'clients.client_id' }).first();
const total = parseInt(String((countRow as any)?.count ?? 0), 10);
const sortBy = filters.sort_by ?? 'name';
const sortOrder = filters.sort_order ?? 'asc';
const clients = await base
.clone()
.clearSelect()
.select('clients.*')
.orderBy(sortBy === 'updated_at' ? 'clients.updated_at' : 'clients.client_name', sortOrder)
.orderBy('clients.client_id', 'asc')
.limit(pageSize)
.offset(offset);
const parsedClients = clients.map((row: any) => clientToSummary(row));
ctx.logger?.info('workflow_action:clients.search', {
duration_ms: Date.now() - startedAt,
query_len: rawQuery.length,
filters: {
include_inactive: Boolean(filters.include_inactive),
tags_count: Array.isArray(filters.tags) ? filters.tags.length : 0,
sort_by: sortBy,
sort_order: sortOrder,
},
result_count: parsedClients.length,
page,
page_size: pageSize,
total,
});
return { clients: parsedClients, first_client: parsedClients[0] ?? null, page, page_size: pageSize, total };
}),
});
// ---------------------------------------------------------------------------
// A11 — clients.create
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.create',
version: 1,
inputSchema: clientCreateInputSchema,
outputSchema: z.object({
client: clientSummarySchema,
tags: z.array(tagResultSchema),
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Create Client',
category: 'Business Operations',
description: 'Create a client and optionally attach initial tags',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'create' });
if (input.parent_client_id) {
await ensureClientExists(ctx, tx, input.parent_client_id);
}
const clientColumns = await getTableColumns(tx, 'clients');
const createdId = uuidv4();
const nowIso = new Date().toISOString();
const createRow = pickExistingFields(
{
tenant: tx.tenantId,
client_id: createdId,
client_name: input.client_name,
client_type: input.client_type ?? 'company',
url: input.url ?? null,
billing_email: input.email ?? null,
notes: input.notes ?? null,
properties: input.properties ? JSON.stringify(input.properties) : null,
default_currency_code: input.default_currency_code ?? 'USD',
parent_client_id: input.parent_client_id ?? null,
contract_line_id: input.contract_line_id ?? null,
is_default: input.is_default ?? false,
is_inactive: false,
created_at: nowIso,
updated_at: nowIso,
},
clientColumns,
new Set([...CLIENT_TABLE_ALLOWED_FIELDS, 'tenant', 'client_id', 'created_at', 'updated_at'])
);
try {
await tx.trx('clients').insert(createRow);
} catch (error) {
rethrowAsStandardError(ctx, error);
}
try {
await ensureDefaultContractForClientIfBillingConfigured(tx.trx, {
tenant: tx.tenantId,
clientId: createdId,
});
} catch {
// Best-effort parity with product behavior.
}
const directPatch = pickExistingFields(
{
default_currency_code: input.default_currency_code,
parent_client_id: input.parent_client_id,
contract_line_id: input.contract_line_id,
is_default: input.is_default,
updated_at: new Date().toISOString(),
},
clientColumns,
new Set([...CLIENT_TABLE_ALLOWED_FIELDS, 'updated_at'])
);
if (Object.keys(directPatch).length > 0) {
await tx.trx('clients')
.where({ tenant: tx.tenantId, client_id: createdId })
.update(directPatch);
}
await upsertDefaultClientLocation(
tx,
createdId,
{
address: input.address,
address_2: input.address_2,
city: input.city,
state: input.state,
zip: input.zip,
country: input.country,
email: input.email,
phone_no: input.phone_no,
},
{ createIfMissing: true }
);
const tagResult = await ensureClientTagMappings(tx, createdId, input.tags ?? []);
const after = await ensureClientExists(ctx, tx, createdId);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.create',
changedData: { client_id: createdId, client_name: after.client_name },
details: { action_id: 'clients.create', action_version: 1, client_id: createdId },
});
await publishWorkflowDomainEvent({
eventType: 'CLIENT_CREATED',
payload: buildClientCreatedPayload({
clientId: after.client_id,
clientName: after.client_name,
createdByUserId: tx.actorUserId,
createdAt: (after.created_at as string | undefined) ?? new Date().toISOString(),
status: Boolean(after.is_inactive) ? 'inactive' : 'active',
}),
tenantId: tx.tenantId,
occurredAt: (after.created_at as string | undefined) ?? new Date().toISOString(),
actorUserId: tx.actorUserId,
idempotencyKey: `client_created:${after.client_id}`,
});
return {
client: clientToSummary(after),
tags: [...tagResult.added, ...tagResult.existing],
};
}),
});
// ---------------------------------------------------------------------------
// A12 — clients.update
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.update',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
patch: clientUpdatePatchSchema,
}),
outputSchema: z.object({
client_before: clientSummarySchema,
client_after: clientSummarySchema,
changed_fields: z.array(z.string()),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Edit Client',
category: 'Business Operations',
description: 'Update editable fields on an existing client',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'update' });
const before = await ensureClientExists(ctx, tx, input.client_id);
const patch = input.patch;
const changedFields: string[] = [];
const hasPropertiesPatch = Object.prototype.hasOwnProperty.call(patch, 'properties');
const clientPatchSource: Record<string, unknown> = {
client_name: patch.client_name,
client_type: patch.client_type,
url: patch.url,
billing_email: patch.email,
notes: patch.notes,
default_currency_code: patch.default_currency_code,
parent_client_id: patch.parent_client_id,
contract_line_id: patch.contract_line_id,
is_default: patch.is_default,
is_inactive: patch.is_inactive,
updated_at: new Date().toISOString(),
};
if (hasPropertiesPatch) {
const nextProperties = patch.properties === null
? null
: { ...(parseClientProperties(before.properties) ?? {}), ...(patch.properties as Record<string, unknown>) };
clientPatchSource.properties = nextProperties ? JSON.stringify(nextProperties) : null;
}
const clientColumns = await getTableColumns(tx, 'clients');
const clientPatch = pickExistingFields(
clientPatchSource,
clientColumns,
new Set([...CLIENT_TABLE_ALLOWED_FIELDS, 'updated_at'])
);
for (const key of Object.keys(clientPatch)) {
if (key !== 'updated_at') changedFields.push(key);
}
if (Object.keys(clientPatch).length > 0) {
try {
await tx.trx('clients')
.where({ tenant: tx.tenantId, client_id: input.client_id })
.update(clientPatch);
} catch (error) {
rethrowAsStandardError(ctx, error);
}
}
await upsertDefaultClientLocation(
tx,
input.client_id,
{
address: patch.address,
address_2: patch.address_2,
city: patch.city,
state: patch.state,
zip: patch.zip,
country: patch.country,
email: patch.email,
phone_no: patch.phone_no,
},
{ createIfMissing: false }
);
if (patch.is_inactive === true) {
await deactivateClientUsersForClient(tx, input.client_id);
if (!changedFields.includes('is_inactive')) {
changedFields.push('is_inactive');
}
}
const after = await ensureClientExists(ctx, tx, input.client_id);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.update',
changedData: { client_id: input.client_id, changed_fields: changedFields },
details: { action_id: 'clients.update', action_version: 1, client_id: input.client_id },
});
const updatedPayload = buildClientUpdatedPayload({
clientId: input.client_id,
before,
after,
updatedFieldKeys: changedFields,
updatedAt: (after.updated_at as string | undefined) ?? new Date().toISOString(),
});
const updatedFields = (updatedPayload as { updatedFields?: string[] }).updatedFields ?? [];
if (updatedFields.length > 0) {
await publishWorkflowDomainEvent({
eventType: 'CLIENT_UPDATED',
payload: updatedPayload,
tenantId: tx.tenantId,
occurredAt: (after.updated_at as string | undefined) ?? new Date().toISOString(),
actorUserId: tx.actorUserId,
idempotencyKey: `client_updated:${input.client_id}:${(after.updated_at as string | undefined) ?? new Date().toISOString()}`,
});
}
return {
client_before: clientToSummary(before),
client_after: clientToSummary(after),
changed_fields: changedFields,
};
}),
});
// ---------------------------------------------------------------------------
// A13 — clients.archive
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.archive',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
}),
outputSchema: z.object({
client_id: uuidSchema,
archived: z.boolean(),
previous_is_inactive: z.boolean(),
current_is_inactive: z.boolean(),
archived_at: isoDateTimeSchema.nullable(),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Archive Client',
category: 'Business Operations',
description: 'Set a client inactive with idempotent semantics',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'update' });
const before = await ensureClientExists(ctx, tx, input.client_id);
const previousInactive = Boolean(before.is_inactive);
let archivedAt: string | null = null;
if (!previousInactive) {
archivedAt = new Date().toISOString();
await tx.trx('clients')
.where({ tenant: tx.tenantId, client_id: input.client_id })
.update({ is_inactive: true, updated_at: archivedAt });
await deactivateClientUsersForClient(tx, input.client_id);
}
const after = await ensureClientExists(ctx, tx, input.client_id);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.archive',
changedData: {
client_id: input.client_id,
previous_is_inactive: previousInactive,
current_is_inactive: Boolean(after.is_inactive),
},
details: { action_id: 'clients.archive', action_version: 1, client_id: input.client_id },
});
if (!previousInactive && archivedAt) {
await publishWorkflowDomainEvent({
eventType: 'CLIENT_ARCHIVED',
payload: buildClientArchivedPayload({
clientId: input.client_id,
archivedByUserId: tx.actorUserId,
archivedAt,
}),
tenantId: tx.tenantId,
occurredAt: archivedAt,
actorUserId: tx.actorUserId,
idempotencyKey: `client_archived:${input.client_id}:${archivedAt}`,
});
}
return {
client_id: input.client_id,
archived: !previousInactive,
previous_is_inactive: previousInactive,
current_is_inactive: Boolean(after.is_inactive),
archived_at: archivedAt,
};
}),
});
// ---------------------------------------------------------------------------
// A14 — clients.delete
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.delete',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
confirm: z.boolean().refine((value) => value === true, { message: 'confirm must be true to delete a client' }),
on_not_found: z.enum(['error', 'return_false']).default('error'),
}),
outputSchema: z.object({
deleted: z.boolean(),
client_id: uuidSchema,
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Delete Client',
category: 'Business Operations',
description: 'Hard-delete a client with dependency validation guardrails',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'delete' });
const client = await tx.trx('clients')
.where({ tenant: tx.tenantId, client_id: input.client_id })
.select('client_id')
.first();
if (!client) {
if (input.on_not_found === 'return_false') {
return { deleted: false, client_id: input.client_id };
}
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Client not found',
details: { client_id: input.client_id },
});
}
const defaultClient = await tx.trx('tenant_companies')
.where({ tenant: tx.tenantId, client_id: input.client_id, is_default: true })
.first();
if (defaultClient) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message:
'Cannot delete the default client. Please set another client as default in General Settings first.',
});
}
const result = await deleteEntityWithValidation(
'client',
input.client_id,
tx.trx,
tx.tenantId,
async (trx: Knex.Transaction, tenantId: string) => {
await cleanupClientDeleteArtifacts(trx, tenantId, input.client_id);
await cleanupClientNotesDocument(trx, tenantId, input.client_id);
await cleanupEntraReferencesBeforeClientDelete(trx, tenantId, input.client_id);
await trx('clients').where({ tenant: tenantId, client_id: input.client_id }).delete();
}
);
if (!result?.deleted) {
throwActionError(ctx, {
category: 'ActionError',
code: 'CONFLICT',
message: result?.message ?? 'Unable to delete client due to dependencies',
details: {
dependencies: result?.dependencies ?? [],
alternatives: result?.alternatives ?? [],
},
});
}
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.delete',
changedData: { client_id: input.client_id, deleted: true },
details: { action_id: 'clients.delete', action_version: 1, client_id: input.client_id },
});
return { deleted: true, client_id: input.client_id };
}),
});
// ---------------------------------------------------------------------------
// A15 — clients.duplicate
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.duplicate',
version: 1,
inputSchema: z.object({
source_client_id: withWorkflowPicker(uuidSchema, 'Source client id', 'client'),
client_name: z.string().min(1).describe('Name for the duplicated client'),
copy_tags: z.boolean().default(true),
copy_locations: z.boolean().default(false),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
}),
outputSchema: z.object({
source_client: clientSummarySchema,
duplicate_client: clientSummarySchema,
copied_tags: z.number().int(),
copied_locations: z.number().int(),
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Duplicate Client',
category: 'Business Operations',
description: 'Create a new client from a source profile with safe copy options',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'read' });
await requirePermission(ctx, tx, { resource: 'client', action: 'create' });
const source = await ensureClientExists(ctx, tx, input.source_client_id);
const nowIso = new Date().toISOString();
const duplicateId = uuidv4();
const clientColumns = await getTableColumns(tx, 'clients');
const duplicateRow = pickExistingFields(
{
client_id: duplicateId,
tenant: tx.tenantId,
client_name: input.client_name,
client_type: source.client_type ?? null,
url: source.url ?? null,
billing_email: source.billing_email ?? null,
notes: source.notes ?? null,
properties: source.properties ?? null,
default_currency_code: source.default_currency_code ?? null,
parent_client_id: source.parent_client_id ?? null,
is_default: false,
is_inactive: false,
region_code: source.region_code ?? null,
tax_id_number: source.tax_id_number ?? null,
is_tax_exempt: source.is_tax_exempt ?? false,
tax_exemption_certificate: source.tax_exemption_certificate ?? null,
payment_terms: source.payment_terms ?? null,
preferred_payment_method: source.preferred_payment_method ?? null,
auto_invoice: source.auto_invoice ?? false,
invoice_delivery_method: source.invoice_delivery_method ?? null,
billing_cycle: source.billing_cycle ?? null,
timezone: source.timezone ?? null,
account_manager_id: source.account_manager_id ?? null,
created_at: nowIso,
updated_at: nowIso,
notes_document_id: null,
},
clientColumns,
new Set([...CLIENT_TABLE_ALLOWED_FIELDS, 'client_id', 'tenant', 'created_at', 'updated_at', 'notes_document_id'])
);
try {
await tx.trx('clients').insert(duplicateRow);
} catch (error) {
rethrowAsStandardError(ctx, error);
}
try {
await ensureDefaultContractForClientIfBillingConfigured(tx.trx, {
tenant: tx.tenantId,
clientId: duplicateId,
});
} catch {
// Non-fatal: duplicate creation remains valid.
}
let copiedTags = 0;
if (input.copy_tags) {
const sourceTags = await tx.trx('tag_mappings as tm')
.join('tag_definitions as td', function joinTagDefs() {
this.on('tm.tenant', 'td.tenant').andOn('tm.tag_id', 'td.tag_id');
})
.where({
'tm.tenant': tx.tenantId,
'tm.tagged_type': 'client',
'tm.tagged_id': input.source_client_id,
'td.tagged_type': 'client',
})
.select('td.tag_text');
const tagResult = await ensureClientTagMappings(
tx,
duplicateId,
sourceTags.map((row: { tag_text: string }) => row.tag_text)
);
copiedTags = tagResult.added.length + tagResult.existing.length;
}
let copiedLocations = 0;
if (input.copy_locations) {
const locationRows = await tx.trx('client_locations')
.where({ tenant: tx.tenantId, client_id: input.source_client_id })
.select('*');
if (locationRows.length > 0) {
const locationColumns = await getTableColumns(tx, 'client_locations');
const inserts = locationRows.map((row: Record<string, unknown>) =>
pickExistingFields(
{
...row,
location_id: uuidv4(),
client_id: duplicateId,
tenant: tx.tenantId,
created_at: nowIso,
updated_at: nowIso,
},
locationColumns,
new Set([...LOCATION_TABLE_ALLOWED_FIELDS, 'location_id', 'tenant', 'client_id', 'created_at', 'updated_at'])
)
);
await tx.trx('client_locations').insert(inserts);
copiedLocations = inserts.length;
}
}
const duplicateClient = await ensureClientExists(ctx, tx, duplicateId);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.duplicate',
changedData: {
source_client_id: input.source_client_id,
duplicate_client_id: duplicateId,
copied_tags: copiedTags,
copied_locations: copiedLocations,
},
details: { action_id: 'clients.duplicate', action_version: 1, client_id: duplicateId },
});
return {
source_client: clientToSummary(source),
duplicate_client: clientToSummary(duplicateClient),
copied_tags: copiedTags,
copied_locations: copiedLocations,
};
}),
});
// ---------------------------------------------------------------------------
// A16 — clients.add_tag
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.add_tag',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
tags: z.array(z.string().min(1)).min(1).describe('One or more tags to attach to the client'),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
}),
outputSchema: z.object({
client_id: uuidSchema,
added: z.array(tagResultSchema),
existing: z.array(tagResultSchema),
added_count: z.number().int(),
existing_count: z.number().int(),
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Add Tag to Client',
category: 'Business Operations',
description: 'Attach one or more tags to a client with idempotent mapping behavior',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'update' });
await ensureClientExists(ctx, tx, input.client_id);
const tagResult = await ensureClientTagMappings(tx, input.client_id, input.tags);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.add_tag',
changedData: {
client_id: input.client_id,
added_count: tagResult.added.length,
existing_count: tagResult.existing.length,
},
details: { action_id: 'clients.add_tag', action_version: 1, client_id: input.client_id },
});
return {
client_id: input.client_id,
added: tagResult.added,
existing: tagResult.existing,
added_count: tagResult.added.length,
existing_count: tagResult.existing.length,
};
}),
});
// ---------------------------------------------------------------------------
// A17 — clients.assign_to_ticket
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.assign_to_ticket',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
ticket_id: withWorkflowPicker(uuidSchema, 'Ticket id', 'ticket'),
contact_id: withWorkflowPicker(nullableUuidSchema.optional(), 'Optional contact id (null clears)', 'contact', ['client_id']),
location_id: withWorkflowPicker(
nullableUuidSchema.optional(),
'Optional location id (null clears)',
'client-location',
['client_id']
),
reason: z.string().optional().describe('Optional reason/audit detail for the reassignment'),
comment: z.string().optional().describe('Optional internal comment/audit detail for the reassignment'),
}),
outputSchema: z.object({
ticket_id: uuidSchema,
previous_client_id: nullableUuidSchema,
current_client_id: nullableUuidSchema,
previous_contact_id: nullableUuidSchema,
current_contact_id: nullableUuidSchema,
previous_location_id: nullableUuidSchema,
current_location_id: nullableUuidSchema,
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Assign Client to Ticket',
category: 'Business Operations',
description: 'Move a ticket to a client and optionally set/clear contact and location',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'read' });
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
await ensureClientExists(ctx, tx, input.client_id);
const ticket = await ensureTicketExists(ctx, tx, input.ticket_id);
if (Object.prototype.hasOwnProperty.call(input, 'contact_id') && input.contact_id !== null && input.contact_id !== undefined) {
const contact = await tx.trx('contacts')
.where({ tenant: tx.tenantId, contact_name_id: input.contact_id })
.first();
if (!contact) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Contact not found',
details: { contact_id: input.contact_id },
});
}
if (contact.client_id !== input.client_id) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'contact_id must belong to the selected client',
details: { contact_id: input.contact_id, client_id: input.client_id },
});
}
}
if (Object.prototype.hasOwnProperty.call(input, 'location_id') && input.location_id !== null && input.location_id !== undefined) {
const location = await tx.trx('client_locations')
.where({ tenant: tx.tenantId, location_id: input.location_id })
.first();
if (!location) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Location not found',
details: { location_id: input.location_id },
});
}
if (location.client_id !== input.client_id) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'location_id must belong to the selected client',
details: { location_id: input.location_id, client_id: input.client_id },
});
}
}
const patch: Record<string, unknown> = {
client_id: input.client_id,
updated_at: new Date().toISOString(),
};
if (Object.prototype.hasOwnProperty.call(input, 'contact_id')) {
patch.contact_name_id = input.contact_id ?? null;
}
if (Object.prototype.hasOwnProperty.call(input, 'location_id')) {
patch.location_id = input.location_id ?? null;
}
await tx.trx('tickets').where({ tenant: tx.tenantId, ticket_id: input.ticket_id }).update(patch);
const after = await ensureTicketExists(ctx, tx, input.ticket_id);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.assign_to_ticket',
changedData: {
ticket_id: input.ticket_id,
previous_client_id: ticket.client_id ?? null,
current_client_id: after.client_id ?? null,
previous_contact_id: ticket.contact_name_id ?? null,
current_contact_id: after.contact_name_id ?? null,
previous_location_id: ticket.location_id ?? null,
current_location_id: after.location_id ?? null,
},
details: {
action_id: 'clients.assign_to_ticket',
action_version: 1,
client_id: input.client_id,
reason: input.reason ?? null,
comment: input.comment ?? null,
},
});
return {
ticket_id: input.ticket_id,
previous_client_id: ticket.client_id ?? null,
current_client_id: after.client_id ?? null,
previous_contact_id: ticket.contact_name_id ?? null,
current_contact_id: after.contact_name_id ?? null,
previous_location_id: ticket.location_id ?? null,
current_location_id: after.location_id ?? null,
};
}),
});
// ---------------------------------------------------------------------------
// A18 — clients.add_note
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.add_note',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
body: z.string().min(1).describe('Note content to append to the client notes document'),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
}),
outputSchema: z.object({
client_id: uuidSchema,
document_id: uuidSchema,
created_document: z.boolean(),
updated_at: isoDateTimeSchema,
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Add Note to Client',
category: 'Business Operations',
description: 'Append a note block to the client notes document',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'update' });
const client = await ensureClientExists(ctx, tx, input.client_id);
const result = await appendClientNoteBlock(tx, client, input.body);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.add_note',
changedData: {
client_id: input.client_id,
document_id: result.document_id,
created_document: result.created_document,
},
details: { action_id: 'clients.add_note', action_version: 1, client_id: input.client_id },
});
if (result.created_document) {
await publishWorkflowDomainEvent({
eventType: 'NOTE_CREATED',
payload: buildNoteCreatedPayload({
noteId: result.document_id,
entityType: 'client',
entityId: input.client_id,
createdByUserId: tx.actorUserId,
createdAt: result.updated_at,
visibility: 'internal',
bodyPreview: input.body,
}),
tenantId: tx.tenantId,
occurredAt: result.updated_at,
actorUserId: tx.actorUserId,
idempotencyKey: `note_created:client:${input.client_id}:${result.document_id}`,
});
}
return {
client_id: input.client_id,
document_id: result.document_id,
created_document: result.created_document,
updated_at: result.updated_at,
};
}),
});
// ---------------------------------------------------------------------------
// A19 — clients.add_interaction
// ---------------------------------------------------------------------------
registry.register({
id: 'clients.add_interaction',
version: 1,
inputSchema: z.object({
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
type_id: uuidSchema.describe('Interaction type id (system or tenant type)'),
title: z.string().min(1),
contact_id: withWorkflowPicker(uuidSchema.optional(), 'Optional contact id', 'contact', ['client_id']),
ticket_id: withWorkflowPicker(uuidSchema.optional(), 'Optional ticket id', 'ticket'),
notes: z.string().optional(),
start_time: isoDateTimeSchema.optional(),
end_time: isoDateTimeSchema.optional(),
duration: z.number().int().nonnegative().optional(),
status_id: uuidSchema.optional(),
interaction_date: isoDateTimeSchema.optional(),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
}),
outputSchema: z.object({
interaction_id: uuidSchema,
client_id: uuidSchema,
contact_id: nullableUuidSchema,
ticket_id: nullableUuidSchema,
type_id: uuidSchema,
status_id: nullableUuidSchema,
title: z.string(),
notes: z.string().nullable(),
interaction_date: isoDateTimeSchema,
start_time: isoDateTimeSchema.nullable(),
end_time: isoDateTimeSchema.nullable(),
duration: z.number().int().nullable(),
user_id: uuidSchema,
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: {
label: 'Add Interaction to Client',
category: 'Business Operations',
description: 'Log an interaction against a client using the workflow actor as owner',
},
handler: async (input, ctx) =>
withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'client', action: 'update' });
await ensureClientExists(ctx, tx, input.client_id);
if (input.contact_id) {
const contact = await tx.trx('contacts')
.where({ tenant: tx.tenantId, contact_name_id: input.contact_id })
.first();
if (!contact) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Contact not found',
details: { contact_id: input.contact_id },
});
}
if (contact.client_id !== input.client_id) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'contact_id must belong to the selected client',
details: { contact_id: input.contact_id, client_id: input.client_id },
});
}
}
if (input.ticket_id) {
const ticket = await tx.trx('tickets')
.where({ tenant: tx.tenantId, ticket_id: input.ticket_id })
.first();
if (!ticket) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Ticket not found',
details: { ticket_id: input.ticket_id },
});
}
if (ticket.client_id && ticket.client_id !== input.client_id) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'ticket_id must belong to the selected client',
details: { ticket_id: input.ticket_id, client_id: input.client_id },
});
}
}
const statusId = input.status_id ?? (await getDefaultInteractionStatusId(ctx, tx));
const interactionDate = input.interaction_date ?? new Date().toISOString();
const insertRow = {
tenant: tx.tenantId,
interaction_id: uuidv4(),
type_id: input.type_id,
contact_name_id: input.contact_id ?? null,
client_id: input.client_id,
user_id: tx.actorUserId,
ticket_id: input.ticket_id ?? null,
title: input.title,
notes: input.notes ?? null,
interaction_date: interactionDate,
start_time: input.start_time ?? interactionDate,
end_time: input.end_time ?? interactionDate,
duration: input.duration ?? 0,
status_id: statusId,
};
let created: any;
try {
[created] = await tx.trx('interactions').insert(insertRow).returning('*');
} catch (error) {
rethrowAsStandardError(ctx, error);
}
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:clients.add_interaction',
changedData: {
interaction_id: created.interaction_id,
client_id: created.client_id,
ticket_id: created.ticket_id ?? null,
contact_id: created.contact_name_id ?? null,
type_id: created.type_id,
},
details: {
action_id: 'clients.add_interaction',
action_version: 1,
client_id: created.client_id,
interaction_id: created.interaction_id,
},
});
await publishWorkflowDomainEvent({
eventType: 'INTERACTION_LOGGED',
payload: buildInteractionLoggedPayload({
interactionId: created.interaction_id,
clientId: created.client_id,
contactId: created.contact_name_id ?? undefined,
interactionType: String(created.type_name ?? created.type_id ?? 'interaction'),
interactionOccurredAt:
created.interaction_date instanceof Date
? created.interaction_date.toISOString()
: String(created.interaction_date),
loggedByUserId: created.user_id,
subject: created.title ?? undefined,
outcome: created.status_name ?? undefined,
}),
tenantId: tx.tenantId,
occurredAt:
created.interaction_date instanceof Date
? created.interaction_date.toISOString()
: String(created.interaction_date),
actorUserId: tx.actorUserId,
idempotencyKey: `interaction_logged:${created.interaction_id}`,
});
return {
interaction_id: created.interaction_id,
client_id: created.client_id,
contact_id: created.contact_name_id ?? null,
ticket_id: created.ticket_id ?? null,
type_id: created.type_id,
status_id: created.status_id ?? null,
title: created.title,
notes: created.notes ?? null,
interaction_date:
created.interaction_date instanceof Date
? created.interaction_date.toISOString()
: String(created.interaction_date),
start_time:
created.start_time instanceof Date
? created.start_time.toISOString()
: created.start_time
? String(created.start_time)
: null,
end_time:
created.end_time instanceof Date
? created.end_time.toISOString()
: created.end_time
? String(created.end_time)
: null,
duration: typeof created.duration === 'number' ? created.duration : created.duration ? Number(created.duration) : null,
user_id: created.user_id,
};
}),
});
}