import { z } from 'zod'; import type { Knex } from 'knex'; import { v4 as uuidv4 } from 'uuid'; import type { ActionContext } from '../../registries/actionRegistry'; export type TenantTxContext = { tenantId: string; actorUserId: string; trx: Knex.Transaction; }; export type ActionErrorCategory = 'ValidationError' | 'ActionError' | 'TransientError'; export function throwActionError( ctx: ActionContext, params: { category: ActionErrorCategory; code: string; message: string; details?: Record } ): never { throw { category: params.category, code: params.code, message: params.message, details: params.details ?? null, nodePath: ctx.stepPath, at: new Date().toISOString() }; } export function rethrowAsStandardError(ctx: ActionContext, error: unknown): never { const message = error instanceof Error ? error.message : String(error); if (error instanceof z.ZodError) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message }); } if (/VALIDATION_ERROR:|validation failed|input validation failed/i.test(message)) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message }); } if (/not found/i.test(message)) { throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message }); } if (/duplicate|unique|conflict/i.test(message)) { throwActionError(ctx, { category: 'ActionError', code: 'CONFLICT', message }); } if (/rate limit/i.test(message)) { throwActionError(ctx, { category: 'TransientError', code: 'RATE_LIMITED', message }); } if (/deadlock|timeout|temporar/i.test(message)) { throwActionError(ctx, { category: 'TransientError', code: 'TRANSIENT_FAILURE', message }); } throwActionError(ctx, { category: 'ActionError', code: 'INTERNAL_ERROR', message }); } export function parseJsonMaybe(value: unknown): any { if (value === null || value === undefined) return null; if (typeof value === 'string') { try { return JSON.parse(value); } catch { return null; } } if (typeof value === 'object') return value; return null; } export function isJsonArrayString(value: string): boolean { try { const parsed = JSON.parse(value); return Array.isArray(parsed); } catch { return false; } } export function buildBlockNoteWithMentions(params: { body: string; mentions?: string[] | null }): string { const body = params.body ?? ''; if (isJsonArrayString(body)) return body; const mentions = (params.mentions ?? []).filter((value) => String(value).trim()); const paragraph: any = { type: 'paragraph', content: [] as any[] }; for (const userId of mentions) { paragraph.content.push({ type: 'mention', props: { userId: String(userId) } }); } if (body.trim()) { paragraph.content.push({ type: 'text', text: `${mentions.length ? ' ' : ''}${body}` }); } return JSON.stringify([paragraph]); } export async function setTenantContext(trx: Knex.Transaction, tenantId: string): Promise { await trx.raw(`select set_config('app.current_tenant', ?, true)`, [tenantId]); } export async function resolveRunActorUserId(trx: Knex.Transaction, runId: string): Promise { const row = await trx('workflow_runs as wr') .leftJoin('workflow_definition_versions as wdv', function joinVersions() { this.on('wr.workflow_id', 'wdv.workflow_id') .andOn('wr.workflow_version', 'wdv.version') .andOn('wr.tenant', 'wdv.tenant'); }) .leftJoin('workflow_definitions as wd', function joinDefinitions() { this.on('wr.workflow_id', 'wd.workflow_id').andOn('wr.tenant', 'wd.tenant'); }) .select( 'wd.workflow_id as matched_workflow_id', 'wdv.published_by as published_by', 'wd.created_by as created_by' ) .where('wr.run_id', runId) .first(); if (!row?.matched_workflow_id) return null; return (row?.published_by as string | null) ?? (row?.created_by as string | null) ?? null; } export async function hasPermissionByUserId( trx: Knex.Transaction, tenantId: string, userId: string, resource: string, action: string ): Promise { const row = await trx('user_roles as ur') .join('roles as r', function joinRoles() { this.on('ur.tenant', 'r.tenant').andOn('ur.role_id', 'r.role_id'); }) .join('role_permissions as rp', function joinRolePerms() { this.on('r.tenant', 'rp.tenant').andOn('r.role_id', 'rp.role_id'); }) .join('permissions as p', function joinPerms() { this.on('rp.tenant', 'p.tenant').andOn('rp.permission_id', 'p.permission_id'); }) .where({ 'ur.tenant': tenantId, 'ur.user_id': userId, 'p.resource': resource, 'p.action': action }) // Default to MSP portal permissions for workflow executions. .where('p.msp', true) .where('r.msp', true) .first(); return !!row; } export async function requirePermission( ctx: ActionContext, tx: TenantTxContext, permission: { resource: string; action: string } ): Promise { const ok = await hasPermissionByUserId(tx.trx, tx.tenantId, tx.actorUserId, permission.resource, permission.action); if (ok) return; throwActionError(ctx, { category: 'ActionError', code: 'PERMISSION_DENIED', message: `Permission denied: ${permission.resource}:${permission.action}`, details: permission }); } export async function writeRunAudit( ctx: ActionContext, tx: TenantTxContext, params: { operation: string; changedData?: Record; details?: Record } ): Promise { await tx.trx('audit_logs').insert({ audit_id: uuidv4(), tenant: tx.tenantId, user_id: tx.actorUserId, operation: params.operation, table_name: 'workflow_runs', record_id: ctx.runId, changed_data: params.changedData ?? {}, details: { action_id: params.details?.action_id ?? null, action_version: params.details?.action_version ?? null, step_path: ctx.stepPath, ...params.details }, timestamp: new Date().toISOString() }); } export async function withTenantTransaction( ctx: ActionContext, fn: (tx: TenantTxContext) => Promise ): Promise { const tenantId = ctx.tenantId ?? null; if (!tenantId) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'tenantId is required' }); } const knex = ctx.knex as Knex | undefined; if (!knex) { throwActionError(ctx, { category: 'ActionError', code: 'INTERNAL_ERROR', message: 'Database connection unavailable' }); } return await knex.transaction(async (trx) => { await setTenantContext(trx, tenantId); const actorUserId = await resolveRunActorUserId(trx, ctx.runId); if (!actorUserId) { throwActionError(ctx, { category: 'ActionError', code: 'INTERNAL_ERROR', message: 'Workflow actor user not found' }); } return await fn({ tenantId, actorUserId, trx }); }); } export const uuidSchema = z.string().uuid(); export const isoDateTimeSchema = z.string().datetime(); export const attachmentSourceSchema = z.object({ file_id: uuidSchema.optional().describe('Existing storage file id (external_files.file_id)'), document_id: uuidSchema.optional().describe('Existing document id (documents.document_id)'), url: z.string().url().optional().describe('URL to download and ingest into storage') }).refine((val) => Boolean(val.file_id || val.document_id || val.url), { message: 'One of file_id, document_id, or url is required' }); export type AttachmentSource = z.infer; export const MAX_ATTACHMENT_BYTES = Number(process.env.WORKFLOW_ACTION_ATTACHMENT_MAX_BYTES ?? 10 * 1024 * 1024); export const ALLOWED_ATTACHMENT_MIME_TYPES = new Set([ 'application/pdf', 'text/plain', 'image/png', 'image/jpeg', 'image/webp', 'application/octet-stream' ]); export function isAllowedAttachmentMimeType(mimeType: string | null): boolean { if (!mimeType) return true; const normalized = mimeType.split(';')[0]?.trim().toLowerCase() ?? mimeType.toLowerCase(); return ALLOWED_ATTACHMENT_MIME_TYPES.has(normalized); } export async function attachDocumentToTicket( ctx: ActionContext, tx: TenantTxContext, ticketId: string, input: { source: AttachmentSource; filename?: string | null; visibility?: 'public' | 'internal' } ): Promise<{ document_id: string; file_id?: string | null; filename: string; content_type?: string | null }> { // Ensure ticket exists. 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 } }); } const nowIso = new Date().toISOString(); // If this is already a document, just associate it to the ticket. if (input.source.document_id) { const doc = await tx.trx('documents').where({ tenant: tx.tenantId, document_id: input.source.document_id }).first(); if (!doc) { throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Document not found', details: { document_id: input.source.document_id } }); } await tx.trx('document_associations') .insert({ tenant: tx.tenantId, document_id: input.source.document_id, entity_id: ticketId, entity_type: 'ticket', created_at: nowIso }) .onConflict(['tenant', 'document_id', 'entity_id', 'entity_type']) .ignore(); return { document_id: input.source.document_id, file_id: (doc.file_id as string | null) ?? null, filename: (doc.document_name as string) ?? 'document', content_type: (doc.mime_type as string | null) ?? null }; } // For file_id or url, ensure we have a file record (create one if url). let fileRecord: any | null = null; if (input.source.file_id) { fileRecord = await tx.trx('external_files').where({ tenant: tx.tenantId, file_id: input.source.file_id, is_deleted: false }).first(); if (!fileRecord) { throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'File not found', details: { file_id: input.source.file_id } }); } if (typeof fileRecord.file_size === 'number' && fileRecord.file_size > MAX_ATTACHMENT_BYTES) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Attachment too large' }); } if (!isAllowedAttachmentMimeType(fileRecord.mime_type ?? null)) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Attachment mime_type not allowed' }); } } else if (input.source.url) { const url = new URL(input.source.url); if (url.protocol !== 'http:' && url.protocol !== 'https:') { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Only http(s) URLs are allowed for attachment ingest' }); } let buffer: Buffer; let contentType: string | null = null; try { const response = await (globalThis as any).fetch(input.source.url); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const arrayBuf = await response.arrayBuffer(); buffer = Buffer.from(arrayBuf); contentType = response.headers.get('content-type'); } catch (error) { throwActionError(ctx, { category: 'TransientError', code: 'TRANSIENT_FAILURE', message: 'Failed to download attachment URL', details: { url: input.source.url, error: error instanceof Error ? error.message : String(error) } }); } if (buffer.length > MAX_ATTACHMENT_BYTES) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Attachment too large' }); } if (!isAllowedAttachmentMimeType(contentType)) { throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Attachment mime_type not allowed' }); } const { StorageProviderFactory, generateStoragePath } = await import('@alga-psa/storage'); const provider = await StorageProviderFactory.createProvider(); const filename = input.filename ?? new URL(input.source.url).pathname.split('/').filter(Boolean).pop() ?? 'attachment.bin'; const storagePath = generateStoragePath(tx.tenantId, '', filename); let uploadPath: string; try { const result = await provider.upload(buffer, storagePath, { mime_type: contentType ?? 'application/octet-stream' } as any); uploadPath = (result as any).path ?? storagePath; } catch (error) { throwActionError(ctx, { category: 'TransientError', code: 'TRANSIENT_FAILURE', message: 'Failed to upload attachment to storage', details: { error: error instanceof Error ? error.message : String(error) } }); } const fileId = uuidv4(); const fileName = storagePath.split('/').pop() ?? filename; await tx.trx('external_files').insert({ tenant: tx.tenantId, file_id: fileId, file_name: fileName, original_name: filename, mime_type: contentType ?? 'application/octet-stream', file_size: buffer.length, storage_path: uploadPath, uploaded_by_id: tx.actorUserId, created_at: nowIso, updated_at: nowIso, is_deleted: false, metadata: { source: 'workflow', url: input.source.url } }); fileRecord = { file_id: fileId, file_name: fileName, original_name: filename, mime_type: contentType ?? 'application/octet-stream', file_size: buffer.length, storage_path: uploadPath }; } if (!fileRecord) { throwActionError(ctx, { category: 'ActionError', code: 'INTERNAL_ERROR', message: 'Attachment source resolution failed' }); } // Create a document pointing at the file, then associate it to the ticket. const documentId = uuidv4(); const documentName = input.filename ?? fileRecord.original_name ?? fileRecord.file_name ?? 'attachment'; await tx.trx('documents').insert({ tenant: tx.tenantId, document_id: documentId, document_name: documentName, type_id: null, shared_type_id: null, user_id: tx.actorUserId, created_by: tx.actorUserId, entered_at: nowIso, updated_at: nowIso, file_id: fileRecord.file_id, storage_path: fileRecord.storage_path, mime_type: fileRecord.mime_type, file_size: fileRecord.file_size }); await tx.trx('document_associations').insert({ tenant: tx.tenantId, document_id: documentId, entity_id: ticketId, entity_type: 'ticket', created_at: nowIso }); return { document_id: documentId, file_id: fileRecord.file_id ?? null, filename: documentName, content_type: fileRecord.mime_type ?? null }; } export function actionProvidedKey(input: { idempotency_key?: string | null }, ctx: ActionContext): string { if (input.idempotency_key && String(input.idempotency_key).trim()) return String(input.idempotency_key).trim(); return `run:${ctx.runId}:${ctx.stepPath}`; }