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

3243 lines
128 KiB
TypeScript

import { z } from 'zod';
import type { Knex } from 'knex';
import { v4 as uuidv4 } from 'uuid';
import { generateKeyBetween } from 'fractional-indexing';
import { getDeletionConfig, validateDeletion } from '@alga-psa/core';
import {
BuiltinAuthorizationKernelProvider,
BundleAuthorizationKernelProvider,
RequestLocalAuthorizationCache,
createAuthorizationKernel,
type AuthorizationRecord,
type AuthorizationSubject,
} from '@alga-psa/authorization/kernel';
import { resolveBundleNarrowingRulesForEvaluation } from '@alga-psa/authorization/bundles/service';
import { withWorkflowJsonSchemaMetadata } from '../../jsonSchemaMetadata';
import { getActionRegistryV2 } from '../../registries/actionRegistry';
import {
uuidSchema,
isoDateTimeSchema,
actionProvidedKey,
withTenantTransaction,
requirePermission,
writeRunAudit,
throwActionError,
rethrowAsStandardError,
type TenantTxContext,
} from './shared';
const WORKFLOW_PICKER_HINTS = {
project: 'Search projects',
'project-phase': 'Search project phases',
'project-task': 'Search project tasks',
'project-task-status': 'Search project task statuses',
ticket: 'Search tickets',
user: 'Search users',
} 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 nullableUuidSchema = z.union([uuidSchema, z.null()]);
const projectSummarySchema = z.object({
project_id: uuidSchema,
project_name: z.string(),
description: z.string().nullable(),
client_id: nullableUuidSchema,
status: z.string().nullable(),
assigned_to: nullableUuidSchema,
wbs_code: z.string().nullable(),
updated_at: isoDateTimeSchema.optional(),
});
const phaseSummarySchema = z.object({
phase_id: uuidSchema,
project_id: uuidSchema,
phase_name: z.string(),
description: z.string().nullable(),
status: z.string().nullable(),
order_number: z.number().int().nullable(),
order_key: z.string().nullable(),
wbs_code: z.string().nullable(),
updated_at: isoDateTimeSchema.optional(),
});
const taskSummarySchema = z.object({
task_id: uuidSchema,
project_id: uuidSchema,
phase_id: uuidSchema,
task_name: z.string(),
description: z.string().nullable(),
assigned_to: nullableUuidSchema,
status_id: nullableUuidSchema,
project_status_mapping_id: nullableUuidSchema,
wbs_code: z.string().nullable(),
order_key: z.string().nullable(),
updated_at: isoDateTimeSchema.optional(),
});
const statusMappingSummarySchema = z.object({
project_status_mapping_id: uuidSchema,
project_id: uuidSchema,
phase_id: nullableUuidSchema.optional(),
status_id: nullableUuidSchema,
standard_status_id: nullableUuidSchema,
custom_name: z.string().nullable(),
display_order: z.number().int().nullable(),
is_visible: z.boolean().nullable(),
is_standard: z.boolean().nullable(),
});
const tagResultSchema = z.object({
tag_id: uuidSchema,
tag_text: z.string(),
mapping_id: uuidSchema.optional(),
});
const assignmentResultSchema = z.object({
task_id: uuidSchema,
assigned_to: nullableUuidSchema,
additional_user_ids: z.array(uuidSchema),
no_op: z.boolean(),
updated_at: isoDateTimeSchema,
});
const linkResultSchema = z.object({
task_id: uuidSchema,
ticket_id: uuidSchema,
project_ticket_link_created: z.boolean(),
ticket_entity_link_created: z.boolean(),
});
const statusMappingOrStatusPicker = withWorkflowPicker(
uuidSchema,
'Project task status mapping id',
'project-task-status',
['project_id', 'phase_id']
);
const projectUpdatePatchSchema = z.object({
project_name: z.string().min(1).optional(),
description: z.string().nullable().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 hasDefined = keys.some((key) => (value as Record<string, unknown>)[key] !== undefined);
if (!hasDefined) {
refinementCtx.addIssue({ code: z.ZodIssueCode.custom, message: 'patch must include at least one defined value' });
}
});
const phaseUpdatePatchSchema = z.object({
phase_name: z.string().min(1).optional(),
description: z.string().nullable().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 hasDefined = keys.some((key) => (value as Record<string, unknown>)[key] !== undefined);
if (!hasDefined) {
refinementCtx.addIssue({ code: z.ZodIssueCode.custom, message: 'patch must include at least one defined value' });
}
});
const taskUpdatePatchSchema = z.object({
task_name: z.string().min(1).optional(),
description: z.string().nullable().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 hasDefined = keys.some((key) => (value as Record<string, unknown>)[key] !== undefined);
if (!hasDefined) {
refinementCtx.addIssue({ code: z.ZodIssueCode.custom, message: 'patch must include at least one defined value' });
}
});
const moveTaskInputSchema = z.object({
task_id: withWorkflowPicker(uuidSchema, 'Project task id', 'project-task', ['target_project_id', 'target_phase_id']),
target_phase_id: withWorkflowPicker(uuidSchema, 'Target project phase id', 'project-phase', ['target_project_id']),
target_project_status_mapping_id: withWorkflowPicker(
uuidSchema.optional(),
'Optional target status mapping id',
'project-task-status',
['target_project_id', 'target_phase_id']
),
target_project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional target project id', 'project'),
before_task_id: withWorkflowPicker(uuidSchema.optional(), 'Optional task id to position before', 'project-task', ['target_project_id', 'target_phase_id']),
after_task_id: withWorkflowPicker(uuidSchema.optional(), 'Optional task id to position after', 'project-task', ['target_project_id', 'target_phase_id']),
}).superRefine((value, refinementCtx) => {
if (value.before_task_id && value.after_task_id) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
message: 'before_task_id and after_task_id are mutually exclusive',
path: ['before_task_id'],
});
}
});
const PROJECT_TABLE_AUTH_COLUMNS = ['project_id', 'client_id', 'assigned_to'] as const;
type ProjectAuthRecord = {
project_id: string;
client_id: string | null;
assigned_to: string | null;
};
const updateResultSchema = z.object({
changed_fields: z.array(z.string()),
no_op: z.boolean(),
updated_at: isoDateTimeSchema,
});
const moveTaskResultSchema = z.object({
task_id: uuidSchema,
previous_project_id: uuidSchema,
previous_phase_id: uuidSchema,
previous_project_status_mapping_id: nullableUuidSchema,
previous_status_id: nullableUuidSchema,
current_project_id: uuidSchema,
current_phase_id: uuidSchema,
current_project_status_mapping_id: nullableUuidSchema,
current_status_id: nullableUuidSchema,
wbs_code: z.string().nullable(),
order_key: z.string().nullable(),
updated_at: isoDateTimeSchema,
});
const assignTaskInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for task picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id for task picker scope', 'project-phase', ['project_id']),
task_id: withWorkflowPicker(uuidSchema, 'Project task id', 'project-task', ['project_id', 'phase_id']),
primary_user_id: withWorkflowPicker(uuidSchema, 'Primary assigned user id', 'user'),
additional_user_ids: withWorkflowPicker(z.array(uuidSchema).default([]), 'Additional assigned user ids', 'user'),
reason: z.string().min(1).max(1000).optional().describe('Optional assignment reason'),
no_op_if_already_assigned: z.boolean().default(true),
idempotency_key: z.string().min(1).max(255).optional(),
}).superRefine((value, refinementCtx) => {
const additional = value.additional_user_ids ?? [];
if (additional.includes(value.primary_user_id)) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
path: ['additional_user_ids'],
message: 'additional_user_ids must not include primary_user_id',
});
}
});
const duplicateTaskInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for source task picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id for source task picker scope', 'project-phase', ['project_id']),
source_task_id: withWorkflowPicker(uuidSchema, 'Source project task id', 'project-task', ['project_id', 'phase_id']),
target_project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional target project id', 'project'),
target_phase_id: withWorkflowPicker(uuidSchema, 'Target project phase id', 'project-phase', ['target_project_id']),
target_project_status_mapping_id: withWorkflowPicker(
uuidSchema.optional(),
'Optional target status mapping id',
'project-task-status',
['target_project_id', 'target_phase_id']
),
copy_primary_assignee: z.boolean().default(false),
copy_additional_assignees: z.boolean().default(false),
copy_checklist: z.boolean().default(false),
copy_ticket_links: z.boolean().default(false),
});
const duplicateTaskResultSchema = z.object({
source_task_id: uuidSchema,
task_id: uuidSchema,
target_project_id: uuidSchema,
target_phase_id: uuidSchema,
target_project_status_mapping_id: nullableUuidSchema,
target_status_id: nullableUuidSchema,
copied_checklist_count: z.number().int().nonnegative(),
copied_additional_assignee_count: z.number().int().nonnegative(),
copied_ticket_link_count: z.number().int().nonnegative(),
created_at: isoDateTimeSchema,
});
const deleteTaskInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for task picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id for task picker scope', 'project-phase', ['project_id']),
task_id: withWorkflowPicker(uuidSchema, 'Project task id', 'project-task', ['project_id', 'phase_id']),
});
const deleteTaskResultSchema = z.object({
task_id: uuidSchema,
deleted: z.boolean(),
deleted_ticket_link_count: z.number().int().nonnegative(),
deleted_checklist_item_count: z.number().int().nonnegative(),
});
const deletePhaseInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for phase picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema, 'Project phase id', 'project-phase', ['project_id']),
});
const deletePhaseResultSchema = z.object({
phase_id: uuidSchema,
project_id: uuidSchema,
deleted: z.boolean(),
});
const deleteProjectInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema, 'Project id', 'project'),
});
const deleteProjectResultSchema = z.object({
success: z.boolean(),
deleted: z.boolean().optional(),
can_delete: z.boolean(),
code: z.string().nullable().optional(),
message: z.string().nullable().optional(),
dependencies: z.array(z.any()).default([]),
alternatives: z.array(z.any()).default([]),
});
const linkTicketToTaskInputSchema = z.object({
task_id: withWorkflowPicker(uuidSchema, 'Project task id', 'project-task', ['project_id', 'phase_id']),
ticket_id: withWorkflowPicker(uuidSchema, 'Ticket id', 'ticket'),
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for validation', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id for validation', 'project-phase', ['project_id']),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
});
const linkTicketToTaskResultSchema = z.object({
task_id: uuidSchema,
ticket_id: uuidSchema,
project_ticket_link_id: nullableUuidSchema,
ticket_entity_link_id: nullableUuidSchema,
project_ticket_link_created: z.boolean(),
ticket_entity_link_created: z.boolean(),
});
const addTagInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema, 'Project id', 'project'),
tags: z.array(z.string().min(1)).min(1).describe('One or more tags to attach to the project'),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
});
const addTaskTagInputSchema = z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for task picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id for task picker scope', 'project-phase', ['project_id']),
task_id: withWorkflowPicker(uuidSchema, 'Project task id', 'project-task', ['project_id', 'phase_id']),
tags: z.array(z.string().min(1)).min(1).describe('One or more tags to attach to the project task'),
idempotency_key: z.string().optional().describe('Optional external idempotency key'),
});
const tagMutationResultSchema = z.object({
added: z.array(tagResultSchema),
existing: z.array(tagResultSchema),
added_count: z.number().int(),
existing_count: z.number().int(),
});
function asIsoString(value: unknown): string | undefined {
if (typeof value === 'string') return value;
if (value instanceof Date) return value.toISOString();
return undefined;
}
function parseNullableUuid(value: unknown): string | null {
if (typeof value !== 'string' || value.length === 0) return null;
return value;
}
function toProjectSummary(row: Record<string, unknown>) {
return projectSummarySchema.parse({
project_id: row.project_id,
project_name: String(row.project_name ?? ''),
description: row.description == null ? null : String(row.description),
client_id: parseNullableUuid(row.client_id ?? row.company_id),
status: row.status == null ? null : String(row.status),
assigned_to: parseNullableUuid(row.assigned_to),
wbs_code: row.wbs_code == null ? null : String(row.wbs_code),
updated_at: asIsoString(row.updated_at),
});
}
function toPhaseSummary(row: Record<string, unknown>) {
return phaseSummarySchema.parse({
phase_id: row.phase_id,
project_id: row.project_id,
phase_name: String(row.phase_name ?? ''),
description: row.description == null ? null : String(row.description),
status: row.status == null ? null : String(row.status),
order_number: row.order_number == null ? null : Number(row.order_number),
order_key: row.order_key == null ? null : String(row.order_key),
wbs_code: row.wbs_code == null ? null : String(row.wbs_code),
updated_at: asIsoString(row.updated_at),
});
}
function toTaskSummary(row: Record<string, unknown>) {
return taskSummarySchema.parse({
task_id: row.task_id,
project_id: row.project_id,
phase_id: row.phase_id,
task_name: String(row.task_name ?? ''),
description: row.description == null ? null : String(row.description),
assigned_to: parseNullableUuid(row.assigned_to),
status_id: parseNullableUuid(row.status_id),
project_status_mapping_id: parseNullableUuid(row.project_status_mapping_id),
wbs_code: row.wbs_code == null ? null : String(row.wbs_code),
order_key: row.order_key == null ? null : String(row.order_key),
updated_at: asIsoString(row.updated_at),
});
}
function handleActionError(ctx: any, error: unknown): never {
if (
error &&
typeof error === 'object' &&
'category' in error &&
'code' in error &&
'message' in error
) {
throw error;
}
rethrowAsStandardError(ctx, error);
}
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));
}
async function ensureProjectExists(ctx: any, tx: TenantTxContext, projectId: string): Promise<Record<string, unknown>> {
const project = await tx.trx('projects').where({ tenant: tx.tenantId, project_id: projectId }).first();
if (!project) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project not found',
details: { project_id: projectId },
});
}
return project;
}
async function ensurePhaseExists(ctx: any, tx: TenantTxContext, phaseId: string): Promise<Record<string, unknown>> {
const phase = await tx.trx('project_phases').where({ tenant: tx.tenantId, phase_id: phaseId }).first();
if (!phase) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project phase not found',
details: { phase_id: phaseId },
});
}
return phase;
}
async function ensureTaskContext(ctx: any, tx: TenantTxContext, taskId: string): Promise<Record<string, unknown>> {
const task = await tx.trx('project_tasks as pt')
.join('project_phases as pp', function joinPhases(this: Knex.JoinClause) {
this.on('pp.tenant', 'pt.tenant').andOn('pp.phase_id', 'pt.phase_id');
})
.join('projects as p', function joinProjects(this: Knex.JoinClause) {
this.on('p.tenant', 'pp.tenant').andOn('p.project_id', 'pp.project_id');
})
.where({ 'pt.tenant': tx.tenantId, 'pt.task_id': taskId })
.select('pt.*', 'pp.project_id')
.first();
if (!task) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project task not found',
details: { task_id: taskId },
});
}
return task;
}
function validateOptionalTaskScope(
ctx: any,
task: Record<string, unknown>,
scope: { project_id?: string; phase_id?: string }
): void {
if (scope.project_id && scope.project_id !== String(task.project_id)) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'project_id does not match task project',
details: { project_id: scope.project_id, task_project_id: task.project_id },
});
}
if (scope.phase_id && scope.phase_id !== String(task.phase_id)) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'phase_id does not match task phase',
details: { phase_id: scope.phase_id, task_phase_id: task.phase_id },
});
}
}
async function ensureTicketExists(ctx: any, tx: TenantTxContext, ticketId: string): Promise<Record<string, unknown>> {
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 ensureStatusMappingExists(
ctx: any,
tx: TenantTxContext,
projectStatusMappingId: string
): Promise<Record<string, unknown>> {
const row = await tx.trx('project_status_mappings')
.where({ tenant: tx.tenantId, project_status_mapping_id: projectStatusMappingId })
.first();
if (!row) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project status mapping not found',
details: { project_status_mapping_id: projectStatusMappingId },
});
}
return statusMappingSummarySchema.parse({
project_status_mapping_id: row.project_status_mapping_id,
project_id: row.project_id,
phase_id: row.phase_id ?? null,
status_id: row.status_id ?? null,
standard_status_id: row.standard_status_id ?? null,
custom_name: row.custom_name ?? null,
display_order: row.display_order == null ? null : Number(row.display_order),
is_visible: row.is_visible == null ? null : Boolean(row.is_visible),
is_standard: row.is_standard == null ? null : Boolean(row.is_standard),
});
}
async function requireProjectReadPermission(ctx: any, tx: TenantTxContext): Promise<void> {
await requirePermission(ctx, tx, { resource: 'project', action: 'read' });
}
async function requireProjectUpdatePermission(ctx: any, tx: TenantTxContext): Promise<void> {
await requirePermission(ctx, tx, { resource: 'project', action: 'update' });
}
async function requireProjectDeletePermission(ctx: any, tx: TenantTxContext): Promise<void> {
await requirePermission(ctx, tx, { resource: 'project', action: 'delete' });
}
type ProjectStatusMappingDetails = {
project_status_mapping_id: string;
project_id: string;
phase_id: string | null;
status_id: string | null;
standard_status_id: string | null;
custom_name: string | null;
status_name: string;
display_order: number | null;
is_closed: boolean;
is_standard: boolean | null;
};
async function getProjectStatusMappingDetails(
tx: TenantTxContext,
projectStatusMappingId: string
): Promise<ProjectStatusMappingDetails | null> {
const row = await tx.trx('project_status_mappings as psm')
.leftJoin('statuses as s', function joinStatuses(this: Knex.JoinClause) {
this.on('psm.status_id', '=', 's.status_id').andOn('psm.tenant', '=', 's.tenant');
})
.leftJoin('standard_statuses as ss', function joinStandardStatuses(this: Knex.JoinClause) {
this.on('psm.standard_status_id', '=', 'ss.standard_status_id');
})
.where({ 'psm.tenant': tx.tenantId, 'psm.project_status_mapping_id': projectStatusMappingId })
.select(
'psm.*',
tx.trx.raw('COALESCE(psm.custom_name, s.name, ss.name, psm.project_status_mapping_id::text) as status_name'),
tx.trx.raw('COALESCE(s.is_closed, ss.is_closed, false) as is_closed')
)
.first();
if (!row) return null;
return {
project_status_mapping_id: row.project_status_mapping_id,
project_id: row.project_id,
phase_id: row.phase_id ?? null,
status_id: row.status_id ?? null,
standard_status_id: row.standard_status_id ?? null,
custom_name: row.custom_name ?? null,
status_name: row.status_name,
display_order: row.display_order == null ? null : Number(row.display_order),
is_closed: Boolean(row.is_closed),
is_standard: row.is_standard == null ? null : Boolean(row.is_standard),
};
}
async function getScopedProjectStatusMappings(
tx: TenantTxContext,
projectId: string,
phaseId?: string | null
): Promise<ProjectStatusMappingDetails[]> {
let query = tx.trx('project_status_mappings as psm')
.leftJoin('statuses as s', function joinStatuses(this: Knex.JoinClause) {
this.on('psm.status_id', '=', 's.status_id').andOn('psm.tenant', '=', 's.tenant');
})
.leftJoin('standard_statuses as ss', function joinStandardStatuses(this: Knex.JoinClause) {
this.on('psm.standard_status_id', '=', 'ss.standard_status_id');
})
.where({ 'psm.tenant': tx.tenantId, 'psm.project_id': projectId });
query = phaseId ? query.andWhere('psm.phase_id', phaseId) : query.whereNull('psm.phase_id');
const rows = await query
.select(
'psm.*',
tx.trx.raw('COALESCE(psm.custom_name, s.name, ss.name, psm.project_status_mapping_id::text) as status_name'),
tx.trx.raw('COALESCE(s.is_closed, ss.is_closed, false) as is_closed')
)
.orderBy('psm.display_order', 'asc')
.orderBy('psm.project_status_mapping_id', 'asc');
return rows.map((row: Record<string, any>) => ({
project_status_mapping_id: row.project_status_mapping_id,
project_id: row.project_id,
phase_id: row.phase_id ?? null,
status_id: row.status_id ?? null,
standard_status_id: row.standard_status_id ?? null,
custom_name: row.custom_name ?? null,
status_name: row.status_name,
display_order: row.display_order == null ? null : Number(row.display_order),
is_closed: Boolean(row.is_closed),
is_standard: row.is_standard == null ? null : Boolean(row.is_standard),
}));
}
async function ensureProjectDefaultStatusMappings(
tx: TenantTxContext,
projectId: string
): Promise<ProjectStatusMappingDetails[]> {
const existing = await getScopedProjectStatusMappings(tx, projectId);
if (existing.length > 0) return existing;
const standardStatuses = await tx.trx('standard_statuses')
.where({ item_type: 'project_task' })
.orderBy('display_order', 'asc');
for (const status of standardStatuses) {
await tx.trx('project_status_mappings').insert({
tenant: tx.tenantId,
project_status_mapping_id: uuidv4(),
project_id: projectId,
standard_status_id: status.standard_status_id,
is_standard: true,
custom_name: null,
display_order: status.display_order,
is_visible: true,
});
}
return getScopedProjectStatusMappings(tx, projectId);
}
async function getEffectiveProjectStatusMappings(
tx: TenantTxContext,
projectId: string,
phaseId?: string | null
): Promise<ProjectStatusMappingDetails[]> {
if (phaseId) {
const phaseMappings = await getScopedProjectStatusMappings(tx, projectId, phaseId);
if (phaseMappings.length > 0) return phaseMappings;
}
return ensureProjectDefaultStatusMappings(tx, projectId);
}
function resolveSameProjectTargetStatusMapping(
sourceMapping: ProjectStatusMappingDetails,
targetMappings: ProjectStatusMappingDetails[]
): ProjectStatusMappingDetails | null {
const sameId = targetMappings.find((mapping) => mapping.project_status_mapping_id === sourceMapping.project_status_mapping_id);
if (sameId) return sameId;
const sameName = targetMappings.find((mapping) => mapping.status_name === sourceMapping.status_name);
if (sameName) return sameName;
const sameClosedState = targetMappings.find((mapping) => mapping.is_closed === sourceMapping.is_closed);
if (sameClosedState) return sameClosedState;
return targetMappings[0] ?? null;
}
async function resolveTargetProjectStatusMappingId(
tx: TenantTxContext,
params: {
sourceTask: Record<string, unknown>;
targetProjectId: string;
targetPhaseId: string;
explicitTargetProjectStatusMappingId?: string;
}
): Promise<string | null> {
const taskColumns = await getTableColumns(tx, 'project_tasks');
if (!taskColumns.has('project_status_mapping_id')) return null;
const targetMappings = await getEffectiveProjectStatusMappings(tx, params.targetProjectId, params.targetPhaseId);
if (params.explicitTargetProjectStatusMappingId) {
const explicit = targetMappings.find((mapping) => mapping.project_status_mapping_id === params.explicitTargetProjectStatusMappingId);
return explicit?.project_status_mapping_id ?? null;
}
const sourceMappingId = parseNullableUuid(params.sourceTask.project_status_mapping_id);
const sourceMapping = sourceMappingId ? await getProjectStatusMappingDetails(tx, sourceMappingId) : null;
if (sourceMapping) {
return resolveSameProjectTargetStatusMapping(sourceMapping, targetMappings)?.project_status_mapping_id ?? null;
}
const sourceStatusId = parseNullableUuid(params.sourceTask.status_id);
if (sourceStatusId) {
const sameStatus = targetMappings.find((mapping) => mapping.status_id === sourceStatusId);
if (sameStatus) return sameStatus.project_status_mapping_id;
}
return targetMappings[0]?.project_status_mapping_id ?? null;
}
async function resolveTargetStatusId(
tx: TenantTxContext,
params: {
sourceTask: Record<string, unknown>;
targetProjectStatusMappingId: string | null;
}
): Promise<string | null> {
const taskColumns = await getTableColumns(tx, 'project_tasks');
if (!taskColumns.has('status_id')) return null;
if (params.targetProjectStatusMappingId) {
const mapping = await getProjectStatusMappingDetails(tx, params.targetProjectStatusMappingId);
return parseNullableUuid(mapping?.status_id) ?? parseNullableUuid(params.sourceTask.status_id);
}
return parseNullableUuid(params.sourceTask.status_id);
}
async function generateTaskWbsCode(
tx: TenantTxContext,
targetPhase: Record<string, unknown>
): Promise<string> {
const baseWbs = String(targetPhase.wbs_code ?? '1');
const countRow = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, phase_id: targetPhase.phase_id })
.count('* as count')
.first();
const nextNumber = parseInt(String((countRow as any)?.count ?? 0), 10) + 1;
return `${baseWbs}.${nextNumber}`;
}
const uniqueStringsSorted = (values: string[]): string[] => Array.from(new Set(values)).sort();
async function getCurrentTaskAdditionalUserIds(
tx: TenantTxContext,
taskId: string
): Promise<string[]> {
const hasTaskResources = await tx.trx.schema.hasTable('task_resources');
if (!hasTaskResources) return [];
const rows = await tx.trx('task_resources')
.where({ tenant: tx.tenantId, task_id: taskId })
.whereNotNull('additional_user_id')
.select('additional_user_id');
return uniqueStringsSorted(
rows
.map((row: { additional_user_id: string | null }) => row.additional_user_id)
.filter((value: string | null): value is string => Boolean(value))
);
}
async function resolveActiveTaskAssignmentUsers(
ctx: any,
tx: TenantTxContext,
input: { primaryUserId: string; additionalUserIds: string[] }
): Promise<{ primaryUserId: string; additionalUserIds: string[] }> {
const userColumns = await getTableColumns(tx, 'users');
const supportsUserType = userColumns.has('user_type');
const supportsInactive = userColumns.has('is_inactive');
const primaryQuery = tx.trx('users')
.where({ tenant: tx.tenantId, user_id: input.primaryUserId });
if (supportsUserType) primaryQuery.andWhere('user_type', 'internal');
if (supportsInactive) primaryQuery.andWhere('is_inactive', false);
const primaryUser = await primaryQuery.first();
if (!primaryUser) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'Primary assigned user not found or inactive',
details: { primary_user_id: input.primaryUserId },
});
}
const uniqueAdditional = uniqueStringsSorted(
input.additionalUserIds.filter((userId) => userId !== input.primaryUserId)
);
if (uniqueAdditional.length === 0) {
return { primaryUserId: input.primaryUserId, additionalUserIds: [] };
}
const additionalQuery = tx.trx('users')
.where({ tenant: tx.tenantId });
if (supportsUserType) additionalQuery.andWhere('user_type', 'internal');
if (supportsInactive) additionalQuery.andWhere('is_inactive', false);
const validAdditionalRows = await additionalQuery
.whereIn('user_id', uniqueAdditional)
.select('user_id');
const validAdditionalSet = new Set(validAdditionalRows.map((row: { user_id: string }) => row.user_id));
const invalidAdditionalUserIds = uniqueAdditional.filter((userId) => !validAdditionalSet.has(userId));
if (invalidAdditionalUserIds.length > 0) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'One or more additional assigned users are invalid or inactive',
details: { invalid_user_ids: invalidAdditionalUserIds },
});
}
return {
primaryUserId: input.primaryUserId,
additionalUserIds: uniqueAdditional,
};
}
async function reconcileTaskAdditionalUsers(
tx: TenantTxContext,
taskId: string,
assignedTo: string,
additionalUserIds: string[]
): Promise<void> {
const hasTaskResources = await tx.trx.schema.hasTable('task_resources');
if (!hasTaskResources) return;
await tx.trx('task_resources')
.where({ tenant: tx.tenantId, task_id: taskId })
.delete();
if (additionalUserIds.length === 0) return;
await tx.trx('task_resources').insert(
additionalUserIds.map((userId) => ({
tenant: tx.tenantId,
task_id: taskId,
assigned_to: assignedTo,
additional_user_id: userId,
role: 'support',
}))
);
}
async function canReadTickets(ctx: any, tx: TenantTxContext): Promise<boolean> {
try {
await requirePermission(ctx, tx, { resource: 'ticket', action: 'read' });
return true;
} catch (error) {
if (
error &&
typeof error === 'object' &&
'code' in error &&
(error as { code?: unknown }).code === 'PERMISSION_DENIED'
) {
return false;
}
throw error;
}
}
async function deleteFromTableIfExists(
tx: TenantTxContext,
tableName: string,
whereBuilder: (query: Knex.QueryBuilder) => Knex.QueryBuilder
): Promise<number> {
const hasTable = await tx.trx.schema.hasTable(tableName);
if (!hasTable) return 0;
const query = whereBuilder(tx.trx(tableName));
const deleted = await query.delete();
return Number(deleted ?? 0);
}
function 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',
};
}
function uniqueNormalizedTags(tags: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const raw of tags) {
const trimmed = raw.trim();
if (!trimmed) continue;
if (seen.has(trimmed)) continue;
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
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 ensureTagMappings(
tx: TenantTxContext,
params: { taggedType: 'project' | 'project_task'; taggedId: string; tags: string[] }
): Promise<{ added: Array<z.infer<typeof tagResultSchema>>; existing: Array<z.infer<typeof tagResultSchema>> }> {
const normalizedTags = uniqueNormalizedTags(params.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: params.taggedType,
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: params.taggedType })
.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: params.taggedId,
tagged_type: params.taggedType,
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: params.taggedId,
tagged_type: params.taggedType,
})
.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 };
}
function toProjectAuthorizationRecord(project: Record<string, unknown>): AuthorizationRecord {
const assignedTo = parseNullableUuid(project.assigned_to);
return {
id: parseNullableUuid(project.project_id),
ownerUserId: assignedTo,
assignedUserIds: assignedTo ? [assignedTo] : [],
clientId: parseNullableUuid(project.client_id ?? project.company_id),
};
}
function toTicketAuthorizationRecord(ticket: Record<string, unknown>): AuthorizationRecord {
const assignedTo = parseNullableUuid(ticket.assigned_to);
return {
id: parseNullableUuid(ticket.ticket_id),
ownerUserId: assignedTo,
assignedUserIds: assignedTo ? [assignedTo] : [],
clientId: parseNullableUuid(ticket.company_id ?? ticket.client_id),
boardId: parseNullableUuid(ticket.board_id),
teamIds: parseNullableUuid(ticket.team_id) ? [parseNullableUuid(ticket.team_id)!] : [],
};
}
async function resolveActorAuthorizationSubject(tx: TenantTxContext): Promise<AuthorizationSubject> {
const userColumns = await getTableColumns(tx, 'users');
const actor = await tx.trx('users')
.where({ tenant: tx.tenantId, user_id: tx.actorUserId })
.select('*')
.first<Record<string, unknown>>();
const roleRows = await tx.trx('user_roles')
.where({ tenant: tx.tenantId, user_id: tx.actorUserId })
.select<{ role_id: string }[]>('role_id')
.catch(() => []);
const teamRows = await tx.trx('team_members')
.where({ tenant: tx.tenantId, user_id: tx.actorUserId })
.select<{ team_id: string }[]>('team_id')
.catch(() => []);
const managedRows = userColumns.has('reports_to')
? await tx.trx('users')
.where({ tenant: tx.tenantId, reports_to: tx.actorUserId })
.select<{ user_id: string }[]>('user_id')
.catch(() => [])
: [];
const clientId = parseNullableUuid(actor?.client_id ?? actor?.clientId);
const userType = actor?.user_type === 'internal' || actor?.user_type === 'client' ? actor.user_type : 'internal';
return {
tenant: tx.tenantId,
userId: tx.actorUserId,
userType,
roleIds: roleRows.map((row) => row.role_id),
teamIds: teamRows.map((row) => row.team_id),
managedUserIds: managedRows.map((row) => row.user_id),
clientId,
portfolioClientIds: clientId ? [clientId] : [],
};
}
async function createProjectReadAuthorizer(tx: TenantTxContext): Promise<(project: Record<string, unknown>) => Promise<boolean>> {
const subject = await resolveActorAuthorizationSubject(tx);
const authorizationKernel = createAuthorizationKernel({
builtinProvider: new BuiltinAuthorizationKernelProvider(),
bundleProvider: new BundleAuthorizationKernelProvider({
resolveRules: async (input) => {
try {
return await resolveBundleNarrowingRulesForEvaluation(tx.trx, input);
} catch {
return [];
}
},
}),
rbacEvaluator: async () => true,
});
const requestCache = new RequestLocalAuthorizationCache();
return async (project: Record<string, unknown>): Promise<boolean> => {
const projectId = parseNullableUuid(project.project_id);
if (!projectId) return false;
const decision = await authorizationKernel.authorizeResource({
subject,
resource: { type: 'project', action: 'read', id: projectId },
record: toProjectAuthorizationRecord(project),
requestCache,
knex: tx.trx,
});
return decision.allowed;
};
}
async function createTicketReadAuthorizer(tx: TenantTxContext): Promise<(ticket: Record<string, unknown>) => Promise<boolean>> {
const subject = await resolveActorAuthorizationSubject(tx);
const authorizationKernel = createAuthorizationKernel({
builtinProvider: new BuiltinAuthorizationKernelProvider(),
bundleProvider: new BundleAuthorizationKernelProvider({
resolveRules: async (input) => {
try {
return await resolveBundleNarrowingRulesForEvaluation(tx.trx, input);
} catch {
return [];
}
},
}),
rbacEvaluator: async () => true,
});
const requestCache = new RequestLocalAuthorizationCache();
return async (ticket: Record<string, unknown>): Promise<boolean> => {
const ticketId = parseNullableUuid(ticket.ticket_id);
if (!ticketId) return false;
const decision = await authorizationKernel.authorizeResource({
subject,
resource: { type: 'ticket', action: 'read', id: ticketId },
record: toTicketAuthorizationRecord(ticket),
requestCache,
knex: tx.trx,
});
return decision.allowed;
};
}
async function canReadProject(tx: TenantTxContext, project: Record<string, unknown>): Promise<boolean> {
const authorize = await createProjectReadAuthorizer(tx);
return authorize(project);
}
async function assertProjectReadable(
ctx: any,
tx: TenantTxContext,
project: Record<string, unknown>
): Promise<void> {
const allowed = await canReadProject(tx, project);
if (!allowed) {
throwActionError(ctx, {
category: 'ActionError',
code: 'PERMISSION_DENIED',
message: 'Permission denied: project:read',
details: { project_id: project.project_id },
});
}
}
async function assertTicketReadable(
ctx: any,
tx: TenantTxContext,
ticket: Record<string, unknown>
): Promise<void> {
const authorize = await createTicketReadAuthorizer(tx);
const allowed = await authorize(ticket);
if (!allowed) {
throwActionError(ctx, {
category: 'ActionError',
code: 'PERMISSION_DENIED',
message: 'Permission denied: ticket:read',
details: { ticket_id: ticket.ticket_id },
});
}
}
async function filterAuthorizedProjects(tx: TenantTxContext, projects: Record<string, unknown>[]) {
if (projects.length === 0) return [];
const authorize = await createProjectReadAuthorizer(tx);
const allowed = await Promise.all(projects.map((project) => authorize(project)));
return projects.filter((_, idx) => allowed[idx]);
}
export function registerProjectActions(): void {
const registry = getActionRegistryV2();
// ---------------------------------------------------------------------------
// A16 — projects.create_task
// ---------------------------------------------------------------------------
registry.register({
id: 'projects.create_task',
version: 1,
inputSchema: z.object({
project_id: withWorkflowPicker(uuidSchema, 'Project id', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id (defaults to first phase)', 'project-phase', ['project_id']),
title: z.string().min(1).describe('Task title'),
description: z.string().optional().describe('Task description'),
due_date: isoDateTimeSchema.optional().describe('Optional due date'),
status_id: statusMappingOrStatusPicker.optional(),
priority_id: uuidSchema.nullable().optional().describe('Optional priority id'),
assignee: z.object({
type: z.enum(['user', 'team']).describe('Assignee type'),
id: uuidSchema.describe('User id or team id')
}).optional().describe('Optional assignee'),
link_ticket_id: withWorkflowPicker(uuidSchema.optional(), 'Optional ticket id to link', 'ticket')
}),
outputSchema: z.object({
task_id: uuidSchema,
url: z.string(),
status_id: uuidSchema.nullable(),
priority_id: uuidSchema.nullable(),
created_at: isoDateTimeSchema
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Create Project Task', category: 'Business Operations', description: 'Create a task under a project' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'project_task', action: 'create' });
const project = await tx.trx('projects').where({ tenant: tx.tenantId, project_id: input.project_id }).first();
if (!project) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Project not found' });
const phaseId = input.phase_id ?? (await tx.trx('project_phases')
.where({ tenant: tx.tenantId, project_id: input.project_id })
.orderBy('order_number', 'asc')
.first())?.phase_id;
if (!phaseId) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Project phase not found' });
const phase = await tx.trx('project_phases').where({ tenant: tx.tenantId, phase_id: phaseId }).first();
if (!phase || phase.project_id !== input.project_id) {
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Project phase does not belong to project' });
}
const assignedTo = input.assignee
? (input.assignee.type === 'user'
? input.assignee.id
: (await tx.trx('teams').where({ tenant: tx.tenantId, team_id: input.assignee.id }).first())?.manager_id)
: null;
if (input.assignee && input.assignee.type === 'team' && !assignedTo) {
throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Team not found' });
}
if (assignedTo) {
const user = await tx.trx('users').where({ tenant: tx.tenantId, user_id: assignedTo }).first();
if (!user) throwActionError(ctx, { category: 'ActionError', code: 'NOT_FOUND', message: 'Assignee user not found' });
}
const taskColumns = await getTableColumns(tx, 'project_tasks');
let statusId: string | null = input.status_id ?? null;
let projectStatusMappingId: string | null = null;
if (taskColumns.has('project_status_mapping_id')) {
if (statusId) {
const mapping = await getProjectStatusMappingDetails(tx, statusId);
const effectiveMappings = await getEffectiveProjectStatusMappings(tx, input.project_id, phaseId);
const isEffective = effectiveMappings.some((candidate) => candidate.project_status_mapping_id === statusId);
if (!mapping || mapping.project_id !== input.project_id || !isEffective) {
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Invalid project task status_id' });
}
projectStatusMappingId = mapping.project_status_mapping_id;
statusId = taskColumns.has('status_id') ? mapping.status_id : mapping.project_status_mapping_id;
} else {
const defaultMapping = (await getEffectiveProjectStatusMappings(tx, input.project_id, phaseId))[0];
projectStatusMappingId = defaultMapping?.project_status_mapping_id ?? null;
statusId = taskColumns.has('status_id') ? (defaultMapping?.status_id ?? null) : projectStatusMappingId;
}
} else if (statusId) {
const status = await tx.trx('statuses').where({ tenant: tx.tenantId, status_id: statusId, status_type: 'project_task' }).first();
if (!status) throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'Invalid project task status_id' });
} else {
const defaultStatus = await tx.trx('statuses')
.where({ tenant: tx.tenantId, status_type: 'project_task' })
.orderBy('is_default', 'desc')
.orderBy('order_number', 'asc')
.first();
statusId = (defaultStatus?.status_id as string | undefined) ?? null;
}
const baseWbs = (phase?.wbs_code as string) ?? '1';
const countRow = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, phase_id: phaseId })
.count('* as count')
.first();
const n = parseInt(String((countRow as any)?.count ?? 0), 10) + 1;
const wbsCode = `${baseWbs}.${n}`;
const taskId = uuidv4();
const nowIso = new Date().toISOString();
const taskPayload: Record<string, unknown> = {
tenant: tx.tenantId,
task_id: taskId,
phase_id: phaseId,
task_name: input.title,
description: input.description ?? null,
assigned_to: assignedTo,
due_date: input.due_date ?? null,
wbs_code: wbsCode,
created_at: nowIso,
updated_at: nowIso,
};
if (taskColumns.has('description_rich_text')) taskPayload.description_rich_text = null;
if (taskColumns.has('status_id')) taskPayload.status_id = statusId;
if (taskColumns.has('project_status_mapping_id')) taskPayload.project_status_mapping_id = projectStatusMappingId;
if (taskColumns.has('priority_id')) taskPayload.priority_id = input.priority_id ?? null;
await tx.trx('project_tasks').insert(taskPayload);
if (input.link_ticket_id) {
await tx.trx('project_ticket_links').insert({
tenant: tx.tenantId,
link_id: uuidv4(),
project_id: input.project_id,
phase_id: phaseId,
task_id: taskId,
ticket_id: input.link_ticket_id,
created_at: nowIso
}).catch(() => undefined);
await tx.trx('ticket_entity_links').insert({
tenant: tx.tenantId,
link_id: uuidv4(),
ticket_id: input.link_ticket_id,
entity_type: 'project_task',
entity_id: taskId,
link_type: 'project_task',
metadata: { project_id: input.project_id, phase_id: phaseId },
created_at: nowIso
}).catch(() => undefined);
}
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.create_task',
changedData: { project_id: input.project_id, task_id: taskId, phase_id: phaseId, link_ticket_id: input.link_ticket_id ?? null },
details: { action_id: 'projects.create_task', action_version: 1, task_id: taskId }
});
return {
task_id: taskId,
url: `/msp/projects/${input.project_id}?task=${taskId}`,
status_id: statusId,
priority_id: input.priority_id ?? null,
created_at: nowIso
};
})
});
registry.register({
id: 'projects.find',
version: 1,
inputSchema: z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Project id', 'project'),
name: z.string().optional().describe('Exact project name (case-insensitive)'),
external_ref: z.string().optional().describe('Optional external reference when supported by project properties'),
on_not_found: z.enum(['return_null', 'error']).default('return_null'),
}).refine((value) => Boolean(value.project_id || value.name || value.external_ref), {
message: 'project_id, name, or external_ref required',
}),
outputSchema: z.object({
project: projectSummarySchema.nullable(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Find Project', category: 'Business Operations', description: 'Find a project by id, name, or external ref' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectReadPermission(ctx, tx);
const startedAt = Date.now();
const projectColumns = await getTableColumns(tx, 'projects');
let project: Record<string, unknown> | undefined;
let matchedBy: 'project_id' | 'name' | 'external_ref' | null = null;
if (input.project_id) {
project = await tx.trx('projects').where({ tenant: tx.tenantId, project_id: input.project_id }).first();
matchedBy = 'project_id';
} else if (input.name) {
const exactName = String(input.name).trim();
project = await tx.trx('projects')
.where({ tenant: tx.tenantId })
.andWhereRaw('lower(project_name) = ?', [exactName.toLowerCase()])
.first();
matchedBy = 'name';
} else if (input.external_ref) {
matchedBy = 'external_ref';
if (projectColumns.has('properties')) {
project = await tx.trx('projects')
.where({ tenant: tx.tenantId })
.andWhereRaw(`(properties->>'external_ref') = ?`, [input.external_ref])
.first();
}
}
if (project) {
await assertProjectReadable(ctx, tx, project);
}
if (!project) {
if (input.on_not_found === 'error') {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project not found',
details: { matched_by: matchedBy },
});
}
return { project: null };
}
ctx.logger?.info('workflow_action:projects.find', {
duration_ms: Date.now() - startedAt,
matched_by: matchedBy,
});
return { project: toProjectSummary(project) };
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.search',
version: 1,
inputSchema: z.object({
query: z.string().min(1).describe('Search query against project name and description'),
filters: z.object({
client_id: withWorkflowPicker(uuidSchema.optional(), 'Filter by client id', 'project'),
assigned_to: withWorkflowPicker(uuidSchema.optional(), 'Filter by assigned user id', 'user'),
include_inactive: z.boolean().optional(),
status: z.string().optional(),
sort_by: z.enum(['project_name', 'updated_at', 'created_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({
projects: z.array(projectSummarySchema),
first_project: projectSummarySchema.nullable(),
page: z.number().int(),
page_size: z.number().int(),
total: z.number().int(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Search Projects', category: 'Business Operations', description: 'Search projects by name or description' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectReadPermission(ctx, tx);
const startedAt = Date.now();
const escaped = String(input.query).trim().replace(/[%_\\]/g, (match) => `\\${match}`);
const pattern = `%${escaped}%`;
const filters = input.filters ?? {};
const page = input.page ?? 1;
const pageSize = input.page_size ?? 25;
const projectColumns = await getTableColumns(tx, 'projects');
let base = tx.trx('projects as p').where({ 'p.tenant': tx.tenantId });
base = base.andWhere(function searchByQuery() {
this.whereRaw(`p.project_name ILIKE ?`, [pattern]);
if (projectColumns.has('description')) {
this.orWhereRaw(`p.description ILIKE ?`, [pattern]);
}
});
if (filters.client_id) {
if (projectColumns.has('client_id')) {
base = base.andWhere('p.client_id', filters.client_id);
} else if (projectColumns.has('company_id')) {
base = base.andWhere('p.company_id', filters.client_id);
}
}
if (filters.assigned_to && projectColumns.has('assigned_to')) {
base = base.andWhere('p.assigned_to', filters.assigned_to);
}
if (!filters.include_inactive && projectColumns.has('is_inactive')) {
base = base.andWhere(function activeProjects() {
this.where('p.is_inactive', false).orWhereNull('p.is_inactive');
});
}
if (filters.status && projectColumns.has('status')) {
base = base.andWhere('p.status', filters.status);
}
const sortBy = filters.sort_by ?? 'project_name';
const sortOrder = filters.sort_order ?? 'asc';
const sortColumn = sortBy === 'project_name' ? 'p.project_name' : sortBy === 'created_at' ? 'p.created_at' : 'p.updated_at';
const matchingRows = await base
.clone()
.clearSelect()
.select('p.*')
.orderBy(sortColumn, sortOrder)
.orderBy('p.project_id', 'asc');
const authorizedRows = await filterAuthorizedProjects(tx, matchingRows as Record<string, unknown>[]);
const total = authorizedRows.length;
const start = (page - 1) * pageSize;
const pageRows = authorizedRows.slice(start, start + pageSize);
const projects = pageRows.map((row) => toProjectSummary(row));
ctx.logger?.info('workflow_action:projects.search', {
duration_ms: Date.now() - startedAt,
query_len: escaped.length,
result_count: projects.length,
total,
page,
page_size: pageSize,
});
return {
projects,
first_project: projects[0] ?? null,
page,
page_size: pageSize,
total,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.find_phase',
version: 1,
inputSchema: z.object({
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Project phase id', 'project-phase', ['project_id']),
project_id: withWorkflowPicker(uuidSchema.optional(), 'Project id for phase lookup', 'project'),
name: z.string().optional().describe('Exact phase name (case-insensitive; requires project_id)'),
on_not_found: z.enum(['return_null', 'error']).default('return_null'),
}).superRefine((value, refinementCtx) => {
if (!value.phase_id && !value.name) {
refinementCtx.addIssue({ code: z.ZodIssueCode.custom, message: 'phase_id or name required' });
}
if (value.name && !value.project_id) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
path: ['project_id'],
message: 'project_id is required when searching by phase name',
});
}
}),
outputSchema: z.object({
phase: phaseSummarySchema.nullable(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Find Project Phase', category: 'Business Operations', description: 'Find a project phase by id or exact name' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectReadPermission(ctx, tx);
const startedAt = Date.now();
let phase: Record<string, unknown> | undefined;
let matchedBy: 'phase_id' | 'name' | null = null;
if (input.phase_id) {
phase = await tx.trx('project_phases').where({ tenant: tx.tenantId, phase_id: input.phase_id }).first();
matchedBy = 'phase_id';
} else if (input.name && input.project_id) {
phase = await tx.trx('project_phases')
.where({ tenant: tx.tenantId, project_id: input.project_id })
.andWhereRaw('lower(phase_name) = ?', [String(input.name).trim().toLowerCase()])
.first();
matchedBy = 'name';
}
if (!phase) {
if (input.on_not_found === 'error') {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project phase not found',
details: { matched_by: matchedBy },
});
}
return { phase: null };
}
const project = await ensureProjectExists(ctx, tx, String(phase.project_id));
await assertProjectReadable(ctx, tx, project);
ctx.logger?.info('workflow_action:projects.find_phase', {
duration_ms: Date.now() - startedAt,
matched_by: matchedBy,
});
return { phase: toPhaseSummary(phase) };
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.search_phases',
version: 1,
inputSchema: z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id', 'project'),
query: z.string().optional().describe('Optional phase query against name/description'),
filters: z.object({
status: z.string().optional(),
sort_by: z.enum(['project_name', 'phase_name', 'updated_at', 'order']).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({
phases: z.array(phaseSummarySchema),
first_phase: phaseSummarySchema.nullable(),
page: z.number().int(),
page_size: z.number().int(),
total: z.number().int(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Search Project Phases', category: 'Business Operations', description: 'Search or list project phases' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectReadPermission(ctx, tx);
const startedAt = Date.now();
const page = input.page ?? 1;
const pageSize = input.page_size ?? 25;
const filters = input.filters ?? {};
const queryText = String(input.query ?? '').trim();
const queryPattern = `%${queryText.replace(/[%_\\]/g, (match) => `\\${match}`)}%`;
let base = tx.trx('project_phases as pp')
.join('projects as p', function joinProjects(this: Knex.JoinClause) {
this.on('p.tenant', 'pp.tenant').andOn('p.project_id', 'pp.project_id');
})
.where({ 'pp.tenant': tx.tenantId });
if (input.project_id) {
base = base.andWhere('pp.project_id', input.project_id);
}
if (queryText.length > 0) {
base = base.andWhere(function queryMatch() {
this.whereRaw(`pp.phase_name ILIKE ?`, [queryPattern]);
this.orWhereRaw(`COALESCE(pp.description, '') ILIKE ?`, [queryPattern]);
});
}
if (filters.status) {
base = base.andWhere('pp.status', filters.status);
}
const sortBy = filters.sort_by ?? 'order';
const sortOrder = filters.sort_order ?? 'asc';
const rows = await base
.clone()
.clearSelect()
.select('pp.*', ...PROJECT_TABLE_AUTH_COLUMNS.map((col) => `p.${col} as project_${col}`))
.orderBy(sortBy === 'project_name' ? 'p.project_name' : sortBy === 'phase_name' ? 'pp.phase_name' : sortBy === 'updated_at' ? 'pp.updated_at' : 'pp.order_key', sortOrder)
.orderBy('pp.order_number', 'asc')
.orderBy('pp.phase_id', 'asc');
const authorizedRows = await Promise.all(rows.map(async (row) => {
const allowed = await canReadProject(tx, {
project_id: row.project_project_id,
client_id: row.project_client_id,
assigned_to: row.project_assigned_to,
});
return allowed ? row : null;
}));
const filteredRows = authorizedRows.filter((row): row is Record<string, unknown> => Boolean(row));
const total = filteredRows.length;
const start = (page - 1) * pageSize;
const pageRows = filteredRows.slice(start, start + pageSize);
const phases = pageRows.map((row) => toPhaseSummary(row));
ctx.logger?.info('workflow_action:projects.search_phases', {
duration_ms: Date.now() - startedAt,
query_len: queryText.length,
result_count: phases.length,
total,
page,
page_size: pageSize,
});
return {
phases,
first_phase: phases[0] ?? null,
page,
page_size: pageSize,
total,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.find_task',
version: 1,
inputSchema: z.object({
task_id: withWorkflowPicker(uuidSchema.optional(), 'Project task id', 'project-task', ['project_id', 'phase_id']),
project_id: withWorkflowPicker(uuidSchema.optional(), 'Project id', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Project phase id', 'project-phase', ['project_id']),
name: z.string().optional().describe('Exact task name (case-insensitive)'),
on_not_found: z.enum(['return_null', 'error']).default('return_null'),
}).superRefine((value, refinementCtx) => {
if (!value.task_id && !value.name) {
refinementCtx.addIssue({ code: z.ZodIssueCode.custom, message: 'task_id or name required' });
}
if (value.name && !value.project_id && !value.phase_id) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
path: ['project_id'],
message: 'project_id or phase_id is required when searching by task name',
});
}
}),
outputSchema: z.object({
task: taskSummarySchema.nullable(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Find Project Task', category: 'Business Operations', description: 'Find a project task by id or exact name' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectReadPermission(ctx, tx);
const startedAt = Date.now();
let task: Record<string, unknown> | undefined;
let matchedBy: 'task_id' | 'name' | null = null;
let query = tx.trx('project_tasks as pt')
.join('project_phases as pp', function joinPhases(this: Knex.JoinClause) {
this.on('pp.tenant', 'pt.tenant').andOn('pp.phase_id', 'pt.phase_id');
})
.join('projects as p', function joinProjects(this: Knex.JoinClause) {
this.on('p.tenant', 'pp.tenant').andOn('p.project_id', 'pp.project_id');
})
.where({ 'pt.tenant': tx.tenantId })
.select('pt.*', 'pp.project_id', ...PROJECT_TABLE_AUTH_COLUMNS.map((col) => `p.${col} as project_${col}`));
if (input.task_id) {
task = await query.clone().andWhere('pt.task_id', input.task_id).first();
matchedBy = 'task_id';
} else if (input.name) {
query = query.andWhereRaw('lower(pt.task_name) = ?', [String(input.name).trim().toLowerCase()]);
if (input.project_id) query = query.andWhere('pp.project_id', input.project_id);
if (input.phase_id) query = query.andWhere('pt.phase_id', input.phase_id);
task = await query.first();
matchedBy = 'name';
}
if (!task) {
if (input.on_not_found === 'error') {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project task not found',
details: { matched_by: matchedBy },
});
}
return { task: null };
}
const allowed = await canReadProject(tx, {
project_id: task.project_project_id,
client_id: task.project_client_id,
assigned_to: task.project_assigned_to,
});
if (!allowed) {
throwActionError(ctx, {
category: 'ActionError',
code: 'PERMISSION_DENIED',
message: 'Permission denied: project:read',
details: { project_id: task.project_id },
});
}
ctx.logger?.info('workflow_action:projects.find_task', {
duration_ms: Date.now() - startedAt,
matched_by: matchedBy,
});
return { task: toTaskSummary(task) };
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.search_tasks',
version: 1,
inputSchema: z.object({
query: z.string().optional().describe('Search task name and description'),
filters: z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Filter by project id', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Filter by phase id', 'project-phase', ['filters.project_id']),
project_status_mapping_id: withWorkflowPicker(uuidSchema.optional(), 'Filter by project task status mapping id', 'project-task-status', ['filters.project_id', 'filters.phase_id']),
status_id: uuidSchema.optional().describe('Filter by underlying status id where supported'),
assigned_to: withWorkflowPicker(uuidSchema.optional(), 'Filter by assigned user id', 'user'),
tags: z.array(z.string().min(1)).optional(),
}).optional(),
page: z.number().int().positive().default(1),
page_size: z.number().int().positive().max(100).default(25),
}).superRefine((value, refinementCtx) => {
const filters = value.filters ?? {};
if (!value.query && !filters.project_id && !filters.phase_id && !filters.project_status_mapping_id && !filters.status_id && !filters.assigned_to && !(filters.tags?.length)) {
refinementCtx.addIssue({
code: z.ZodIssueCode.custom,
message: 'query or at least one filter is required',
});
}
}),
outputSchema: z.object({
tasks: z.array(taskSummarySchema),
first_task: taskSummarySchema.nullable(),
page: z.number().int(),
page_size: z.number().int(),
total: z.number().int(),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Search Project Tasks', category: 'Business Operations', description: 'Search project tasks by text and filters' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectReadPermission(ctx, tx);
const startedAt = Date.now();
const queryText = String(input.query ?? '').trim();
const queryPattern = `%${queryText.replace(/[%_\\]/g, (match) => `\\${match}`)}%`;
const filters = input.filters ?? {};
const page = input.page ?? 1;
const pageSize = input.page_size ?? 25;
const taskColumns = await getTableColumns(tx, 'project_tasks');
let base = tx.trx('project_tasks as pt')
.join('project_phases as pp', function joinPhases(this: Knex.JoinClause) {
this.on('pp.tenant', 'pt.tenant').andOn('pp.phase_id', 'pt.phase_id');
})
.join('projects as p', function joinProjects(this: Knex.JoinClause) {
this.on('p.tenant', 'pp.tenant').andOn('p.project_id', 'pp.project_id');
})
.where({ 'pt.tenant': tx.tenantId });
if (filters.project_id) base = base.andWhere('pp.project_id', filters.project_id);
if (filters.phase_id) base = base.andWhere('pt.phase_id', filters.phase_id);
if (filters.project_status_mapping_id && taskColumns.has('project_status_mapping_id')) {
base = base.andWhere('pt.project_status_mapping_id', filters.project_status_mapping_id);
}
if (filters.status_id && taskColumns.has('status_id')) {
base = base.andWhere('pt.status_id', filters.status_id);
}
if (filters.assigned_to) {
base = base.andWhere('pt.assigned_to', filters.assigned_to);
}
if (queryText.length > 0) {
base = base.andWhere(function queryMatch() {
this.whereRaw(`pt.task_name ILIKE ?`, [queryPattern]);
this.orWhereRaw(`COALESCE(pt.description, '') ILIKE ?`, [queryPattern]);
});
}
if (Array.isArray(filters.tags) && filters.tags.length > 0) {
base = base
.join('tag_mappings as tm', function joinMappings(this: Knex.JoinClause) {
this.on('tm.tenant', 'pt.tenant').andOn('tm.tagged_id', 'pt.task_id');
})
.join('tag_definitions as td', function joinDefinitions(this: Knex.JoinClause) {
this.on('td.tenant', 'tm.tenant').andOn('td.tag_id', 'tm.tag_id');
})
.where('tm.tagged_type', 'project_task')
.whereIn('td.tag_text', filters.tags);
}
const rows = await base
.clone()
.clearSelect()
.select('pt.*', 'pp.project_id', ...PROJECT_TABLE_AUTH_COLUMNS.map((col) => `p.${col} as project_${col}`))
.orderBy('pt.updated_at', 'desc')
.orderBy('pt.task_name', 'asc')
.orderBy('pt.task_id', 'asc');
const authorizedRows = await Promise.all(rows.map(async (row) => {
const allowed = await canReadProject(tx, {
project_id: row.project_project_id,
client_id: row.project_client_id,
assigned_to: row.project_assigned_to,
});
return allowed ? row : null;
}));
const filteredRows = authorizedRows.filter((row): row is Record<string, unknown> => Boolean(row));
const total = filteredRows.length;
const start = (page - 1) * pageSize;
const pageRows = filteredRows.slice(start, start + pageSize);
const tasks = pageRows.map((row) => toTaskSummary(row));
ctx.logger?.info('workflow_action:projects.search_tasks', {
duration_ms: Date.now() - startedAt,
query_len: queryText.length,
result_count: tasks.length,
total,
page,
page_size: pageSize,
});
return {
tasks,
first_task: tasks[0] ?? null,
page,
page_size: pageSize,
total,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.update',
version: 1,
inputSchema: z.object({
project_id: withWorkflowPicker(uuidSchema, 'Project id', 'project'),
patch: projectUpdatePatchSchema,
}),
outputSchema: z.object({
project: projectSummarySchema,
changed_fields: updateResultSchema.shape.changed_fields,
no_op: updateResultSchema.shape.no_op,
updated_at: updateResultSchema.shape.updated_at,
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Update Project', category: 'Business Operations', description: 'Update project name and description' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const projectColumns = await getTableColumns(tx, 'projects');
const project = await ensureProjectExists(ctx, tx, input.project_id);
await assertProjectReadable(ctx, tx, project);
const patch: Record<string, unknown> = {};
if (input.patch.project_name !== undefined && projectColumns.has('project_name')) patch.project_name = input.patch.project_name;
if (input.patch.description !== undefined && projectColumns.has('description')) patch.description = input.patch.description;
const changedFields = Object.keys(patch).filter((key) => String(project[key] ?? null) !== String(patch[key] ?? null));
const nowIso = new Date().toISOString();
if (changedFields.length > 0) {
await tx.trx('projects')
.where({ tenant: tx.tenantId, project_id: input.project_id })
.update({ ...patch, updated_at: nowIso });
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.update',
changedData: { project_id: input.project_id, changed_fields: changedFields },
details: { action_id: 'projects.update', action_version: 1, changed_fields: changedFields, no_op: false },
});
} else {
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.update',
changedData: { project_id: input.project_id, changed_fields: [] },
details: { action_id: 'projects.update', action_version: 1, changed_fields: [], no_op: true },
});
}
const updated = await ensureProjectExists(ctx, tx, input.project_id);
return {
project: toProjectSummary(updated),
changed_fields: changedFields,
no_op: changedFields.length === 0,
updated_at: asIsoString(updated.updated_at) ?? nowIso,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.update_phase',
version: 1,
inputSchema: z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for phase picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema, 'Project phase id', 'project-phase', ['project_id']),
patch: phaseUpdatePatchSchema,
}),
outputSchema: z.object({
phase: phaseSummarySchema,
changed_fields: updateResultSchema.shape.changed_fields,
no_op: updateResultSchema.shape.no_op,
updated_at: updateResultSchema.shape.updated_at,
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Update Project Phase', category: 'Business Operations', description: 'Update project phase name and description' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const phaseColumns = await getTableColumns(tx, 'project_phases');
const phase = await ensurePhaseExists(ctx, tx, input.phase_id);
const project = await ensureProjectExists(ctx, tx, String(phase.project_id));
await assertProjectReadable(ctx, tx, project);
const patch: Record<string, unknown> = {};
if (input.patch.phase_name !== undefined && phaseColumns.has('phase_name')) patch.phase_name = input.patch.phase_name;
if (input.patch.description !== undefined && phaseColumns.has('description')) patch.description = input.patch.description;
const changedFields = Object.keys(patch).filter((key) => String(phase[key] ?? null) !== String(patch[key] ?? null));
const nowIso = new Date().toISOString();
if (changedFields.length > 0) {
await tx.trx('project_phases')
.where({ tenant: tx.tenantId, phase_id: input.phase_id })
.update({ ...patch, updated_at: nowIso });
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.update_phase',
changedData: { phase_id: input.phase_id, project_id: phase.project_id, changed_fields: changedFields },
details: { action_id: 'projects.update_phase', action_version: 1, changed_fields: changedFields, no_op: false },
});
} else {
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.update_phase',
changedData: { phase_id: input.phase_id, project_id: phase.project_id, changed_fields: [] },
details: { action_id: 'projects.update_phase', action_version: 1, changed_fields: [], no_op: true },
});
}
const updated = await ensurePhaseExists(ctx, tx, input.phase_id);
return {
phase: toPhaseSummary(updated),
changed_fields: changedFields,
no_op: changedFields.length === 0,
updated_at: asIsoString(updated.updated_at) ?? nowIso,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.update_task',
version: 1,
inputSchema: z.object({
project_id: withWorkflowPicker(uuidSchema.optional(), 'Optional project id for task picker scope', 'project'),
phase_id: withWorkflowPicker(uuidSchema.optional(), 'Optional phase id for task picker scope', 'project-phase', ['project_id']),
task_id: withWorkflowPicker(uuidSchema, 'Project task id', 'project-task', ['project_id', 'phase_id']),
patch: taskUpdatePatchSchema,
}),
outputSchema: z.object({
task: taskSummarySchema,
changed_fields: updateResultSchema.shape.changed_fields,
no_op: updateResultSchema.shape.no_op,
updated_at: updateResultSchema.shape.updated_at,
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Update Project Task', category: 'Business Operations', description: 'Update project task title and description' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const taskColumns = await getTableColumns(tx, 'project_tasks');
const task = await ensureTaskContext(ctx, tx, input.task_id);
const project = await ensureProjectExists(ctx, tx, String(task.project_id));
await assertProjectReadable(ctx, tx, project);
const patch: Record<string, unknown> = {};
if (input.patch.task_name !== undefined && taskColumns.has('task_name')) patch.task_name = input.patch.task_name;
if (input.patch.description !== undefined && taskColumns.has('description')) patch.description = input.patch.description;
const changedFields = Object.keys(patch).filter((key) => String(task[key] ?? null) !== String(patch[key] ?? null));
const nowIso = new Date().toISOString();
if (changedFields.length > 0) {
await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.task_id })
.update({ ...patch, updated_at: nowIso });
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.update_task',
changedData: { task_id: input.task_id, project_id: task.project_id, phase_id: task.phase_id, changed_fields: changedFields },
details: { action_id: 'projects.update_task', action_version: 1, changed_fields: changedFields, no_op: false },
});
} else {
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.update_task',
changedData: { task_id: input.task_id, project_id: task.project_id, phase_id: task.phase_id, changed_fields: [] },
details: { action_id: 'projects.update_task', action_version: 1, changed_fields: [], no_op: true },
});
}
const updated = await ensureTaskContext(ctx, tx, input.task_id);
return {
task: toTaskSummary(updated),
changed_fields: changedFields,
no_op: changedFields.length === 0,
updated_at: asIsoString(updated.updated_at) ?? nowIso,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.move_task',
version: 1,
inputSchema: moveTaskInputSchema,
outputSchema: moveTaskResultSchema,
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Move Project Task', category: 'Business Operations', description: 'Move a project task to another phase/project/status mapping' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const taskColumns = await getTableColumns(tx, 'project_tasks');
const sourceTask = await ensureTaskContext(ctx, tx, input.task_id);
const sourceProject = await ensureProjectExists(ctx, tx, String(sourceTask.project_id));
await assertProjectReadable(ctx, tx, sourceProject);
const targetPhase = await ensurePhaseExists(ctx, tx, input.target_phase_id);
const targetProjectId = String(targetPhase.project_id);
if (input.target_project_id && input.target_project_id !== targetProjectId) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'target_project_id must match target phase project_id',
details: { target_project_id: input.target_project_id, target_phase_id: input.target_phase_id },
});
}
const targetProject = await ensureProjectExists(ctx, tx, targetProjectId);
await assertProjectReadable(ctx, tx, targetProject);
const targetProjectStatusMappingId = await resolveTargetProjectStatusMappingId(tx, {
sourceTask,
targetProjectId,
targetPhaseId: input.target_phase_id,
explicitTargetProjectStatusMappingId: input.target_project_status_mapping_id,
});
if (input.target_project_status_mapping_id && !targetProjectStatusMappingId) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'Invalid target_project_status_mapping_id',
details: { target_project_status_mapping_id: input.target_project_status_mapping_id },
});
}
const targetStatusId = await resolveTargetStatusId(tx, {
sourceTask,
targetProjectStatusMappingId,
});
const nextWbsCode = await generateTaskWbsCode(tx, targetPhase);
const nowIso = new Date().toISOString();
let beforeKey: string | null = null;
let afterKey: string | null = null;
if (taskColumns.has('order_key')) {
if (input.before_task_id) {
const beforeTask = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.before_task_id, phase_id: input.target_phase_id })
.first('order_key');
if (!beforeTask) {
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'before_task_id must be in the target phase' });
}
afterKey = beforeTask.order_key ?? null;
} else if (input.after_task_id) {
const afterTask = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.after_task_id, phase_id: input.target_phase_id })
.first('order_key');
if (!afterTask) {
throwActionError(ctx, { category: 'ValidationError', code: 'VALIDATION_ERROR', message: 'after_task_id must be in the target phase' });
}
beforeKey = afterTask.order_key ?? null;
} else {
const lastTask = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, phase_id: input.target_phase_id })
.modify((query) => {
if (targetProjectStatusMappingId && taskColumns.has('project_status_mapping_id')) {
query.andWhere('project_status_mapping_id', targetProjectStatusMappingId);
}
})
.orderBy('order_key', 'desc')
.first('order_key');
beforeKey = lastTask?.order_key ?? null;
}
}
const orderKey = taskColumns.has('order_key') ? generateKeyBetween(beforeKey, afterKey) : null;
const updatePayload: Record<string, unknown> = {
phase_id: input.target_phase_id,
updated_at: nowIso,
};
if (taskColumns.has('wbs_code')) updatePayload.wbs_code = nextWbsCode;
if (taskColumns.has('order_key') && orderKey) updatePayload.order_key = orderKey;
if (taskColumns.has('project_status_mapping_id')) updatePayload.project_status_mapping_id = targetProjectStatusMappingId;
if (taskColumns.has('status_id')) updatePayload.status_id = targetStatusId;
await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.task_id })
.update(updatePayload);
await tx.trx('project_ticket_links')
.where({ tenant: tx.tenantId, task_id: input.task_id })
.update({ project_id: targetProjectId, phase_id: input.target_phase_id });
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.move_task',
changedData: {
task_id: input.task_id,
from_project_id: sourceTask.project_id,
from_phase_id: sourceTask.phase_id,
to_project_id: targetProjectId,
to_phase_id: input.target_phase_id,
to_project_status_mapping_id: targetProjectStatusMappingId,
},
details: {
action_id: 'projects.move_task',
action_version: 1,
task_id: input.task_id,
},
});
const updatedTask = await ensureTaskContext(ctx, tx, input.task_id);
return moveTaskResultSchema.parse({
task_id: input.task_id,
previous_project_id: sourceTask.project_id,
previous_phase_id: sourceTask.phase_id,
previous_project_status_mapping_id: sourceTask.project_status_mapping_id ?? null,
previous_status_id: sourceTask.status_id ?? null,
current_project_id: updatedTask.project_id,
current_phase_id: updatedTask.phase_id,
current_project_status_mapping_id: updatedTask.project_status_mapping_id ?? null,
current_status_id: updatedTask.status_id ?? null,
wbs_code: updatedTask.wbs_code ?? null,
order_key: updatedTask.order_key ?? null,
updated_at: asIsoString(updatedTask.updated_at) ?? nowIso,
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.assign_task',
version: 1,
inputSchema: assignTaskInputSchema,
outputSchema: assignmentResultSchema,
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Assign Project Task', category: 'Business Operations', description: 'Assign a project task to a primary user and additional users' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const taskColumns = await getTableColumns(tx, 'project_tasks');
const task = await ensureTaskContext(ctx, tx, input.task_id);
validateOptionalTaskScope(ctx, task, { project_id: input.project_id, phase_id: input.phase_id });
const project = await ensureProjectExists(ctx, tx, String(task.project_id));
await assertProjectReadable(ctx, tx, project);
const resolvedUsers = await resolveActiveTaskAssignmentUsers(ctx, tx, {
primaryUserId: input.primary_user_id,
additionalUserIds: input.additional_user_ids ?? [],
});
const currentAdditionalUserIds = await getCurrentTaskAdditionalUserIds(tx, input.task_id);
const requestedAdditionalUserIds = uniqueStringsSorted(resolvedUsers.additionalUserIds);
const noOp = (
parseNullableUuid(task.assigned_to) === resolvedUsers.primaryUserId &&
currentAdditionalUserIds.join(',') === requestedAdditionalUserIds.join(',')
);
if (noOp && input.no_op_if_already_assigned !== false) {
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.assign_task',
changedData: {
task_id: input.task_id,
project_id: task.project_id,
phase_id: task.phase_id,
assigned_to: resolvedUsers.primaryUserId,
additional_user_ids: requestedAdditionalUserIds,
},
details: {
action_id: 'projects.assign_task',
action_version: 1,
task_id: input.task_id,
no_op: true,
reason: input.reason ?? null,
},
});
return assignmentResultSchema.parse({
task_id: input.task_id,
assigned_to: resolvedUsers.primaryUserId,
additional_user_ids: requestedAdditionalUserIds,
no_op: true,
updated_at: asIsoString(task.updated_at) ?? new Date().toISOString(),
});
}
const nowIso = new Date().toISOString();
const taskPatch: Record<string, unknown> = {
assigned_to: resolvedUsers.primaryUserId,
updated_at: nowIso,
};
if (taskColumns.has('assigned_team_id')) taskPatch.assigned_team_id = null;
await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.task_id })
.update(taskPatch);
await reconcileTaskAdditionalUsers(
tx,
input.task_id,
resolvedUsers.primaryUserId,
requestedAdditionalUserIds
);
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.assign_task',
changedData: {
task_id: input.task_id,
project_id: task.project_id,
phase_id: task.phase_id,
assigned_to: resolvedUsers.primaryUserId,
additional_user_ids: requestedAdditionalUserIds,
},
details: {
action_id: 'projects.assign_task',
action_version: 1,
task_id: input.task_id,
no_op: false,
reason: input.reason ?? null,
},
});
const updatedTask = await ensureTaskContext(ctx, tx, input.task_id);
return assignmentResultSchema.parse({
task_id: input.task_id,
assigned_to: parseNullableUuid(updatedTask.assigned_to),
additional_user_ids: requestedAdditionalUserIds,
no_op: noOp,
updated_at: asIsoString(updatedTask.updated_at) ?? nowIso,
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.duplicate_task',
version: 1,
inputSchema: duplicateTaskInputSchema,
outputSchema: duplicateTaskResultSchema,
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Duplicate Project Task', category: 'Business Operations', description: 'Duplicate a task into a target project phase' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requirePermission(ctx, tx, { resource: 'project', action: 'create' });
await requireProjectReadPermission(ctx, tx);
const sourceTask = await ensureTaskContext(ctx, tx, input.source_task_id);
const sourceProject = await ensureProjectExists(ctx, tx, String(sourceTask.project_id));
await assertProjectReadable(ctx, tx, sourceProject);
const targetPhase = await ensurePhaseExists(ctx, tx, input.target_phase_id);
const targetProjectId = String(targetPhase.project_id);
const targetProject = await ensureProjectExists(ctx, tx, targetProjectId);
await assertProjectReadable(ctx, tx, targetProject);
const explicitMapping = input.target_project_status_mapping_id
? await ensureStatusMappingExists(ctx, tx, input.target_project_status_mapping_id)
: null;
if (explicitMapping && explicitMapping.project_id !== targetProjectId) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'target_project_status_mapping_id does not belong to the target project',
details: {
target_project_status_mapping_id: input.target_project_status_mapping_id,
target_project_id: targetProjectId,
},
});
}
const targetProjectStatusMappingId = await resolveTargetProjectStatusMappingId(tx, {
sourceTask,
targetProjectId,
targetPhaseId: input.target_phase_id,
explicitTargetProjectStatusMappingId: input.target_project_status_mapping_id,
});
const targetStatusId = await resolveTargetStatusId(tx, {
sourceTask,
targetProjectStatusMappingId,
});
const sourceTaskRow = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.source_task_id })
.first();
if (!sourceTaskRow) {
throwActionError(ctx, {
category: 'ActionError',
code: 'NOT_FOUND',
message: 'Project task not found',
details: { task_id: input.source_task_id },
});
}
const taskColumns = await getTableColumns(tx, 'project_tasks');
const copiedTaskId = uuidv4();
const nowIso = new Date().toISOString();
const wbsCode = await generateTaskWbsCode(tx, targetPhase);
const orderKey = taskColumns.has('order_key')
? `${Date.now().toString(36)}-${uuidv4().slice(0, 8)}`
: null;
const assignedTo = input.copy_primary_assignee
? parseNullableUuid(sourceTaskRow.assigned_to)
: null;
const copiedTaskRow: Record<string, unknown> = {
...sourceTaskRow,
task_id: copiedTaskId,
phase_id: input.target_phase_id,
task_name: `${String(sourceTaskRow.task_name ?? '')} (Copy)`,
assigned_to: assignedTo,
updated_at: nowIso,
created_at: nowIso,
};
if (taskColumns.has('assigned_team_id')) copiedTaskRow.assigned_team_id = null;
if (taskColumns.has('actual_hours')) copiedTaskRow.actual_hours = 0;
if (taskColumns.has('estimated_hours')) copiedTaskRow.estimated_hours = sourceTaskRow.estimated_hours ?? null;
if (taskColumns.has('project_status_mapping_id')) copiedTaskRow.project_status_mapping_id = targetProjectStatusMappingId;
if (taskColumns.has('status_id')) copiedTaskRow.status_id = targetStatusId;
if (taskColumns.has('wbs_code')) copiedTaskRow.wbs_code = wbsCode;
if (taskColumns.has('order_key') && orderKey) copiedTaskRow.order_key = orderKey;
await tx.trx('project_tasks').insert(copiedTaskRow);
let copiedChecklistCount = 0;
if (input.copy_checklist) {
const hasChecklist = await tx.trx.schema.hasTable('task_checklist_items');
if (hasChecklist) {
const checklistRows = await tx.trx('task_checklist_items')
.where({ tenant: tx.tenantId, task_id: input.source_task_id })
.select('*');
if (checklistRows.length > 0) {
const checklistColumns = await getTableColumns(tx, 'task_checklist_items');
const checklistInserts = checklistRows.map((row: Record<string, unknown>) => {
const item: Record<string, unknown> = {
...row,
checklist_item_id: uuidv4(),
task_id: copiedTaskId,
};
if (checklistColumns.has('created_at')) item.created_at = nowIso;
if (checklistColumns.has('updated_at')) item.updated_at = nowIso;
return item;
});
await tx.trx('task_checklist_items').insert(checklistInserts);
copiedChecklistCount = checklistInserts.length;
}
}
}
let copiedAdditionalAssigneeCount = 0;
if (input.copy_additional_assignees) {
const hasTaskResources = await tx.trx.schema.hasTable('task_resources');
if (hasTaskResources) {
const sourceResources = await tx.trx('task_resources')
.where({ tenant: tx.tenantId, task_id: input.source_task_id })
.whereNotNull('additional_user_id')
.select('*');
if (sourceResources.length > 0) {
const resourceColumns = await getTableColumns(tx, 'task_resources');
const inserts = sourceResources.map((row: Record<string, unknown>) => ({
...row,
assignment_id: uuidv4(),
task_id: copiedTaskId,
assigned_to: assignedTo ?? parseNullableUuid(row.assigned_to) ?? String(row.additional_user_id),
assigned_at: resourceColumns.has('assigned_at') ? nowIso : row.assigned_at,
}));
await tx.trx('task_resources').insert(inserts);
copiedAdditionalAssigneeCount = inserts.length;
}
}
}
let copiedTicketLinkCount = 0;
if (input.copy_ticket_links) {
const sourceLinks = await tx.trx('project_ticket_links')
.where({ tenant: tx.tenantId, task_id: input.source_task_id })
.select('*');
if (sourceLinks.length > 0) {
const canReadTicketLinks = await canReadTickets(ctx, tx);
const sourceTicketIds = sourceLinks.map((link: Record<string, unknown>) => String(link.ticket_id));
const tickets = canReadTicketLinks
? await tx.trx('tickets').where({ tenant: tx.tenantId }).whereIn('ticket_id', sourceTicketIds).select('*')
: [];
const authorizeTicket = await createTicketReadAuthorizer(tx);
const ticketAuthorization = await Promise.all(tickets.map((ticket: Record<string, unknown>) => authorizeTicket(ticket)));
const allowedTicketIds = new Set(
tickets
.filter((_, idx) => ticketAuthorization[idx])
.map((ticket: Record<string, unknown>) => String(ticket.ticket_id))
);
if (allowedTicketIds.size > 0) {
const inserts = sourceLinks
.filter((link: Record<string, unknown>) => allowedTicketIds.has(String(link.ticket_id)))
.map((link: Record<string, unknown>) => ({
...link,
link_id: uuidv4(),
project_id: targetProjectId,
phase_id: input.target_phase_id,
task_id: copiedTaskId,
created_at: nowIso,
}));
for (const link of inserts) {
const ticketId = String((link as Record<string, unknown>).ticket_id);
await tx.trx('project_ticket_links').insert(link).catch(() => undefined);
await tx.trx('ticket_entity_links').insert({
tenant: tx.tenantId,
link_id: uuidv4(),
ticket_id: ticketId,
entity_type: 'project_task',
entity_id: copiedTaskId,
link_type: 'project_task',
metadata: { project_id: targetProjectId, phase_id: input.target_phase_id },
created_at: nowIso,
}).catch(() => undefined);
copiedTicketLinkCount += 1;
}
}
}
}
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.duplicate_task',
changedData: {
source_task_id: input.source_task_id,
task_id: copiedTaskId,
target_project_id: targetProjectId,
target_phase_id: input.target_phase_id,
copied_checklist_count: copiedChecklistCount,
copied_additional_assignee_count: copiedAdditionalAssigneeCount,
copied_ticket_link_count: copiedTicketLinkCount,
},
details: {
action_id: 'projects.duplicate_task',
action_version: 1,
source_task_id: input.source_task_id,
task_id: copiedTaskId,
},
});
return duplicateTaskResultSchema.parse({
source_task_id: input.source_task_id,
task_id: copiedTaskId,
target_project_id: targetProjectId,
target_phase_id: input.target_phase_id,
target_project_status_mapping_id: targetProjectStatusMappingId,
target_status_id: targetStatusId,
copied_checklist_count: copiedChecklistCount,
copied_additional_assignee_count: copiedAdditionalAssigneeCount,
copied_ticket_link_count: copiedTicketLinkCount,
created_at: nowIso,
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.delete_task',
version: 1,
inputSchema: deleteTaskInputSchema,
outputSchema: deleteTaskResultSchema,
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Delete Project Task', category: 'Business Operations', description: 'Delete a project task' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectDeletePermission(ctx, tx);
const task = await ensureTaskContext(ctx, tx, input.task_id);
validateOptionalTaskScope(ctx, task, { project_id: input.project_id, phase_id: input.phase_id });
const project = await ensureProjectExists(ctx, tx, String(task.project_id));
await assertProjectReadable(ctx, tx, project);
const timeEntriesExist = await tx.trx.schema.hasTable('time_entries');
if (timeEntriesExist) {
const timeEntryCountRow = await tx.trx('time_entries')
.where({ tenant: tx.tenantId, work_item_id: input.task_id, work_item_type: 'project_task' })
.count('* as count')
.first();
const timeEntryCount = Number((timeEntryCountRow as { count?: string | number } | undefined)?.count ?? 0);
if (timeEntryCount > 0) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: `Cannot delete task: ${timeEntryCount} associated time entries exist.`,
details: { task_id: input.task_id, time_entry_count: timeEntryCount },
});
}
}
await deleteFromTableIfExists(tx, 'project_task_dependencies', (query) =>
query.where({ tenant: tx.tenantId }).andWhere(function dependenciesForTask(this: Knex.QueryBuilder) {
this.where('predecessor_task_id', input.task_id).orWhere('successor_task_id', input.task_id);
})
);
const taskCommentIds = await tx.trx.schema.hasTable('project_task_comments')
? await tx.trx('project_task_comments')
.where({ tenant: tx.tenantId, task_id: input.task_id })
.pluck<string[]>('task_comment_id')
: [];
if (taskCommentIds.length > 0) {
await deleteFromTableIfExists(tx, 'project_task_comment_reactions', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_comment_id', taskCommentIds)
);
}
await deleteFromTableIfExists(tx, 'project_task_comments', (query) =>
query.where({ tenant: tx.tenantId, task_id: input.task_id })
);
const deletedTicketLinks = await deleteFromTableIfExists(tx, 'project_ticket_links', (query) =>
query.where({ tenant: tx.tenantId, task_id: input.task_id })
);
await deleteFromTableIfExists(tx, 'ticket_entity_links', (query) =>
query.where({ tenant: tx.tenantId, entity_type: 'project_task', entity_id: input.task_id })
);
const deletedChecklistItems = await deleteFromTableIfExists(tx, 'task_checklist_items', (query) =>
query.where({ tenant: tx.tenantId, task_id: input.task_id })
);
await deleteFromTableIfExists(tx, 'task_resources', (query) =>
query.where({ tenant: tx.tenantId, task_id: input.task_id })
);
await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, task_id: input.task_id })
.delete();
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.delete_task',
changedData: {
task_id: input.task_id,
project_id: task.project_id,
phase_id: task.phase_id,
deleted_ticket_link_count: deletedTicketLinks,
deleted_checklist_item_count: deletedChecklistItems,
},
details: {
action_id: 'projects.delete_task',
action_version: 1,
task_id: input.task_id,
},
});
return deleteTaskResultSchema.parse({
task_id: input.task_id,
deleted: true,
deleted_ticket_link_count: deletedTicketLinks,
deleted_checklist_item_count: deletedChecklistItems,
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.delete_phase',
version: 1,
inputSchema: deletePhaseInputSchema,
outputSchema: deletePhaseResultSchema,
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Delete Project Phase', category: 'Business Operations', description: 'Delete a project phase' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectDeletePermission(ctx, tx);
const phase = await ensurePhaseExists(ctx, tx, input.phase_id);
const project = await ensureProjectExists(ctx, tx, String(phase.project_id));
await assertProjectReadable(ctx, tx, project);
const taskIds = await tx.trx('project_tasks')
.where({ tenant: tx.tenantId, phase_id: input.phase_id })
.pluck<string[]>('task_id');
if (taskIds.length > 0) {
const timeEntriesExist = await tx.trx.schema.hasTable('time_entries');
if (timeEntriesExist) {
const timeEntryCountRow = await tx.trx('time_entries')
.where({ tenant: tx.tenantId, work_item_type: 'project_task' })
.whereIn('work_item_id', taskIds)
.count('* as count')
.first();
const timeEntryCount = Number((timeEntryCountRow as { count?: string | number } | undefined)?.count ?? 0);
if (timeEntryCount > 0) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: `Cannot delete phase: ${timeEntryCount} associated task time entries exist.`,
details: { phase_id: input.phase_id, time_entry_count: timeEntryCount },
});
}
}
await deleteFromTableIfExists(tx, 'project_task_dependencies', (query) =>
query.where({ tenant: tx.tenantId }).andWhere(function dependenciesForPhaseTasks(this: Knex.QueryBuilder) {
this.whereIn('predecessor_task_id', taskIds).orWhereIn('successor_task_id', taskIds);
})
);
const taskCommentIds = await tx.trx.schema.hasTable('project_task_comments')
? await tx.trx('project_task_comments')
.where({ tenant: tx.tenantId })
.whereIn('task_id', taskIds)
.pluck<string[]>('task_comment_id')
: [];
if (taskCommentIds.length > 0) {
await deleteFromTableIfExists(tx, 'project_task_comment_reactions', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_comment_id', taskCommentIds)
);
}
await deleteFromTableIfExists(tx, 'project_task_comments', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
await deleteFromTableIfExists(tx, 'task_resources', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
await deleteFromTableIfExists(tx, 'task_checklist_items', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
await deleteFromTableIfExists(tx, 'project_ticket_links', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
await deleteFromTableIfExists(tx, 'ticket_entity_links', (query) =>
query.where({ tenant: tx.tenantId, entity_type: 'project_task' }).whereIn('entity_id', taskIds)
);
await tx.trx('project_tasks')
.where({ tenant: tx.tenantId })
.whereIn('task_id', taskIds)
.delete();
}
const deleted = await tx.trx('project_phases')
.where({ tenant: tx.tenantId, phase_id: input.phase_id })
.delete();
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.delete_phase',
changedData: {
phase_id: input.phase_id,
project_id: phase.project_id,
},
details: {
action_id: 'projects.delete_phase',
action_version: 1,
phase_id: input.phase_id,
},
});
return deletePhaseResultSchema.parse({
phase_id: input.phase_id,
project_id: String(phase.project_id),
deleted: Number(deleted ?? 0) > 0,
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.delete',
version: 1,
inputSchema: deleteProjectInputSchema,
outputSchema: deleteProjectResultSchema,
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: { label: 'Delete Project', category: 'Business Operations', description: 'Delete a project with validation and cleanup' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectDeletePermission(ctx, tx);
const project = await ensureProjectExists(ctx, tx, input.project_id);
await assertProjectReadable(ctx, tx, project);
const deletionConfig = getDeletionConfig('project');
if (!deletionConfig) {
return deleteProjectResultSchema.parse({
success: false,
can_delete: false,
deleted: false,
code: 'UNKNOWN_ENTITY',
message: 'Unknown entity type: project',
dependencies: [],
alternatives: [],
});
}
const validation = await validateDeletion(tx.trx, deletionConfig, input.project_id, tx.tenantId);
if (!validation.canDelete) {
return deleteProjectResultSchema.parse({
success: false,
can_delete: false,
deleted: false,
code: validation.code ?? 'VALIDATION_FAILED',
message: validation.message ?? 'Project cannot be deleted',
dependencies: validation.dependencies ?? [],
alternatives: validation.alternatives ?? [],
});
}
const phaseIds = await tx.trx('project_phases')
.where({ tenant: tx.tenantId, project_id: input.project_id })
.pluck<string[]>('phase_id');
const taskIds = phaseIds.length > 0
? await tx.trx('project_tasks')
.where({ tenant: tx.tenantId })
.whereIn('phase_id', phaseIds)
.pluck<string[]>('task_id')
: [];
const timeEntriesExist = await tx.trx.schema.hasTable('time_entries');
if (timeEntriesExist && taskIds.length > 0) {
const timeEntryCountRow = await tx.trx('time_entries')
.where({ tenant: tx.tenantId, work_item_type: 'project_task' })
.whereIn('work_item_id', taskIds)
.count('* as count')
.first();
const timeEntryCount = Number((timeEntryCountRow as { count?: string | number } | undefined)?.count ?? 0);
if (timeEntryCount > 0) {
return deleteProjectResultSchema.parse({
success: false,
can_delete: false,
deleted: false,
code: 'VALIDATION_FAILED',
message: `Cannot delete project: ${timeEntryCount} associated task time entries exist.`,
dependencies: [{ type: 'time_entries', count: timeEntryCount }],
alternatives: [],
});
}
}
await deleteFromTableIfExists(tx, 'tag_mappings', (query) =>
query.where({ tenant: tx.tenantId, tagged_type: 'project', tagged_id: input.project_id })
);
if (taskIds.length > 0) {
await deleteFromTableIfExists(tx, 'tag_mappings', (query) =>
query.where({ tenant: tx.tenantId, tagged_type: 'project_task' }).whereIn('tagged_id', taskIds)
);
await deleteFromTableIfExists(tx, 'project_task_dependencies', (query) =>
query.where({ tenant: tx.tenantId }).andWhere(function dependenciesForProjectTasks(this: Knex.QueryBuilder) {
this.whereIn('predecessor_task_id', taskIds).orWhereIn('successor_task_id', taskIds);
})
);
const taskCommentIds = await tx.trx.schema.hasTable('project_task_comments')
? await tx.trx('project_task_comments')
.where({ tenant: tx.tenantId })
.whereIn('task_id', taskIds)
.pluck<string[]>('task_comment_id')
: [];
if (taskCommentIds.length > 0) {
await deleteFromTableIfExists(tx, 'project_task_comment_reactions', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_comment_id', taskCommentIds)
);
}
await deleteFromTableIfExists(tx, 'project_task_comments', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
await deleteFromTableIfExists(tx, 'ticket_entity_links', (query) =>
query.where({ tenant: tx.tenantId, entity_type: 'project_task' }).whereIn('entity_id', taskIds)
);
await deleteFromTableIfExists(tx, 'task_resources', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
await deleteFromTableIfExists(tx, 'task_checklist_items', (query) =>
query.where({ tenant: tx.tenantId }).whereIn('task_id', taskIds)
);
}
await deleteFromTableIfExists(tx, 'project_ticket_links', (query) =>
query.where({ tenant: tx.tenantId, project_id: input.project_id })
);
await deleteFromTableIfExists(tx, 'email_reply_tokens', (query) =>
query.where({ tenant: tx.tenantId, project_id: input.project_id })
);
if (taskIds.length > 0) {
await tx.trx('project_tasks')
.where({ tenant: tx.tenantId })
.whereIn('task_id', taskIds)
.delete();
}
if (phaseIds.length > 0) {
await tx.trx('project_phases')
.where({ tenant: tx.tenantId })
.whereIn('phase_id', phaseIds)
.delete();
}
await deleteFromTableIfExists(tx, 'project_status_mappings', (query) =>
query.where({ tenant: tx.tenantId, project_id: input.project_id })
);
const deleted = await tx.trx('projects')
.where({ tenant: tx.tenantId, project_id: input.project_id })
.delete();
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.delete',
changedData: {
project_id: input.project_id,
deleted: Number(deleted ?? 0) > 0,
},
details: {
action_id: 'projects.delete',
action_version: 1,
project_id: input.project_id,
},
});
return deleteProjectResultSchema.parse({
success: Number(deleted ?? 0) > 0,
deleted: Number(deleted ?? 0) > 0,
can_delete: true,
code: null,
message: null,
dependencies: [],
alternatives: [],
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.link_ticket_to_task',
version: 1,
inputSchema: linkTicketToTaskInputSchema,
outputSchema: linkTicketToTaskResultSchema,
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: { label: 'Link Ticket to Project Task', category: 'Business Operations', description: 'Link a ticket to an existing project task' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
await requirePermission(ctx, tx, { resource: 'ticket', action: 'read' });
const task = await ensureTaskContext(ctx, tx, input.task_id);
const project = await ensureProjectExists(ctx, tx, String(task.project_id));
await assertProjectReadable(ctx, tx, project);
const ticket = await ensureTicketExists(ctx, tx, input.ticket_id);
await assertTicketReadable(ctx, tx, ticket);
if (input.project_id && input.project_id !== String(task.project_id)) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'project_id does not match task project',
details: { project_id: input.project_id, task_project_id: task.project_id },
});
}
if (input.phase_id && input.phase_id !== String(task.phase_id)) {
throwActionError(ctx, {
category: 'ValidationError',
code: 'VALIDATION_ERROR',
message: 'phase_id does not match task phase',
details: { phase_id: input.phase_id, task_phase_id: task.phase_id },
});
}
const nowIso = new Date().toISOString();
const existingProjectLink = await tx.trx('project_ticket_links')
.where({ tenant: tx.tenantId, task_id: input.task_id, ticket_id: input.ticket_id })
.first();
let projectTicketLinkCreated = false;
let projectTicketLinkId = parseNullableUuid(existingProjectLink?.link_id) ?? null;
if (!existingProjectLink) {
const insertedProjectLinks = await tx.trx('project_ticket_links')
.insert({
tenant: tx.tenantId,
link_id: uuidv4(),
project_id: task.project_id,
phase_id: task.phase_id,
task_id: input.task_id,
ticket_id: input.ticket_id,
created_at: nowIso,
})
.returning('link_id')
.catch(() => []);
const insertedProjectLinkId = Array.isArray(insertedProjectLinks) && insertedProjectLinks.length > 0
? parseNullableUuid((insertedProjectLinks[0] as Record<string, unknown>).link_id)
: null;
const resolvedProjectLink = insertedProjectLinkId
? { link_id: insertedProjectLinkId }
: await tx.trx('project_ticket_links')
.where({ tenant: tx.tenantId, task_id: input.task_id, ticket_id: input.ticket_id })
.first('link_id');
projectTicketLinkId = parseNullableUuid(resolvedProjectLink?.link_id) ?? null;
projectTicketLinkCreated = Boolean(insertedProjectLinkId);
}
const existingEntityLink = await tx.trx('ticket_entity_links')
.where({
tenant: tx.tenantId,
ticket_id: input.ticket_id,
entity_type: 'project_task',
entity_id: input.task_id,
link_type: 'project_task',
})
.first();
let ticketEntityLinkCreated = false;
let ticketEntityLinkId = parseNullableUuid(existingEntityLink?.link_id) ?? null;
if (!existingEntityLink) {
const insertedEntityLinks = await tx.trx('ticket_entity_links')
.insert({
tenant: tx.tenantId,
link_id: uuidv4(),
ticket_id: input.ticket_id,
entity_type: 'project_task',
entity_id: input.task_id,
link_type: 'project_task',
metadata: { project_id: task.project_id, phase_id: task.phase_id },
created_at: nowIso,
})
.returning('link_id')
.catch(() => []);
const insertedEntityLinkId = Array.isArray(insertedEntityLinks) && insertedEntityLinks.length > 0
? parseNullableUuid((insertedEntityLinks[0] as Record<string, unknown>).link_id)
: null;
const resolvedEntityLink = insertedEntityLinkId
? { link_id: insertedEntityLinkId }
: await tx.trx('ticket_entity_links')
.where({
tenant: tx.tenantId,
ticket_id: input.ticket_id,
entity_type: 'project_task',
entity_id: input.task_id,
link_type: 'project_task',
})
.first('link_id');
ticketEntityLinkId = parseNullableUuid(resolvedEntityLink?.link_id) ?? null;
ticketEntityLinkCreated = Boolean(insertedEntityLinkId);
}
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.link_ticket_to_task',
changedData: {
task_id: input.task_id,
project_id: task.project_id,
phase_id: task.phase_id,
ticket_id: input.ticket_id,
project_ticket_link_created: projectTicketLinkCreated,
ticket_entity_link_created: ticketEntityLinkCreated,
},
details: {
action_id: 'projects.link_ticket_to_task',
action_version: 1,
task_id: input.task_id,
ticket_id: input.ticket_id,
},
});
return linkTicketToTaskResultSchema.parse({
task_id: input.task_id,
ticket_id: input.ticket_id,
project_ticket_link_id: projectTicketLinkId,
ticket_entity_link_id: ticketEntityLinkId,
project_ticket_link_created: projectTicketLinkCreated,
ticket_entity_link_created: ticketEntityLinkCreated,
});
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.add_tag',
version: 1,
inputSchema: addTagInputSchema,
outputSchema: z.object({
project_id: uuidSchema,
added: tagMutationResultSchema.shape.added,
existing: tagMutationResultSchema.shape.existing,
added_count: tagMutationResultSchema.shape.added_count,
existing_count: tagMutationResultSchema.shape.existing_count,
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: { label: 'Add Tag to Project', category: 'Business Operations', description: 'Attach one or more tags to a project' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const project = await ensureProjectExists(ctx, tx, input.project_id);
await assertProjectReadable(ctx, tx, project);
const tagResult = await ensureTagMappings(tx, {
taggedType: 'project',
taggedId: input.project_id,
tags: input.tags,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.add_tag',
changedData: {
project_id: input.project_id,
added_count: tagResult.added.length,
existing_count: tagResult.existing.length,
},
details: { action_id: 'projects.add_tag', action_version: 1, project_id: input.project_id },
});
return {
project_id: input.project_id,
added: tagResult.added,
existing: tagResult.existing,
added_count: tagResult.added.length,
existing_count: tagResult.existing.length,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
registry.register({
id: 'projects.add_task_tag',
version: 1,
inputSchema: addTaskTagInputSchema,
outputSchema: z.object({
task_id: uuidSchema,
added: tagMutationResultSchema.shape.added,
existing: tagMutationResultSchema.shape.existing,
added_count: tagMutationResultSchema.shape.added_count,
existing_count: tagMutationResultSchema.shape.existing_count,
}),
sideEffectful: true,
idempotency: { mode: 'actionProvided', key: actionProvidedKey },
ui: { label: 'Add Tag to Project Task', category: 'Business Operations', description: 'Attach one or more tags to a project task' },
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
try {
await requireProjectUpdatePermission(ctx, tx);
const task = await ensureTaskContext(ctx, tx, input.task_id);
validateOptionalTaskScope(ctx, task, { project_id: input.project_id, phase_id: input.phase_id });
const project = await ensureProjectExists(ctx, tx, String(task.project_id));
await assertProjectReadable(ctx, tx, project);
const tagResult = await ensureTagMappings(tx, {
taggedType: 'project_task',
taggedId: input.task_id,
tags: input.tags,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:projects.add_task_tag',
changedData: {
task_id: input.task_id,
project_id: task.project_id,
phase_id: task.phase_id,
added_count: tagResult.added.length,
existing_count: tagResult.existing.length,
},
details: { action_id: 'projects.add_task_tag', action_version: 1, task_id: input.task_id },
});
return {
task_id: input.task_id,
added: tagResult.added,
existing: tagResult.existing,
added_count: tagResult.added.length,
existing_count: tagResult.existing.length,
};
} catch (error) {
handleActionError(ctx, error);
}
}),
});
void ensureTicketExists;
void ensurePhaseExists;
void ensureTaskContext;
void ensureStatusMappingExists;
void requireProjectUpdatePermission;
void requireProjectDeletePermission;
void tagResultSchema;
void assignmentResultSchema;
void linkResultSchema;
void assignTaskInputSchema;
void duplicateTaskInputSchema;
void deleteTaskInputSchema;
void deletePhaseInputSchema;
void deleteProjectInputSchema;
void linkTicketToTaskInputSchema;
void addTagInputSchema;
void addTaskTagInputSchema;
}