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
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
1768 lines
66 KiB
TypeScript
1768 lines
66 KiB
TypeScript
import { z } from 'zod';
|
|
import type { Knex } from 'knex';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { getActionRegistryV2 } from '../../registries/actionRegistry';
|
|
import { getWorkflowEmailProvider } from '../../registries/workflowEmailRegistry';
|
|
import { TicketModel } from '../../../../models/ticketModel';
|
|
import { auditCloseRulesBypassIfGated } from '../../../../lib/ticketCloseRules';
|
|
import { TICKET_ACTIVITY_ACTOR, TICKET_ACTIVITY_SOURCE } from '../../../../lib/ticketActivity';
|
|
import { applyChecklistTemplateToTicket } from '../../../../lib/ticketChecklists';
|
|
import {
|
|
uuidSchema,
|
|
isoDateTimeSchema,
|
|
attachmentSourceSchema,
|
|
actionProvidedKey,
|
|
withTenantTransaction,
|
|
requirePermission,
|
|
writeRunAudit,
|
|
throwActionError,
|
|
rethrowAsStandardError,
|
|
parseJsonMaybe,
|
|
buildBlockNoteWithMentions,
|
|
attachDocumentToTicket,
|
|
type TenantTxContext,
|
|
} from './shared';
|
|
import { withWorkflowJsonSchemaMetadata } from '../../jsonSchemaMetadata';
|
|
|
|
const WORKFLOW_PICKER_HINTS = {
|
|
board: 'Search boards',
|
|
client: 'Search clients',
|
|
contact: 'Search contacts',
|
|
'ticket-status': 'Search statuses',
|
|
'ticket-priority': 'Search priorities',
|
|
user: 'Search users',
|
|
'user-or-team': 'Search users or teams',
|
|
'ticket-category': 'Search categories',
|
|
'ticket-subcategory': 'Search subcategories',
|
|
'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 workflowTicketAssignmentPrimaryTypeSchema = z.enum(['user', 'team', 'queue']);
|
|
|
|
type WorkflowTicketAssignmentInput = {
|
|
primary?: {
|
|
type?: z.infer<typeof workflowTicketAssignmentPrimaryTypeSchema>;
|
|
id?: string;
|
|
} | null;
|
|
additional_user_ids?: string[];
|
|
};
|
|
|
|
type WorkflowTicketResolvedAdditionalUser = {
|
|
userId: string;
|
|
role: 'support' | 'team_member';
|
|
};
|
|
|
|
type WorkflowTicketResolvedAssignment = {
|
|
primary: WorkflowTicketAssignmentInput['primary'];
|
|
assignedTo: string | null;
|
|
assignedTeamId: string | null;
|
|
additionalUsers: WorkflowTicketResolvedAdditionalUser[];
|
|
};
|
|
|
|
const buildWorkflowTicketAssignmentPrimarySchema = (typeDependencyPath: string) =>
|
|
z.object({
|
|
type: workflowTicketAssignmentPrimaryTypeSchema.describe('Primary assignment type'),
|
|
id: withWorkflowPicker(
|
|
uuidSchema,
|
|
'Primary assignment target id',
|
|
'user-or-team',
|
|
[typeDependencyPath]
|
|
)
|
|
}).describe('Primary assignment target');
|
|
|
|
const buildWorkflowTicketAssignmentSchema = ({
|
|
dependencyPrefix,
|
|
requirePrimary = false,
|
|
}: {
|
|
dependencyPrefix: string;
|
|
requirePrimary?: boolean;
|
|
}) => {
|
|
const primarySchema = buildWorkflowTicketAssignmentPrimarySchema(`${dependencyPrefix}.primary.type`);
|
|
|
|
return z.object({
|
|
primary: requirePrimary ? primarySchema : primarySchema.nullable().default(null),
|
|
additional_user_ids: withWorkflowPicker(
|
|
z.array(uuidSchema).default([]),
|
|
'Additional assigned MSP user ids',
|
|
'user'
|
|
)
|
|
}).superRefine((assignment, refinementCtx) => {
|
|
if (!assignment.primary && assignment.additional_user_ids.length > 0) {
|
|
refinementCtx.addIssue({
|
|
code: z.ZodIssueCode.custom,
|
|
path: ['additional_user_ids'],
|
|
message: 'additional_user_ids requires a primary assignment'
|
|
});
|
|
}
|
|
});
|
|
};
|
|
|
|
const uniqueStrings = (values: string[]): string[] => Array.from(new Set(values));
|
|
|
|
const getCurrentTicketAdditionalUserIds = async (
|
|
tx: { tenantId: string; trx: any },
|
|
ticketId: string
|
|
): Promise<string[]> => {
|
|
const rows = await tx.trx('ticket_resources')
|
|
.where({ tenant: tx.tenantId, ticket_id: ticketId })
|
|
.whereNotNull('additional_user_id')
|
|
.select('additional_user_id');
|
|
|
|
return uniqueStrings(
|
|
rows
|
|
.map((row: { additional_user_id: string | null }) => row.additional_user_id)
|
|
.filter((userId: string | null): userId is string => typeof userId === 'string' && userId.length > 0)
|
|
);
|
|
};
|
|
|
|
const resolveWorkflowTicketAssignment = async (
|
|
tx: { tenantId: string; trx: any },
|
|
ctx: any,
|
|
assignment: WorkflowTicketAssignmentInput | null | undefined,
|
|
options: {
|
|
requirePrimary?: boolean;
|
|
} = {}
|
|
): Promise<WorkflowTicketResolvedAssignment> => {
|
|
const primary = assignment?.primary ?? null;
|
|
const explicitAdditionalUserIds = uniqueStrings(assignment?.additional_user_ids ?? []);
|
|
|
|
if (!primary?.type || !primary?.id) {
|
|
if (options.requirePrimary) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'assignment.primary is required'
|
|
});
|
|
}
|
|
|
|
return {
|
|
primary: null,
|
|
assignedTo: null,
|
|
assignedTeamId: null,
|
|
additionalUsers: []
|
|
};
|
|
}
|
|
|
|
let assignedTo: string | null = null;
|
|
let assignedTeamId: string | null = null;
|
|
const implicitAdditionalUsers: WorkflowTicketResolvedAdditionalUser[] = [];
|
|
|
|
if (primary.type === 'user') {
|
|
const user = await tx.trx('users')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
user_id: primary.id,
|
|
user_type: 'internal',
|
|
is_inactive: false,
|
|
})
|
|
.first();
|
|
|
|
if (!user) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Primary assigned user not found or inactive',
|
|
details: { user_id: primary.id }
|
|
});
|
|
}
|
|
|
|
assignedTo = primary.id;
|
|
} else if (primary.type === 'team') {
|
|
const team = await tx.trx('teams')
|
|
.where({ tenant: tx.tenantId, team_id: primary.id })
|
|
.first();
|
|
|
|
if (!team) {
|
|
throwActionError(ctx, {
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Team not found',
|
|
details: { team_id: primary.id }
|
|
});
|
|
}
|
|
|
|
if (!team.manager_id) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Team lead not found',
|
|
details: { team_id: primary.id }
|
|
});
|
|
}
|
|
|
|
const manager = await tx.trx('users')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
user_id: team.manager_id,
|
|
user_type: 'internal',
|
|
is_inactive: false,
|
|
})
|
|
.first();
|
|
|
|
if (!manager) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Team lead is inactive or not found',
|
|
details: { team_id: primary.id, manager_id: team.manager_id }
|
|
});
|
|
}
|
|
|
|
assignedTo = team.manager_id;
|
|
assignedTeamId = team.team_id;
|
|
|
|
const teamMembers = await tx.trx('team_members')
|
|
.join('users', function (this: Knex.JoinClause) {
|
|
this.on('team_members.user_id', 'users.user_id')
|
|
.andOn('team_members.tenant', 'users.tenant');
|
|
})
|
|
.where({
|
|
'team_members.tenant': tx.tenantId,
|
|
'team_members.team_id': primary.id,
|
|
})
|
|
.andWhere('users.user_type', 'internal')
|
|
.andWhere('users.is_inactive', false)
|
|
.select('team_members.user_id');
|
|
|
|
implicitAdditionalUsers.push(
|
|
...teamMembers
|
|
.map((member: { user_id: string }) => member.user_id)
|
|
.filter((userId: string) => userId && userId !== assignedTo)
|
|
.map((userId: string) => ({ userId, role: 'team_member' as const }))
|
|
);
|
|
} else {
|
|
const member = await tx.trx('team_members')
|
|
.where({ tenant: tx.tenantId, team_id: primary.id })
|
|
.orderBy('created_at', 'asc')
|
|
.first();
|
|
|
|
if (!member?.user_id) {
|
|
throwActionError(ctx, {
|
|
category: 'ActionError',
|
|
code: 'NOT_FOUND',
|
|
message: 'Queue has no members',
|
|
details: { queue_id: primary.id }
|
|
});
|
|
}
|
|
|
|
const resolvedUser = await tx.trx('users')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
user_id: member.user_id,
|
|
user_type: 'internal',
|
|
is_inactive: false,
|
|
})
|
|
.first();
|
|
|
|
if (!resolvedUser) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Queue resolved to an inactive or missing user',
|
|
details: { queue_id: primary.id, user_id: member.user_id }
|
|
});
|
|
}
|
|
|
|
assignedTo = member.user_id;
|
|
}
|
|
|
|
const validExplicitUsers = explicitAdditionalUserIds.length > 0
|
|
? await tx.trx('users')
|
|
.where({ tenant: tx.tenantId, user_type: 'internal', is_inactive: false })
|
|
.whereIn('user_id', explicitAdditionalUserIds)
|
|
.select('user_id')
|
|
: [];
|
|
|
|
const validExplicitUserIds = new Set(
|
|
validExplicitUsers.map((user: { user_id: string }) => user.user_id)
|
|
);
|
|
const missingExplicitUserIds = explicitAdditionalUserIds.filter(
|
|
(userId) => !validExplicitUserIds.has(userId)
|
|
);
|
|
|
|
if (missingExplicitUserIds.length > 0) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'One or more additional assigned users are invalid or inactive',
|
|
details: { invalid_user_ids: missingExplicitUserIds }
|
|
});
|
|
}
|
|
|
|
const additionalUsersById = new Map<string, WorkflowTicketResolvedAdditionalUser>();
|
|
|
|
for (const additionalUser of implicitAdditionalUsers) {
|
|
additionalUsersById.set(additionalUser.userId, additionalUser);
|
|
}
|
|
|
|
for (const userId of explicitAdditionalUserIds) {
|
|
if (!additionalUsersById.has(userId)) {
|
|
additionalUsersById.set(userId, {
|
|
userId,
|
|
role: 'support'
|
|
});
|
|
}
|
|
}
|
|
|
|
if (assignedTo) {
|
|
additionalUsersById.delete(assignedTo);
|
|
}
|
|
|
|
return {
|
|
primary,
|
|
assignedTo,
|
|
assignedTeamId,
|
|
additionalUsers: Array.from(additionalUsersById.values())
|
|
};
|
|
};
|
|
|
|
const reconcileWorkflowTicketAdditionalUsers = async (
|
|
tx: { tenantId: string; trx: any },
|
|
ticketId: string,
|
|
assignedTo: string | null,
|
|
additionalUsers: WorkflowTicketResolvedAdditionalUser[]
|
|
): Promise<void> => {
|
|
await tx.trx('ticket_resources')
|
|
.where({ tenant: tx.tenantId, ticket_id: ticketId })
|
|
.delete();
|
|
|
|
if (!assignedTo || additionalUsers.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await tx.trx('ticket_resources').insert(
|
|
additionalUsers.map((additionalUser) => ({
|
|
tenant: tx.tenantId,
|
|
ticket_id: ticketId,
|
|
assigned_to: assignedTo,
|
|
additional_user_id: additionalUser.userId,
|
|
role: additionalUser.role,
|
|
assigned_at: new Date()
|
|
}))
|
|
);
|
|
};
|
|
|
|
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 normalizeTicketTags = (tags: string[] | undefined): string[] => {
|
|
if (!Array.isArray(tags)) return [];
|
|
|
|
const seen = new Set<string>();
|
|
const normalized: string[] = [];
|
|
|
|
tags.forEach((tag) => {
|
|
const trimmed = tag.trim();
|
|
if (!trimmed || seen.has(trimmed)) return;
|
|
seen.add(trimmed);
|
|
normalized.push(trimmed);
|
|
});
|
|
|
|
return normalized;
|
|
};
|
|
|
|
const ensureTicketTagMappings = async (
|
|
tx: TenantTxContext,
|
|
ticketId: string,
|
|
tags: string[] | undefined
|
|
): Promise<void> => {
|
|
const normalizedTags = normalizeTicketTags(tags);
|
|
if (normalizedTags.length === 0) {
|
|
return;
|
|
}
|
|
|
|
for (const tagText of normalizedTags) {
|
|
const { backgroundColor, textColor } = generateTagColors(tagText);
|
|
|
|
let definition = await tx.trx('tag_definitions')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
tag_text: tagText,
|
|
tagged_type: 'ticket',
|
|
})
|
|
.first();
|
|
|
|
if (!definition) {
|
|
const definitionRow = {
|
|
tenant: tx.tenantId,
|
|
tag_id: uuidv4(),
|
|
tag_text: tagText,
|
|
tagged_type: 'ticket',
|
|
board_id: null,
|
|
background_color: backgroundColor,
|
|
text_color: textColor,
|
|
};
|
|
|
|
try {
|
|
await tx.trx('tag_definitions').insert(definitionRow);
|
|
definition = definitionRow;
|
|
} catch (error: unknown) {
|
|
const errorCode =
|
|
typeof error === 'object' && error !== null && 'code' in error
|
|
? String((error as { code?: unknown }).code)
|
|
: undefined;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
if (errorCode === '23505' || /duplicate|unique/i.test(errorMessage)) {
|
|
definition = await tx.trx('tag_definitions')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
tag_text: tagText,
|
|
tagged_type: 'ticket',
|
|
})
|
|
.first();
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!definition?.tag_id) {
|
|
throw new Error(`Failed to resolve ticket tag definition for "${tagText}"`);
|
|
}
|
|
|
|
try {
|
|
await tx.trx('tag_mappings').insert({
|
|
tenant: tx.tenantId,
|
|
mapping_id: uuidv4(),
|
|
tag_id: definition.tag_id,
|
|
tagged_id: ticketId,
|
|
tagged_type: 'ticket',
|
|
created_by: tx.actorUserId,
|
|
});
|
|
} catch (error: unknown) {
|
|
const errorCode =
|
|
typeof error === 'object' && error !== null && 'code' in error
|
|
? String((error as { code?: unknown }).code)
|
|
: undefined;
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
|
|
if (errorCode === '23505' || /duplicate|unique/i.test(errorMessage)) {
|
|
continue;
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
};
|
|
|
|
export function registerTicketActions(): void {
|
|
const registry = getActionRegistryV2();
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A01 — tickets.create
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.create',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
client_id: withWorkflowPicker(uuidSchema, 'Client id', 'client'),
|
|
contact_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Optional contact id',
|
|
'contact',
|
|
['client_id']
|
|
),
|
|
title: z.string().min(1).describe('Ticket subject/title'),
|
|
description: z.string().default('').describe('Ticket description/body'),
|
|
board_id: withWorkflowPicker(uuidSchema, 'Board id', 'board'),
|
|
location_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Optional location id',
|
|
'client-location',
|
|
['client_id']
|
|
),
|
|
status_id: withWorkflowPicker(
|
|
uuidSchema.optional(),
|
|
'Status id',
|
|
'ticket-status',
|
|
['board_id']
|
|
),
|
|
priority_id: withWorkflowPicker(uuidSchema, 'Priority id', 'ticket-priority'),
|
|
assignment: buildWorkflowTicketAssignmentSchema({
|
|
dependencyPrefix: 'assignment'
|
|
}).optional().describe('Ticket assignment configuration'),
|
|
category_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Category id',
|
|
'ticket-category',
|
|
['board_id']
|
|
),
|
|
subcategory_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Subcategory id',
|
|
'ticket-subcategory',
|
|
['board_id', 'category_id']
|
|
),
|
|
tags: z.array(z.string()).optional().describe('Optional tags (applied to ticket tags and mirrored into ticket attributes)'),
|
|
custom_fields: z.record(z.unknown()).optional().describe('Optional custom fields (stored in ticket attributes)'),
|
|
attributes: z.record(z.unknown()).optional().describe('Additional attributes (merged into ticket.attributes)'),
|
|
initial_comment: z.object({
|
|
body: z.string().min(1).describe('Initial comment body'),
|
|
visibility: z.enum(['public', 'internal']).default('public').describe('Comment visibility')
|
|
}).optional().describe('Optional initial comment'),
|
|
attachments: z.array(z.object({
|
|
source: attachmentSourceSchema,
|
|
filename: z.string().optional(),
|
|
visibility: z.enum(['public', 'internal']).optional()
|
|
})).optional().describe('Optional attachments (documents)'),
|
|
idempotency_key: z.string().optional().describe('Optional external idempotency key')
|
|
}),
|
|
outputSchema: z.object({
|
|
ticket_id: uuidSchema,
|
|
ticket_number: z.string(),
|
|
url: z.string().nullable(),
|
|
created_at: isoDateTimeSchema,
|
|
status_id: uuidSchema,
|
|
priority_id: uuidSchema
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
|
|
ui: {
|
|
label: 'Create Ticket',
|
|
category: 'Business Operations',
|
|
description: 'Create a ticket in Alga PSA'
|
|
},
|
|
examples: {
|
|
minimal: {
|
|
client_id: '00000000-0000-0000-0000-000000000000',
|
|
title: 'Printer not working',
|
|
description: 'The office printer is jammed.',
|
|
board_id: '00000000-0000-0000-0000-000000000000',
|
|
status_id: '00000000-0000-0000-0000-000000000000',
|
|
priority_id: '00000000-0000-0000-0000-000000000000'
|
|
}
|
|
},
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'create' });
|
|
|
|
const mergedAttributes: Record<string, any> = {
|
|
...(input.attributes ?? {})
|
|
};
|
|
const normalizedTags = normalizeTicketTags(input.tags);
|
|
if (normalizedTags.length) mergedAttributes.tags = normalizedTags;
|
|
if (input.custom_fields) mergedAttributes.custom_fields = input.custom_fields;
|
|
|
|
if (input.status_id) {
|
|
const status = await tx.trx('statuses')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
status_id: input.status_id,
|
|
status_type: 'ticket',
|
|
board_id: input.board_id
|
|
})
|
|
.first();
|
|
|
|
if (!status) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Invalid status_id for selected board'
|
|
});
|
|
}
|
|
}
|
|
|
|
const resolvedAssignment = await resolveWorkflowTicketAssignment(
|
|
tx,
|
|
ctx,
|
|
input.assignment,
|
|
);
|
|
|
|
let created: any;
|
|
try {
|
|
created = await TicketModel.createTicket(
|
|
{
|
|
title: input.title,
|
|
description: input.description ?? '',
|
|
client_id: input.client_id,
|
|
contact_id: input.contact_id ?? undefined,
|
|
board_id: input.board_id,
|
|
location_id: input.location_id ?? undefined,
|
|
status_id: input.status_id,
|
|
priority_id: input.priority_id,
|
|
assigned_to: resolvedAssignment.assignedTo ?? undefined,
|
|
assigned_team_id: resolvedAssignment.assignedTeamId ?? undefined,
|
|
category_id: input.category_id ?? undefined,
|
|
subcategory_id: input.subcategory_id ?? undefined,
|
|
entered_by: tx.actorUserId,
|
|
attributes: mergedAttributes
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
{},
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
} catch (error) {
|
|
rethrowAsStandardError(ctx, error);
|
|
}
|
|
|
|
await ensureTicketTagMappings(tx, created.ticket_id, normalizedTags);
|
|
|
|
await reconcileWorkflowTicketAdditionalUsers(
|
|
tx,
|
|
created.ticket_id,
|
|
resolvedAssignment.assignedTo,
|
|
resolvedAssignment.additionalUsers
|
|
);
|
|
|
|
if (input.initial_comment?.body) {
|
|
try {
|
|
await TicketModel.createComment(
|
|
{
|
|
ticket_id: created.ticket_id,
|
|
content: input.initial_comment.body,
|
|
is_internal: input.initial_comment.visibility === 'internal',
|
|
is_resolution: false,
|
|
author_type: 'system',
|
|
author_id: tx.actorUserId,
|
|
metadata: { source: 'workflow', run_id: ctx.runId, step_path: ctx.stepPath }
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
} catch (error) {
|
|
rethrowAsStandardError(ctx, error);
|
|
}
|
|
}
|
|
|
|
if (input.attachments?.length) {
|
|
for (const attachment of input.attachments) {
|
|
await attachDocumentToTicket(ctx, tx, created.ticket_id, {
|
|
source: attachment.source,
|
|
filename: attachment.filename ?? null,
|
|
visibility: attachment.visibility
|
|
});
|
|
}
|
|
}
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.create',
|
|
changedData: { ticket_id: created.ticket_id, ticket_number: created.ticket_number },
|
|
details: { action_id: 'tickets.create', action_version: 1, ticket_id: created.ticket_id }
|
|
});
|
|
|
|
return {
|
|
ticket_id: created.ticket_id,
|
|
ticket_number: created.ticket_number,
|
|
url: (created as any).url ?? null,
|
|
created_at: created.entered_at,
|
|
status_id: created.status_id,
|
|
priority_id: input.priority_id
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A02 — tickets.add_comment
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.add_comment',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
body: z.string().min(1).describe('Comment body'),
|
|
visibility: z.enum(['public', 'internal']).default('public').describe('Comment visibility'),
|
|
mentions: z.array(z.string().min(1)).optional().describe('Optional mentioned user ids (or @everyone)'),
|
|
attachments: z.array(z.object({
|
|
source: attachmentSourceSchema,
|
|
filename: z.string().optional()
|
|
})).optional().describe('Optional attachments (added to ticket)'),
|
|
idempotency_key: z.string().optional().describe('Optional external idempotency key')
|
|
}),
|
|
outputSchema: z.object({
|
|
comment_id: uuidSchema,
|
|
created_at: isoDateTimeSchema,
|
|
visibility: z.enum(['public', 'internal'])
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
|
|
ui: {
|
|
label: 'Add Ticket Comment',
|
|
category: 'Business Operations',
|
|
description: 'Add a public or internal comment to a ticket'
|
|
},
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
|
|
const content = input.mentions?.length ? buildBlockNoteWithMentions({ body: input.body, mentions: input.mentions }) : input.body;
|
|
|
|
let created: any;
|
|
try {
|
|
created = await TicketModel.createComment(
|
|
{
|
|
ticket_id: input.ticket_id,
|
|
content,
|
|
is_internal: input.visibility === 'internal',
|
|
is_resolution: false,
|
|
author_type: 'system',
|
|
author_id: tx.actorUserId,
|
|
metadata: { source: 'workflow', run_id: ctx.runId, step_path: ctx.stepPath }
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
} catch (error) {
|
|
rethrowAsStandardError(ctx, error);
|
|
}
|
|
|
|
if (input.attachments?.length) {
|
|
for (const attachment of input.attachments) {
|
|
await attachDocumentToTicket(ctx, tx, input.ticket_id, {
|
|
source: attachment.source,
|
|
filename: attachment.filename ?? null
|
|
});
|
|
}
|
|
}
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.add_comment',
|
|
changedData: { ticket_id: input.ticket_id, comment_id: created.comment_id },
|
|
details: { action_id: 'tickets.add_comment', action_version: 1, ticket_id: input.ticket_id, comment_id: created.comment_id }
|
|
});
|
|
|
|
return {
|
|
comment_id: created.comment_id,
|
|
created_at: created.created_at,
|
|
visibility: input.visibility
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A03 — tickets.update_fields
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.update_fields',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
patch: z.object({
|
|
status_id: withWorkflowPicker(
|
|
uuidSchema.optional(),
|
|
'New status id',
|
|
'ticket-status',
|
|
['ticket_id']
|
|
),
|
|
priority_id: withWorkflowPicker(
|
|
uuidSchema.optional(),
|
|
'New priority id',
|
|
'ticket-priority'
|
|
),
|
|
assignment: buildWorkflowTicketAssignmentSchema({
|
|
dependencyPrefix: 'patch.assignment'
|
|
}).optional().describe('Atomic assignment replacement'),
|
|
title: z.string().min(1).optional().describe('New title'),
|
|
category_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Category id',
|
|
'ticket-category'
|
|
),
|
|
subcategory_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Subcategory id',
|
|
'ticket-subcategory'
|
|
),
|
|
location_id: withWorkflowPicker(
|
|
uuidSchema.nullable().optional(),
|
|
'Location id',
|
|
'client-location'
|
|
),
|
|
due_date: isoDateTimeSchema.nullable().optional().describe('Optional due date (stored in ticket.attributes.due_date)'),
|
|
tags: z.array(z.string()).optional().describe('Tags (stored in ticket attributes)'),
|
|
custom_fields: z.record(z.unknown()).optional().describe('Custom fields (stored in ticket attributes)'),
|
|
attributes: z.record(z.unknown()).optional().describe('Attributes merge')
|
|
}).describe('Patch object').refine((patch) => Object.keys(patch).length > 0, {
|
|
message: 'Patch must include at least one field'
|
|
}),
|
|
expected_updated_at: isoDateTimeSchema.optional().describe('Optional optimistic concurrency token (ticket.updated_at)'),
|
|
idempotency_key: z.string().optional().describe('Optional external idempotency key')
|
|
}),
|
|
outputSchema: z.object({
|
|
ticket_id: uuidSchema,
|
|
updated_at: isoDateTimeSchema,
|
|
status_id: uuidSchema.nullable(),
|
|
priority_id: uuidSchema.nullable(),
|
|
tags: z.array(z.string()).nullable()
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
|
|
ui: {
|
|
label: 'Update Ticket Fields',
|
|
category: 'Business Operations',
|
|
description: 'Patch core ticket fields (status, priority, assignment, attributes)'
|
|
},
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
|
|
const current = await tx.trx('tickets').where({ tenant: tx.tenantId, ticket_id: input.ticket_id }).first();
|
|
if (!current) {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Ticket not found', details: { ticket_id: input.ticket_id } });
|
|
}
|
|
|
|
if (input.expected_updated_at) {
|
|
const currentUpdated = current.updated_at ? new Date(current.updated_at).toISOString() : null;
|
|
if (!currentUpdated || currentUpdated !== input.expected_updated_at) {
|
|
throwActionError(ctx, {
|
|
category: 'ActionError',
|
|
code: 'CONFLICT',
|
|
message: 'Ticket was modified since expected_updated_at',
|
|
details: { expected_updated_at: input.expected_updated_at, actual_updated_at: currentUpdated }
|
|
});
|
|
}
|
|
}
|
|
|
|
if (input.patch.status_id) {
|
|
const status = await tx.trx('statuses')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
status_id: input.patch.status_id,
|
|
status_type: 'ticket',
|
|
board_id: current.board_id,
|
|
})
|
|
.first();
|
|
if (!status) {
|
|
throwActionError(ctx, {
|
|
category: 'ValidationError',
|
|
code: 'VALIDATION_ERROR',
|
|
message: 'Invalid status_id for selected board'
|
|
});
|
|
}
|
|
}
|
|
if (input.patch.priority_id) {
|
|
const priority = await tx.trx('priorities').where({ tenant: tx.tenantId, priority_id: input.patch.priority_id }).first();
|
|
if (!priority) {
|
|
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Invalid priority_id for ticket' });
|
|
}
|
|
}
|
|
|
|
let currentAttributes: Record<string, any> = {};
|
|
const parsedAttrs = parseJsonMaybe(current.attributes);
|
|
currentAttributes = parsedAttrs && typeof parsedAttrs === 'object' && !Array.isArray(parsedAttrs) ? parsedAttrs : {};
|
|
const currentAdditionalUserIds = input.patch.assignment !== undefined
|
|
? await getCurrentTicketAdditionalUserIds(tx, input.ticket_id)
|
|
: [];
|
|
|
|
const mergedAttributes = {
|
|
...currentAttributes,
|
|
...(input.patch.attributes ?? {})
|
|
} as Record<string, any>;
|
|
if (input.patch.tags) mergedAttributes.tags = input.patch.tags;
|
|
if (input.patch.custom_fields) mergedAttributes.custom_fields = input.patch.custom_fields;
|
|
if (input.patch.due_date !== undefined) mergedAttributes.due_date = input.patch.due_date;
|
|
|
|
const before = {
|
|
title: (current.title as string | null) ?? null,
|
|
status_id: (current.status_id as string | null) ?? null,
|
|
priority_id: (current.priority_id as string | null) ?? null,
|
|
assigned_to: (current.assigned_to as string | null) ?? null,
|
|
assignment: input.patch.assignment !== undefined
|
|
? {
|
|
primary: current.assigned_to
|
|
? {
|
|
type: current.assigned_team_id ? 'team' : 'user',
|
|
id: (current.assigned_team_id as string | null) ?? (current.assigned_to as string)
|
|
}
|
|
: null,
|
|
additional_user_ids: currentAdditionalUserIds
|
|
}
|
|
: undefined,
|
|
category_id: (current.category_id as string | null) ?? null,
|
|
subcategory_id: (current.subcategory_id as string | null) ?? null,
|
|
location_id: (current.location_id as string | null) ?? null,
|
|
due_date: (currentAttributes.due_date as string | null | undefined) ?? null,
|
|
tags: (currentAttributes.tags as string[] | undefined) ?? null
|
|
};
|
|
|
|
const resolvedAssignment = input.patch.assignment !== undefined
|
|
? await resolveWorkflowTicketAssignment(tx, ctx, input.patch.assignment)
|
|
: null;
|
|
|
|
let updated: any;
|
|
try {
|
|
if (resolvedAssignment) {
|
|
await tx.trx('ticket_resources')
|
|
.where({ tenant: tx.tenantId, ticket_id: input.ticket_id })
|
|
.delete();
|
|
}
|
|
|
|
updated = await TicketModel.updateTicket(
|
|
input.ticket_id,
|
|
{
|
|
...(input.patch.title ? { title: input.patch.title } : {}),
|
|
...(input.patch.status_id ? { status_id: input.patch.status_id } : {}),
|
|
...(input.patch.priority_id ? { priority_id: input.patch.priority_id } : {}),
|
|
...(resolvedAssignment
|
|
? {
|
|
assigned_to: resolvedAssignment.assignedTo,
|
|
assigned_team_id: resolvedAssignment.assignedTeamId,
|
|
}
|
|
: {}),
|
|
...(input.patch.category_id !== undefined ? { category_id: input.patch.category_id } : {}),
|
|
...(input.patch.subcategory_id !== undefined ? { subcategory_id: input.patch.subcategory_id } : {}),
|
|
...(input.patch.location_id !== undefined ? { location_id: input.patch.location_id } : {}),
|
|
attributes: mergedAttributes,
|
|
updated_by: tx.actorUserId
|
|
} as any,
|
|
tx.tenantId,
|
|
tx.trx,
|
|
{},
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
|
|
if (resolvedAssignment) {
|
|
await reconcileWorkflowTicketAdditionalUsers(
|
|
tx,
|
|
input.ticket_id,
|
|
resolvedAssignment.assignedTo,
|
|
resolvedAssignment.additionalUsers
|
|
);
|
|
}
|
|
} catch (error) {
|
|
rethrowAsStandardError(ctx, error);
|
|
}
|
|
|
|
const updatedAttributes = parseJsonMaybe(updated.attributes);
|
|
const normalizedUpdatedAttributes =
|
|
updatedAttributes && typeof updatedAttributes === 'object' && !Array.isArray(updatedAttributes) ? updatedAttributes : {};
|
|
|
|
const after = {
|
|
title: (updated.title as string | null) ?? null,
|
|
status_id: (updated.status_id as string | null) ?? null,
|
|
priority_id: (updated.priority_id as string | null) ?? null,
|
|
assigned_to: (updated.assigned_to as string | null) ?? null,
|
|
assignment: resolvedAssignment
|
|
? {
|
|
primary: resolvedAssignment.primary,
|
|
additional_user_ids: resolvedAssignment.additionalUsers.map((additionalUser) => additionalUser.userId)
|
|
}
|
|
: undefined,
|
|
category_id: (updated.category_id as string | null) ?? null,
|
|
subcategory_id: (updated.subcategory_id as string | null) ?? null,
|
|
location_id: (updated.location_id as string | null) ?? null,
|
|
due_date: (normalizedUpdatedAttributes.due_date as string | null | undefined) ?? null,
|
|
tags: (normalizedUpdatedAttributes.tags as string[] | undefined) ?? null
|
|
};
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.update_fields',
|
|
changedData: { ticket_id: input.ticket_id, before, after },
|
|
details: { action_id: 'tickets.update_fields', action_version: 1, ticket_id: input.ticket_id }
|
|
});
|
|
|
|
return {
|
|
ticket_id: input.ticket_id,
|
|
updated_at: new Date(updated.updated_at ?? new Date().toISOString()).toISOString(),
|
|
status_id: (updated.status_id as string | null) ?? null,
|
|
priority_id: (updated.priority_id as string | null) ?? null,
|
|
tags: (after.tags as string[] | null) ?? null
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A04 — tickets.assign
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.assign',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
assignment: buildWorkflowTicketAssignmentSchema({
|
|
dependencyPrefix: 'assignment',
|
|
requirePrimary: true,
|
|
}).describe('Ticket assignment configuration'),
|
|
reason: z.string().optional().describe('Optional assignment reason'),
|
|
comment: z.object({
|
|
body: z.string().min(1).describe('Optional assignment comment body'),
|
|
visibility: z.enum(['public', 'internal']).default('internal').describe('Comment visibility')
|
|
}).optional().describe('Optional assignment comment'),
|
|
no_op_if_already_assigned: z.boolean().default(true).describe('No-op if the resolved assignment already matches the ticket')
|
|
}),
|
|
outputSchema: z.object({
|
|
ticket_id: uuidSchema,
|
|
assigned_type: z.enum(['user', 'team', 'queue']),
|
|
assigned_id: uuidSchema,
|
|
assigned_to: uuidSchema.nullable(),
|
|
updated_at: isoDateTimeSchema
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'engineProvided' },
|
|
ui: {
|
|
label: 'Assign Ticket',
|
|
category: 'Business Operations',
|
|
description: 'Assign a ticket using the canonical workflow assignment model'
|
|
},
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
|
|
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 } });
|
|
}
|
|
|
|
const resolvedAssignment = await resolveWorkflowTicketAssignment(tx, ctx, input.assignment, {
|
|
requirePrimary: true,
|
|
});
|
|
const currentAdditionalUserIds = await getCurrentTicketAdditionalUserIds(tx, input.ticket_id);
|
|
const nextAdditionalUserIds = resolvedAssignment.additionalUsers.map((additionalUser) => additionalUser.userId).sort();
|
|
const currentAdditionalUserIdsSorted = [...currentAdditionalUserIds].sort();
|
|
const matchesCurrentAssignment =
|
|
(ticket.assigned_to as string | null) === resolvedAssignment.assignedTo &&
|
|
((ticket.assigned_team_id as string | null) ?? null) === resolvedAssignment.assignedTeamId &&
|
|
currentAdditionalUserIdsSorted.length === nextAdditionalUserIds.length &&
|
|
currentAdditionalUserIdsSorted.every((userId, index) => userId === nextAdditionalUserIds[index]);
|
|
|
|
if (input.no_op_if_already_assigned && matchesCurrentAssignment) {
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.assign',
|
|
changedData: {
|
|
ticket_id: input.ticket_id,
|
|
noop: true,
|
|
assignment: input.assignment,
|
|
assigned_to: resolvedAssignment.assignedTo,
|
|
reason: input.reason ?? null,
|
|
},
|
|
details: { action_id: 'tickets.assign', action_version: 1, ticket_id: input.ticket_id, assigned_to: resolvedAssignment.assignedTo, noop: true }
|
|
});
|
|
|
|
return {
|
|
ticket_id: input.ticket_id,
|
|
assigned_type: input.assignment.primary!.type,
|
|
assigned_id: input.assignment.primary!.id,
|
|
assigned_to: (ticket.assigned_to as string | null) ?? null,
|
|
updated_at: new Date(ticket.updated_at ?? new Date().toISOString()).toISOString()
|
|
};
|
|
}
|
|
|
|
let updated: any;
|
|
try {
|
|
await tx.trx('ticket_resources')
|
|
.where({ tenant: tx.tenantId, ticket_id: input.ticket_id })
|
|
.delete();
|
|
|
|
updated = await TicketModel.updateTicket(
|
|
input.ticket_id,
|
|
{
|
|
assigned_to: resolvedAssignment.assignedTo,
|
|
assigned_team_id: resolvedAssignment.assignedTeamId,
|
|
updated_by: tx.actorUserId,
|
|
} as any,
|
|
tx.tenantId,
|
|
tx.trx,
|
|
{},
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
|
|
await reconcileWorkflowTicketAdditionalUsers(
|
|
tx,
|
|
input.ticket_id,
|
|
resolvedAssignment.assignedTo,
|
|
resolvedAssignment.additionalUsers
|
|
);
|
|
} catch (error) {
|
|
rethrowAsStandardError(ctx, error);
|
|
}
|
|
|
|
let commentId: string | null = null;
|
|
if (input.comment?.body) {
|
|
try {
|
|
const comment = await TicketModel.createComment(
|
|
{
|
|
ticket_id: input.ticket_id,
|
|
content: input.comment.body,
|
|
is_internal: input.comment.visibility === 'internal',
|
|
is_resolution: false,
|
|
author_type: 'system',
|
|
author_id: tx.actorUserId,
|
|
metadata: { source: 'workflow', run_id: ctx.runId, step_path: ctx.stepPath, reason: input.reason ?? null }
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
commentId = (comment?.comment_id as string | undefined) ?? null;
|
|
} catch (error) {
|
|
rethrowAsStandardError(ctx, error);
|
|
}
|
|
}
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.assign',
|
|
changedData: {
|
|
ticket_id: input.ticket_id,
|
|
assignment: input.assignment,
|
|
assigned_to: resolvedAssignment.assignedTo,
|
|
additional_user_ids: resolvedAssignment.additionalUsers.map((additionalUser) => additionalUser.userId),
|
|
reason: input.reason ?? null,
|
|
comment_id: commentId,
|
|
},
|
|
details: { action_id: 'tickets.assign', action_version: 1, ticket_id: input.ticket_id, assigned_to: resolvedAssignment.assignedTo, comment_id: commentId }
|
|
});
|
|
|
|
return {
|
|
ticket_id: input.ticket_id,
|
|
assigned_type: input.assignment.primary!.type,
|
|
assigned_id: input.assignment.primary!.id,
|
|
assigned_to: (updated.assigned_to as string | null) ?? null,
|
|
updated_at: new Date(updated.updated_at ?? new Date().toISOString()).toISOString()
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A05 — tickets.close
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.close',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
resolution: z.object({
|
|
code: z.string().min(1).describe('Resolution code'),
|
|
text: z.string().min(1).optional().describe('Resolution text/summary')
|
|
}).describe('Resolution information'),
|
|
public_note: z.string().optional().describe('Optional public closure note'),
|
|
internal_note: z.string().optional().describe('Optional internal closure note'),
|
|
notify_requester: z.boolean().default(false).describe('Notify requester via email'),
|
|
email: z.object({
|
|
subject: z.string().optional(),
|
|
html: z.string().optional(),
|
|
text: z.string().optional()
|
|
}).optional().describe('Optional email overrides')
|
|
}),
|
|
outputSchema: z.object({
|
|
ticket_id: uuidSchema,
|
|
closed_at: isoDateTimeSchema,
|
|
resolution_code: z.string(),
|
|
final_status_id: uuidSchema
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'engineProvided' },
|
|
ui: {
|
|
label: 'Close Ticket',
|
|
category: 'Business Operations',
|
|
description: 'Close a ticket with resolution and optional notification'
|
|
},
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
|
|
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.closed_at) {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'CONFLICT', message: 'Ticket already closed', details: { ticket_id: input.ticket_id } });
|
|
}
|
|
|
|
const currentStatus = ticket.status_id
|
|
? await tx.trx('statuses')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
status_id: ticket.status_id,
|
|
status_type: 'ticket',
|
|
board_id: ticket.board_id,
|
|
})
|
|
.first()
|
|
: null;
|
|
if (currentStatus?.is_closed) {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'CONFLICT', message: 'Ticket is already in a closed status', details: { status_id: ticket.status_id } });
|
|
}
|
|
|
|
// Choose a closed status.
|
|
const closedStatus = await tx.trx('statuses')
|
|
.where({
|
|
tenant: tx.tenantId,
|
|
status_type: 'ticket',
|
|
board_id: ticket.board_id,
|
|
})
|
|
.andWhere('is_closed', true)
|
|
.orderBy('is_default', 'desc')
|
|
.orderBy('order_number', 'asc')
|
|
.first();
|
|
if (!closedStatus) {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'INTERNAL_ERROR', message: 'No closed ticket status configured' });
|
|
}
|
|
|
|
const nowIso = ctx.nowIso();
|
|
|
|
// Workflow closes are exempt from board close rules; the exemption is
|
|
// recorded as an audited bypass when the board has enabled gates.
|
|
await auditCloseRulesBypassIfGated(
|
|
tx.trx,
|
|
tx.tenantId,
|
|
input.ticket_id,
|
|
ticket.board_id,
|
|
'workflow',
|
|
{ actorType: TICKET_ACTIVITY_ACTOR.WORKFLOW, userId: tx.actorUserId ?? null },
|
|
TICKET_ACTIVITY_SOURCE.WORKFLOW
|
|
);
|
|
|
|
// Update ticket closure fields.
|
|
await tx.trx('tickets')
|
|
.where({ tenant: tx.tenantId, ticket_id: input.ticket_id })
|
|
.update({
|
|
status_id: closedStatus.status_id,
|
|
is_closed: true,
|
|
closed_at: nowIso,
|
|
closed_by: tx.actorUserId,
|
|
resolution_code: input.resolution.code,
|
|
updated_at: nowIso,
|
|
updated_by: tx.actorUserId
|
|
});
|
|
|
|
if (input.public_note) {
|
|
await TicketModel.createComment(
|
|
{
|
|
ticket_id: input.ticket_id,
|
|
content: input.public_note,
|
|
is_internal: false,
|
|
is_resolution: true,
|
|
author_type: 'system',
|
|
author_id: tx.actorUserId,
|
|
metadata: { source: 'workflow', run_id: ctx.runId, step_path: ctx.stepPath }
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
}
|
|
|
|
if (input.internal_note) {
|
|
await TicketModel.createComment(
|
|
{
|
|
ticket_id: input.ticket_id,
|
|
content: input.internal_note,
|
|
is_internal: true,
|
|
is_resolution: true,
|
|
author_type: 'system',
|
|
author_id: tx.actorUserId,
|
|
metadata: { source: 'workflow', run_id: ctx.runId, step_path: ctx.stepPath }
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
}
|
|
|
|
if (input.notify_requester) {
|
|
const contactId = (ticket.contact_name_id as string | null) ?? null;
|
|
if (!contactId) {
|
|
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Ticket has no requester contact to notify' });
|
|
}
|
|
const contact = await tx.trx('contacts').where({ tenant: tx.tenantId, contact_name_id: contactId }).first();
|
|
const email = contact?.email ? String(contact.email) : null;
|
|
if (!email) {
|
|
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Requester contact has no email address' });
|
|
}
|
|
|
|
const { TenantEmailService, StaticTemplateProcessor } = getWorkflowEmailProvider();
|
|
const service = TenantEmailService.getInstance(tx.tenantId);
|
|
const subject = input.email?.subject ?? `Ticket ${ticket.ticket_number ?? ''} closed`;
|
|
const html = input.email?.html ?? `<p>Your ticket has been closed.</p><p>Resolution: ${input.resolution.code}</p>`;
|
|
const text = input.email?.text ?? `Your ticket has been closed.\nResolution: ${input.resolution.code}`;
|
|
const templateProcessor = new StaticTemplateProcessor(subject, html, text);
|
|
const result = await service.sendEmail({
|
|
tenantId: tx.tenantId,
|
|
to: { email },
|
|
templateProcessor,
|
|
templateData: {
|
|
ticket: {
|
|
ticketNumber: ticket.ticket_number ?? null,
|
|
title: ticket.title ?? null,
|
|
resolutionCode: input.resolution.code,
|
|
resolutionText: input.resolution.text ?? null
|
|
}
|
|
}
|
|
} as any);
|
|
if (!result.success) {
|
|
throwActionError(ctx, { category: 'TransientError', code: 'TRANSIENT_FAILURE', message: result.error ?? 'Failed to send requester email' });
|
|
}
|
|
}
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.close',
|
|
changedData: { ticket_id: input.ticket_id, closed_at: nowIso, resolution_code: input.resolution.code },
|
|
details: { action_id: 'tickets.close', action_version: 1, ticket_id: input.ticket_id, closed_at: nowIso }
|
|
});
|
|
|
|
return {
|
|
ticket_id: input.ticket_id,
|
|
closed_at: nowIso,
|
|
resolution_code: input.resolution.code,
|
|
final_status_id: closedStatus.status_id as string
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A06 — tickets.link_entities
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.link_entities',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
entity_type: z.enum(['project', 'project_task', 'asset', 'contract']).describe('Entity type'),
|
|
entity_id: uuidSchema.describe('Entity id'),
|
|
link_type: z.string().min(1).describe('Link type'),
|
|
metadata: z.record(z.unknown()).optional().describe('Optional link metadata')
|
|
}),
|
|
outputSchema: z.object({
|
|
link_id: uuidSchema,
|
|
entity_type: z.string(),
|
|
entity_id: uuidSchema,
|
|
link_type: z.string(),
|
|
linked_entity_summary: z.object({
|
|
type: z.string(),
|
|
id: uuidSchema,
|
|
name: z.string().nullable(),
|
|
url: z.string().nullable()
|
|
})
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'engineProvided' },
|
|
ui: { label: 'Link Ticket Entity', category: 'Business Operations', description: 'Link a ticket to another entity' },
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
|
|
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' });
|
|
}
|
|
|
|
let linkedEntitySummary: { type: string; id: string; name: string | null; url: string | null } = {
|
|
type: input.entity_type,
|
|
id: input.entity_id,
|
|
name: null,
|
|
url: null
|
|
};
|
|
|
|
// Entity existence checks
|
|
if (input.entity_type === 'project') {
|
|
const project = await tx.trx('projects').where({ tenant: tx.tenantId, project_id: input.entity_id }).first();
|
|
if (!project) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Project not found' });
|
|
linkedEntitySummary = {
|
|
type: 'project',
|
|
id: input.entity_id,
|
|
name: (project.project_name as string | null) ?? null,
|
|
url: null
|
|
};
|
|
} else if (input.entity_type === 'project_task') {
|
|
const task = await tx.trx('project_tasks').where({ tenant: tx.tenantId, task_id: input.entity_id }).first();
|
|
if (!task) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Project task not found' });
|
|
linkedEntitySummary = {
|
|
type: 'project_task',
|
|
id: input.entity_id,
|
|
name: (task.task_name as string | null) ?? null,
|
|
url: null
|
|
};
|
|
} else if (input.entity_type === 'asset') {
|
|
const asset = await tx.trx('assets').where({ tenant: tx.tenantId, asset_id: input.entity_id }).first();
|
|
if (!asset) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Asset not found' });
|
|
const assetName =
|
|
(asset.asset_name as string | null | undefined) ??
|
|
(asset.name as string | null | undefined) ??
|
|
(asset.asset_tag as string | null | undefined) ??
|
|
null;
|
|
linkedEntitySummary = { type: 'asset', id: input.entity_id, name: assetName, url: null };
|
|
} else if (input.entity_type === 'contract') {
|
|
const contract = await tx.trx('contracts').where({ tenant: tx.tenantId, contract_id: input.entity_id }).first().catch(() => null);
|
|
if (!contract) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Contract not found' });
|
|
const contractName =
|
|
(contract.contract_name as string | null | undefined) ??
|
|
(contract.name as string | null | undefined) ??
|
|
(contract.contract_number as string | null | undefined) ??
|
|
null;
|
|
linkedEntitySummary = { type: 'contract', id: input.entity_id, name: contractName, url: null };
|
|
}
|
|
|
|
const linkId = uuidv4();
|
|
const nowIso = new Date().toISOString();
|
|
|
|
// Generic polymorphic link table (added via migration in this plan).
|
|
try {
|
|
await tx.trx('ticket_entity_links').insert({
|
|
tenant: tx.tenantId,
|
|
link_id: linkId,
|
|
ticket_id: input.ticket_id,
|
|
entity_type: input.entity_type,
|
|
entity_id: input.entity_id,
|
|
link_type: input.link_type,
|
|
metadata: input.metadata ?? null,
|
|
created_at: nowIso
|
|
});
|
|
} catch (error) {
|
|
const msg = error instanceof Error ? error.message : String(error);
|
|
if (/duplicate|unique/i.test(msg)) {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'CONFLICT', message: 'Link already exists' });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.link_entities',
|
|
changedData: { ticket_id: input.ticket_id, entity_type: input.entity_type, entity_id: input.entity_id, link_type: input.link_type },
|
|
details: { action_id: 'tickets.link_entities', action_version: 1, link_id: linkId }
|
|
});
|
|
|
|
return {
|
|
link_id: linkId,
|
|
entity_type: input.entity_type,
|
|
entity_id: input.entity_id,
|
|
link_type: input.link_type,
|
|
linked_entity_summary: linkedEntitySummary
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A07 — tickets.add_attachment
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.add_attachment',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
source: attachmentSourceSchema.describe('Attachment source'),
|
|
filename: z.string().optional().describe('Optional filename'),
|
|
visibility: z.enum(['public', 'internal']).optional().describe('Visibility (currently informational)'),
|
|
comment: z.object({
|
|
body: z.string().min(1),
|
|
visibility: z.enum(['public', 'internal']).default('public')
|
|
}).optional().describe('Optional comment to add alongside the attachment'),
|
|
idempotency_key: z.string().optional().describe('Optional external idempotency key')
|
|
}),
|
|
outputSchema: z.object({
|
|
attachment_id: uuidSchema.describe('Document id used as the attachment identifier'),
|
|
filename: z.string(),
|
|
mime_type: z.string().nullable(),
|
|
storage_ref: z.string().nullable().describe('Storage file id (external_files.file_id) when available')
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
|
|
ui: { label: 'Add Ticket Attachment', category: 'Business Operations', description: 'Attach a document/file to a ticket' },
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
await requirePermission(ctx, tx, { resource: 'document', action: 'create' });
|
|
const attached = await attachDocumentToTicket(ctx, tx, input.ticket_id, {
|
|
source: input.source,
|
|
filename: input.filename ?? null,
|
|
visibility: input.visibility
|
|
});
|
|
|
|
if (input.comment?.body) {
|
|
await TicketModel.createComment(
|
|
{
|
|
ticket_id: input.ticket_id,
|
|
content: input.comment.body,
|
|
is_internal: input.comment.visibility === 'internal',
|
|
is_resolution: false,
|
|
author_type: 'system',
|
|
author_id: tx.actorUserId,
|
|
metadata: { source: 'workflow', attachment_id: attached.document_id, run_id: ctx.runId, step_path: ctx.stepPath }
|
|
},
|
|
tx.tenantId,
|
|
tx.trx,
|
|
undefined,
|
|
undefined,
|
|
tx.actorUserId
|
|
);
|
|
}
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.add_attachment',
|
|
changedData: { ticket_id: input.ticket_id, document_id: attached.document_id },
|
|
details: { action_id: 'tickets.add_attachment', action_version: 1, ticket_id: input.ticket_id, document_id: attached.document_id }
|
|
});
|
|
return {
|
|
attachment_id: attached.document_id,
|
|
filename: attached.filename,
|
|
mime_type: attached.content_type ?? null,
|
|
storage_ref: attached.file_id ?? null
|
|
};
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A08 — tickets.find
|
|
// ---------------------------------------------------------------------------
|
|
const ticketSummarySchema = z.object({
|
|
ticket_id: uuidSchema,
|
|
ticket_number: z.string(),
|
|
title: z.string().nullable(),
|
|
url: z.string().nullable(),
|
|
company_id: uuidSchema.nullable(),
|
|
contact_name_id: uuidSchema.nullable(),
|
|
status_id: uuidSchema.nullable(),
|
|
priority_id: uuidSchema.nullable(),
|
|
category_id: uuidSchema.nullable(),
|
|
subcategory_id: uuidSchema.nullable(),
|
|
assigned_to: uuidSchema.nullable(),
|
|
entered_at: isoDateTimeSchema.nullable(),
|
|
updated_at: isoDateTimeSchema.nullable(),
|
|
closed_at: isoDateTimeSchema.nullable(),
|
|
is_closed: z.boolean().nullable(),
|
|
attributes: z.record(z.unknown()).optional()
|
|
});
|
|
|
|
const ticketCommentSchema = z.object({
|
|
comment_id: uuidSchema,
|
|
note: z.string(),
|
|
is_internal: z.boolean(),
|
|
is_resolution: z.boolean(),
|
|
is_initial_description: z.boolean(),
|
|
created_at: isoDateTimeSchema,
|
|
user_id: uuidSchema.nullable(),
|
|
contact_name_id: uuidSchema.nullable()
|
|
});
|
|
|
|
const ticketAttachmentSchema = z.object({
|
|
document_id: uuidSchema,
|
|
document_name: z.string(),
|
|
file_id: uuidSchema.nullable(),
|
|
mime_type: z.string().nullable(),
|
|
associated_at: isoDateTimeSchema.nullable()
|
|
});
|
|
|
|
registry.register({
|
|
id: 'tickets.find',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.optional().describe('Ticket id'),
|
|
ticket_number: z.string().optional().describe('Ticket number'),
|
|
external_ref: z.string().optional().describe('External reference (stored in tickets.attributes.external_ref)'),
|
|
on_not_found: z.enum(['return_null', 'error']).default('return_null'),
|
|
include: z.object({
|
|
comments: z.boolean().optional(),
|
|
attachments: z.boolean().optional(),
|
|
attributes: z.boolean().optional().describe('Include ticket.attributes (raw JSON)'),
|
|
custom_fields: z.boolean().optional().describe('Alias for include.attributes'),
|
|
comments_limit: z.number().int().positive().max(200).optional(),
|
|
attachments_limit: z.number().int().positive().max(200).optional()
|
|
}).optional()
|
|
}).refine((val) => Boolean(val.ticket_id || val.ticket_number || val.external_ref), { message: 'ticket_id, ticket_number, or external_ref is required' }),
|
|
outputSchema: z.object({
|
|
ticket: ticketSummarySchema.nullable(),
|
|
comments: z.array(ticketCommentSchema).optional(),
|
|
attachments: z.array(ticketAttachmentSchema).optional()
|
|
}),
|
|
sideEffectful: false,
|
|
idempotency: { mode: 'engineProvided' },
|
|
ui: { label: 'Find Ticket', category: 'Business Operations', description: 'Fetch a ticket by id or number' },
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'read' });
|
|
|
|
const startedAt = Date.now();
|
|
let ticket: any = null;
|
|
if (input.ticket_id) {
|
|
ticket = await tx.trx('tickets').where({ tenant: tx.tenantId, ticket_id: input.ticket_id }).first();
|
|
} else if (input.ticket_number) {
|
|
ticket = await tx.trx('tickets').where({ tenant: tx.tenantId, ticket_number: input.ticket_number }).first();
|
|
} else if (input.external_ref) {
|
|
ticket = await tx.trx('tickets')
|
|
.where({ tenant: tx.tenantId })
|
|
.andWhereRaw(`(attributes->>'external_ref') = ?`, [String(input.external_ref)])
|
|
.first();
|
|
}
|
|
|
|
if (!ticket) {
|
|
if (input.on_not_found === 'error') {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Ticket not found' });
|
|
}
|
|
return { ticket: null, comments: [], attachments: [] };
|
|
}
|
|
|
|
const include = input.include ?? {};
|
|
const includeAttributes = Boolean(include.attributes || include.custom_fields);
|
|
const attrs = includeAttributes ? parseJsonMaybe(ticket.attributes) : undefined;
|
|
|
|
const parsedTicket = ticketSummarySchema.parse({
|
|
ticket_id: ticket.ticket_id,
|
|
ticket_number: ticket.ticket_number,
|
|
title: ticket.title ?? null,
|
|
url: ticket.url ?? null,
|
|
company_id: ticket.company_id ?? null,
|
|
contact_name_id: ticket.contact_name_id ?? null,
|
|
status_id: ticket.status_id ?? null,
|
|
priority_id: ticket.priority_id ?? null,
|
|
category_id: ticket.category_id ?? null,
|
|
subcategory_id: ticket.subcategory_id ?? null,
|
|
assigned_to: ticket.assigned_to ?? null,
|
|
entered_at: ticket.entered_at ? new Date(ticket.entered_at).toISOString() : null,
|
|
updated_at: ticket.updated_at ? new Date(ticket.updated_at).toISOString() : null,
|
|
closed_at: ticket.closed_at ? new Date(ticket.closed_at).toISOString() : null,
|
|
is_closed: ticket.is_closed ?? null,
|
|
...(includeAttributes ? { attributes: (attrs && typeof attrs === 'object' && !Array.isArray(attrs)) ? attrs : {} } : {})
|
|
});
|
|
|
|
const result: any = { ticket: parsedTicket };
|
|
|
|
if (include.comments) {
|
|
const rows = await tx.trx('comments')
|
|
.where({ tenant: tx.tenantId, ticket_id: ticket.ticket_id })
|
|
.orderBy('created_at', 'asc')
|
|
.limit(include.comments_limit ?? 50);
|
|
result.comments = rows.map((row: any) => ticketCommentSchema.parse({
|
|
comment_id: row.comment_id,
|
|
note: row.note,
|
|
is_internal: Boolean(row.is_internal),
|
|
is_resolution: Boolean(row.is_resolution),
|
|
is_initial_description: Boolean(row.is_initial_description),
|
|
created_at: new Date(row.created_at ?? new Date().toISOString()).toISOString(),
|
|
user_id: row.user_id ?? null,
|
|
contact_name_id: row.contact_name_id ?? null
|
|
}));
|
|
}
|
|
|
|
if (include.attachments) {
|
|
const rows = await tx.trx('document_associations as da')
|
|
.join('documents as d', function joinDocs() {
|
|
this.on('da.tenant', 'd.tenant').andOn('da.document_id', 'd.document_id');
|
|
})
|
|
.where({ 'da.tenant': tx.tenantId, 'da.entity_type': 'ticket', 'da.entity_id': ticket.ticket_id })
|
|
.select('d.document_id', 'd.document_name', 'd.file_id', 'd.mime_type', 'da.created_at as associated_at');
|
|
result.attachments = rows.slice(0, include.attachments_limit ?? 50).map((row: any) => ticketAttachmentSchema.parse({
|
|
document_id: row.document_id,
|
|
document_name: row.document_name,
|
|
file_id: row.file_id ?? null,
|
|
mime_type: row.mime_type ?? null,
|
|
associated_at: row.associated_at ? new Date(row.associated_at).toISOString() : null
|
|
}));
|
|
}
|
|
|
|
const durationMs = Date.now() - startedAt;
|
|
ctx.logger?.info('workflow_action:tickets.find', {
|
|
duration_ms: durationMs,
|
|
include_comments: Boolean(include.comments),
|
|
include_attachments: Boolean(include.attachments),
|
|
comments_count: Array.isArray(result.comments) ? result.comments.length : 0,
|
|
attachments_count: Array.isArray(result.attachments) ? result.attachments.length : 0
|
|
});
|
|
|
|
return result;
|
|
})
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// A09 — tickets.apply_checklist
|
|
// ---------------------------------------------------------------------------
|
|
registry.register({
|
|
id: 'tickets.apply_checklist',
|
|
version: 1,
|
|
inputSchema: z.object({
|
|
ticket_id: uuidSchema.describe('Ticket id'),
|
|
template_id: uuidSchema.describe('Checklist template id')
|
|
}),
|
|
outputSchema: z.object({
|
|
ticket_id: uuidSchema,
|
|
template_id: uuidSchema,
|
|
applied: z.boolean().describe('False when the template was already on the ticket (idempotent no-op)'),
|
|
items_added: z.number().int()
|
|
}),
|
|
sideEffectful: true,
|
|
idempotency: { mode: 'engineProvided' },
|
|
ui: {
|
|
label: 'Apply checklist template',
|
|
category: 'Business Operations',
|
|
description: 'Copy a checklist template\'s items onto a ticket (never applies the same template twice)'
|
|
},
|
|
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
|
|
await requirePermission(ctx, tx, { resource: 'ticket', action: 'update' });
|
|
|
|
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 } });
|
|
}
|
|
|
|
let applyResult;
|
|
try {
|
|
applyResult = await applyChecklistTemplateToTicket(
|
|
tx.trx,
|
|
tx.tenantId,
|
|
input.ticket_id,
|
|
input.template_id,
|
|
'workflow',
|
|
{
|
|
actor: { actorType: TICKET_ACTIVITY_ACTOR.WORKFLOW, userId: tx.actorUserId ?? null },
|
|
source: TICKET_ACTIVITY_SOURCE.WORKFLOW,
|
|
}
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof Error && error.message === 'Checklist template not found') {
|
|
throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Checklist template not found', details: { template_id: input.template_id } });
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
await writeRunAudit(ctx, tx, {
|
|
operation: 'workflow_action:tickets.apply_checklist',
|
|
changedData: { ticket_id: input.ticket_id, template_id: input.template_id, items_added: applyResult.itemsAdded },
|
|
details: { action_id: 'tickets.apply_checklist', action_version: 1, applied: applyResult.applied }
|
|
});
|
|
|
|
return {
|
|
ticket_id: input.ticket_id,
|
|
template_id: input.template_id,
|
|
applied: applyResult.applied,
|
|
items_added: applyResult.itemsAdded
|
|
};
|
|
})
|
|
});
|
|
}
|