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

1418 lines
51 KiB
TypeScript

import { z } from 'zod';
import { getActionRegistryV2 } from '../../registries/actionRegistry';
import { withWorkflowJsonSchemaMetadata } from '../../jsonSchemaMetadata';
import {
uuidSchema,
isoDateTimeSchema,
withTenantTransaction,
requirePermission,
writeRunAudit,
throwActionError,
rethrowAsStandardError,
} from './shared';
import {
createWorkflowTimeEntry,
updateWorkflowTimeEntry,
deleteWorkflowTimeEntry,
getWorkflowTimeEntry,
findWorkflowTimeEntries,
setWorkflowTimeEntryApprovalStatus,
requestWorkflowTimeEntryChanges,
findOrCreateWorkflowTimeSheet,
getWorkflowTimeSheet,
findWorkflowTimeSheets,
submitWorkflowTimeSheet,
approveWorkflowTimeSheet,
requestWorkflowTimeSheetChanges,
reverseWorkflowTimeSheetApproval,
addWorkflowTimeSheetComment,
summarizeWorkflowTimeEntries,
findWorkflowTimeBillingBlockers,
validateWorkflowTimeEntries,
WorkflowTimeDomainError,
type WorkflowTimeCreateEntryInput,
type WorkflowTimeUpdateEntryInput,
type WorkflowTimeFindEntriesInput,
type WorkflowTimeApprovalStatus,
type WorkflowTimeSummaryGroupBy,
} from './timeDomain';
const TIME_WORKFLOW_PICKER_HINTS = {
user: 'Search users',
ticket: 'Search tickets',
} as const;
const withTimeWorkflowPicker = <T extends z.ZodTypeAny>(
schema: T,
description: string,
kind: keyof typeof TIME_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': TIME_WORKFLOW_PICKER_HINTS[kind],
'x-workflow-picker-allow-dynamic-reference': true,
});
const withTimeWorkflowTextarea = <T extends z.ZodTypeAny>(schema: T, description: string): T =>
withWorkflowJsonSchemaMetadata(schema, description, {
'x-workflow-editor': {
kind: 'text',
inline: { mode: 'textarea' },
dialog: { mode: 'large-text' },
allowsDynamicReference: true,
},
});
const timeEntryLinkSchema = z.object({
type: z.enum(['ticket', 'project', 'project_task', 'interaction', 'ad_hoc', 'non_billable_category'])
.describe('Work item type for the time entry'),
id: uuidSchema.describe('Work item id matching the selected work item type')
});
export function registerTimeActions(): void {
const registry = getActionRegistryV2();
registry.register({
id: 'time.create_entry',
version: 1,
inputSchema: z.object({
user_id: withTimeWorkflowPicker(uuidSchema, 'User id that owns the time entry', 'user'),
start: isoDateTimeSchema.describe('Start timestamp in ISO-8601 format'),
end: isoDateTimeSchema.optional().describe('End timestamp in ISO-8601 format'),
duration_minutes: z.number().int().min(0).optional().describe('Duration in minutes (used when end is omitted)'),
billable: z.boolean().default(true).describe('Whether this entry should be billable'),
billable_duration_minutes: z.number().int().min(0).optional().describe('Optional explicit billable duration override in minutes'),
link: timeEntryLinkSchema.optional().describe('Optional work item link'),
service_id: uuidSchema.describe('Service id for the time entry'),
contract_line_id: uuidSchema.nullable().optional().describe('Optional contract line id'),
tax_rate_id: uuidSchema.nullable().optional().describe('Optional tax rate id'),
notes: withTimeWorkflowTextarea(z.string().optional(), 'Optional notes'),
time_sheet_id: uuidSchema.nullable().optional().describe('Optional explicit time sheet id'),
attach_to_timesheet: z.boolean().default(true).describe('Automatically find/create a time sheet from work_date when true'),
billing_plan_id: uuidSchema.nullable().optional().describe('Deprecated alias for contract_line_id (kept for compatibility)')
}).superRefine((value, ctx) => {
if (!value.end && value.duration_minutes === undefined) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Provide either end or duration_minutes',
path: ['end']
});
}
}),
outputSchema: z.object({
time_entry: z.object({
entry_id: uuidSchema,
user_id: uuidSchema,
work_item_id: uuidSchema.nullable(),
work_item_type: z.string().nullable(),
service_id: uuidSchema,
contract_line_id: uuidSchema.nullable(),
time_sheet_id: uuidSchema.nullable(),
start_time: isoDateTimeSchema,
end_time: isoDateTimeSchema,
total_minutes: z.number().int(),
billable_minutes: z.number().int(),
work_date: z.string(),
work_timezone: z.string(),
approval_status: z.string(),
invoiced: z.boolean(),
notes: z.string().nullable(),
})
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Create Time Entry',
category: 'Business Operations',
description: 'Create a workflow-safe time entry using canonical time module behavior'
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'create' });
if (input.user_id !== tx.actorUserId) {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'update' });
}
try {
const createInput: WorkflowTimeCreateEntryInput = {
user_id: input.user_id,
start: input.start,
end: input.end,
duration_minutes: input.duration_minutes,
billable: input.billable,
billable_duration_minutes: input.billable_duration_minutes,
link: input.link ? { type: input.link.type, id: input.link.id } : undefined,
service_id: input.service_id,
contract_line_id: input.contract_line_id ?? input.billing_plan_id ?? null,
tax_rate_id: input.tax_rate_id,
notes: input.notes,
time_sheet_id: input.time_sheet_id,
attach_to_timesheet: input.attach_to_timesheet,
};
const created = await createWorkflowTimeEntry({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input: createInput,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.create_entry',
changedData: {
entry_id: created.entry_id,
user_id: created.user_id,
service_id: created.service_id,
work_item_id: created.work_item_id,
work_item_type: created.work_item_type,
total_minutes: created.total_minutes,
billable_minutes: created.billable_minutes,
work_date: created.work_date,
time_sheet_id: created.time_sheet_id,
contract_line_id: created.contract_line_id,
approval_status: created.approval_status,
},
details: {
action_id: 'time.create_entry',
action_version: 1,
entry_id: created.entry_id,
}
});
return { time_entry: created };
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
})
});
registry.register({
id: 'time.summarize_entries',
version: 1,
inputSchema: z.object({
group_by: z.array(z.enum([
'user_id',
'client_id',
'work_item_type',
'work_item_id',
'service_id',
'contract_line_id',
'approval_status',
'billable',
'work_date',
])).max(5).optional(),
user_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Optional user filter', 'user'),
work_item_id: uuidSchema.optional(),
work_item_type: z.enum(['ticket', 'project', 'project_task', 'interaction', 'ad_hoc', 'non_billable_category']).optional(),
client_id: uuidSchema.optional(),
ticket_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Optional ticket filter', 'ticket'),
project_task_id: uuidSchema.optional(),
time_sheet_id: uuidSchema.optional(),
service_id: uuidSchema.optional(),
contract_line_id: uuidSchema.optional(),
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']).optional(),
billable: z.boolean().optional(),
work_date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
work_date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
start_from: isoDateTimeSchema.optional(),
start_to: isoDateTimeSchema.optional(),
invoiced: z.boolean().optional(),
limit: z.number().int().min(1).max(500).default(200),
}),
outputSchema: z.object({
totals: z.object({
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
non_billable_minutes: z.number(),
approved_count: z.number().int(),
submitted_count: z.number().int(),
draft_count: z.number().int(),
changes_requested_count: z.number().int(),
invoiced_count: z.number().int(),
}),
groups: z.array(z.object({
key: z.record(z.union([z.string(), z.boolean(), z.null()])),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
})),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Summarize Time Entries',
category: 'Business Operations',
description: 'Summarize filtered time entries with optional grouping dimensions',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'read' });
try {
return await summarizeWorkflowTimeEntries({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input: input as WorkflowTimeFindEntriesInput & { group_by?: WorkflowTimeSummaryGroupBy[] },
});
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.find_billing_blockers',
version: 1,
inputSchema: z.object({
entry_ids: z.array(uuidSchema).max(500).optional(),
require_timesheet: z.boolean().default(false),
user_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Optional user filter', 'user'),
work_item_id: uuidSchema.optional(),
work_item_type: z.enum(['ticket', 'project', 'project_task', 'interaction', 'ad_hoc', 'non_billable_category']).optional(),
client_id: uuidSchema.optional(),
ticket_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Optional ticket filter', 'ticket'),
project_task_id: uuidSchema.optional(),
time_sheet_id: uuidSchema.optional(),
service_id: uuidSchema.optional(),
contract_line_id: uuidSchema.optional(),
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']).optional(),
billable: z.boolean().optional(),
work_date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
work_date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
start_from: isoDateTimeSchema.optional(),
start_to: isoDateTimeSchema.optional(),
invoiced: z.boolean().optional(),
limit: z.number().int().min(1).max(500).default(200),
}),
outputSchema: z.object({
blockers: z.array(z.object({
category: z.string(),
count: z.number().int(),
entry_ids: z.array(uuidSchema),
explanation: z.string(),
})),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Find Time Billing Blockers',
category: 'Business Operations',
description: 'Find billing-readiness blockers across a bounded time-entry scope',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'read' });
try {
return await findWorkflowTimeBillingBlockers({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input,
});
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.validate_entries',
version: 1,
inputSchema: z.object({
entry_ids: z.array(uuidSchema).max(500).optional(),
require_timesheet: z.boolean().default(false),
user_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Optional user filter', 'user'),
work_item_id: uuidSchema.optional(),
work_item_type: z.enum(['ticket', 'project', 'project_task', 'interaction', 'ad_hoc', 'non_billable_category']).optional(),
client_id: uuidSchema.optional(),
ticket_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Optional ticket filter', 'ticket'),
project_task_id: uuidSchema.optional(),
time_sheet_id: uuidSchema.optional(),
service_id: uuidSchema.optional(),
contract_line_id: uuidSchema.optional(),
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']).optional(),
billable: z.boolean().optional(),
work_date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
work_date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
start_from: isoDateTimeSchema.optional(),
start_to: isoDateTimeSchema.optional(),
invoiced: z.boolean().optional(),
limit: z.number().int().min(1).max(500).default(200),
}),
outputSchema: z.object({
valid: z.boolean(),
blocker_count: z.number().int(),
blockers: z.array(z.object({
category: z.string(),
count: z.number().int(),
entry_ids: z.array(uuidSchema),
explanation: z.string(),
})),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Validate Time Entries',
category: 'Business Operations',
description: 'Validate entry readiness and return pass/fail with blocker details',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'read' });
try {
return await validateWorkflowTimeEntries({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input,
});
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.submit_timesheet',
version: 1,
inputSchema: z.object({
time_sheet_id: uuidSchema.describe('Time sheet id to submit'),
}),
outputSchema: z.object({
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Submit Time Sheet',
category: 'Business Operations',
description: 'Submit a draft or changes-requested timesheet and submit associated entries',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'submit' });
try {
const result = await submitWorkflowTimeSheet({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
timeSheetId: input.time_sheet_id,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.submit_timesheet',
changedData: {
time_sheet_id: result.time_sheet.time_sheet_id,
approval_status: result.time_sheet.approval_status,
entry_count: result.time_sheet.entry_count,
},
details: {
action_id: 'time.submit_timesheet',
action_version: 1,
time_sheet_id: result.time_sheet.time_sheet_id,
}
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.approve_timesheet',
version: 1,
inputSchema: z.object({
time_sheet_id: uuidSchema.describe('Time sheet id to approve'),
comment: withTimeWorkflowTextarea(z.string().optional(), 'Optional approver comment'),
}),
outputSchema: z.object({
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Approve Time Sheet',
category: 'Business Operations',
description: 'Approve a submitted timesheet and approve associated entries',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'approve' });
try {
const result = await approveWorkflowTimeSheet({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
timeSheetId: input.time_sheet_id,
comment: input.comment,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.approve_timesheet',
changedData: {
time_sheet_id: result.time_sheet.time_sheet_id,
approval_status: result.time_sheet.approval_status,
approved_by: result.time_sheet.approved_by,
},
details: {
action_id: 'time.approve_timesheet',
action_version: 1,
time_sheet_id: result.time_sheet.time_sheet_id,
}
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.request_timesheet_changes',
version: 1,
inputSchema: z.object({
time_sheet_id: uuidSchema.describe('Time sheet id'),
comment: withTimeWorkflowTextarea(z.string().min(1), 'Approver change-request comment'),
}),
outputSchema: z.object({
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Request Time Sheet Changes',
category: 'Business Operations',
description: 'Mark a timesheet as CHANGES_REQUESTED with an approver comment',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'approve' });
try {
const result = await requestWorkflowTimeSheetChanges({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
timeSheetId: input.time_sheet_id,
comment: input.comment,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.request_timesheet_changes',
changedData: {
time_sheet_id: result.time_sheet.time_sheet_id,
approval_status: result.time_sheet.approval_status,
},
details: {
action_id: 'time.request_timesheet_changes',
action_version: 1,
time_sheet_id: result.time_sheet.time_sheet_id,
}
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.reverse_timesheet_approval',
version: 1,
inputSchema: z.object({
time_sheet_id: uuidSchema.describe('Approved time sheet id'),
reason: withTimeWorkflowTextarea(z.string().min(1), 'Reason for reversing approval'),
}),
outputSchema: z.object({
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Reverse Time Sheet Approval',
category: 'Business Operations',
description: 'Reopen an approved timesheet unless invoiced entries are present',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'reverse' });
try {
const result = await reverseWorkflowTimeSheetApproval({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
timeSheetId: input.time_sheet_id,
reason: input.reason,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.reverse_timesheet_approval',
changedData: {
time_sheet_id: result.time_sheet.time_sheet_id,
approval_status: result.time_sheet.approval_status,
},
details: {
action_id: 'time.reverse_timesheet_approval',
action_version: 1,
time_sheet_id: result.time_sheet.time_sheet_id,
}
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.add_timesheet_comment',
version: 1,
inputSchema: z.object({
time_sheet_id: uuidSchema.describe('Time sheet id'),
comment: withTimeWorkflowTextarea(z.string().min(1), 'Comment text'),
is_approver: z.boolean().default(false).describe('Mark comment as approver-authored'),
}),
outputSchema: z.object({
comment: z.object({
comment_id: uuidSchema,
user_id: uuidSchema,
comment: z.string(),
is_approver: z.boolean(),
created_at: isoDateTimeSchema,
}),
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Add Time Sheet Comment',
category: 'Business Operations',
description: 'Add an owner or approver comment to a timesheet',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'read' });
if (input.is_approver) {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'approve' });
}
try {
const result = await addWorkflowTimeSheetComment({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
timeSheetId: input.time_sheet_id,
comment: input.comment,
isApprover: input.is_approver ?? false,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.add_timesheet_comment',
changedData: {
time_sheet_id: result.time_sheet.time_sheet_id,
comment_id: result.comment.comment_id,
is_approver: result.comment.is_approver,
},
details: {
action_id: 'time.add_timesheet_comment',
action_version: 1,
time_sheet_id: result.time_sheet.time_sheet_id,
comment_id: result.comment.comment_id,
}
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.find_or_create_timesheet',
version: 1,
inputSchema: z.object({
user_id: withTimeWorkflowPicker(uuidSchema, 'Timesheet owner user id', 'user'),
period_id: uuidSchema.optional().describe('Optional explicit time period id'),
work_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Optional work date used to resolve an open period'),
}).superRefine((value, issueCtx) => {
if (!value.period_id && !value.work_date) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
path: ['period_id'],
message: 'Provide period_id or work_date',
});
}
}),
outputSchema: z.object({
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Find or Create Time Sheet',
category: 'Business Operations',
description: 'Find or create a timesheet by user and period/date',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'read' });
try {
const result = await findOrCreateWorkflowTimeSheet({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
userId: input.user_id,
periodId: input.period_id,
workDate: input.work_date,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.find_or_create_timesheet',
changedData: {
time_sheet_id: result.time_sheet_id,
user_id: result.user_id,
period_id: result.period_id,
approval_status: result.approval_status,
},
details: {
action_id: 'time.find_or_create_timesheet',
action_version: 1,
time_sheet_id: result.time_sheet_id,
}
});
return { time_sheet: result };
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.get_timesheet',
version: 1,
inputSchema: z.object({
time_sheet_id: uuidSchema.describe('Time sheet id'),
}),
outputSchema: z.object({
time_sheet: z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
}),
comments: z.array(z.object({
comment_id: uuidSchema,
user_id: uuidSchema,
comment: z.string(),
is_approver: z.boolean(),
created_at: isoDateTimeSchema,
})),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Get Time Sheet',
category: 'Business Operations',
description: 'Load a timesheet with period, comments, and summary fields',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'read' });
try {
return await getWorkflowTimeSheet({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
timeSheetId: input.time_sheet_id,
});
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.find_timesheets',
version: 1,
inputSchema: z.object({
user_ids: z.array(withTimeWorkflowPicker(uuidSchema, 'User filter item', 'user')).max(100).optional().describe('Optional user filter'),
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']).optional()
.describe('Optional status filter'),
period_start_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Lower period start bound (inclusive)'),
period_end_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Upper period end bound (inclusive)'),
work_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Find periods containing this work date'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum rows (1-200)'),
}),
outputSchema: z.object({
time_sheets: z.array(z.object({
time_sheet_id: uuidSchema,
user_id: uuidSchema,
period_id: uuidSchema,
period_start_date: z.string(),
period_end_date: z.string(),
approval_status: z.string(),
submitted_at: isoDateTimeSchema.nullable(),
approved_at: isoDateTimeSchema.nullable(),
approved_by: uuidSchema.nullable(),
entry_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
comment_count: z.number().int(),
})),
summary: z.object({
total_count: z.number().int(),
}),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Find Time Sheets',
category: 'Business Operations',
description: 'Find timesheets by user/date/status with bounded results',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'read' });
try {
return await findWorkflowTimeSheets({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input,
});
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.set_entry_approval_status',
version: 1,
inputSchema: z.object({
entry_id: uuidSchema.describe('Time entry id to update approval status for'),
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED'])
.describe('Target approval status'),
change_request_comment: withTimeWorkflowTextarea(z.string().optional(), 'Required when requesting changes'),
}).superRefine((value, issueCtx) => {
if (value.approval_status === 'CHANGES_REQUESTED' && !value.change_request_comment?.trim()) {
issueCtx.addIssue({
code: z.ZodIssueCode.custom,
path: ['change_request_comment'],
message: 'change_request_comment is required when approval_status is CHANGES_REQUESTED',
});
}
}),
outputSchema: z.object({
entry: z.object({
entry_id: uuidSchema,
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']),
time_sheet_id: uuidSchema.nullable(),
change_request_id: uuidSchema.nullable(),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Set Time Entry Approval Status',
category: 'Business Operations',
description: 'Set a time entry approval status with optional change-request comment',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'approve' });
try {
const result = await setWorkflowTimeEntryApprovalStatus({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
entryId: input.entry_id,
approvalStatus: input.approval_status as WorkflowTimeApprovalStatus,
changeRequestComment: input.change_request_comment,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.set_entry_approval_status',
changedData: result,
details: {
action_id: 'time.set_entry_approval_status',
action_version: 1,
entry_id: result.entry_id,
},
});
return { entry: result };
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.request_entry_changes',
version: 1,
inputSchema: z.object({
entry_ids: z.array(uuidSchema).min(1).max(200).describe('Entry ids to move to CHANGES_REQUESTED'),
comment: withTimeWorkflowTextarea(z.string().min(1), 'Change request comment'),
}),
outputSchema: z.object({
entries: z.array(z.object({
entry_id: uuidSchema,
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']),
time_sheet_id: uuidSchema.nullable(),
change_request_id: uuidSchema.nullable(),
})),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Request Time Entry Changes',
category: 'Business Operations',
description: 'Request changes for one or more time entries with a comment',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timesheet', action: 'approve' });
try {
const result = await requestWorkflowTimeEntryChanges({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
entryIds: input.entry_ids,
comment: input.comment,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.request_entry_changes',
changedData: {
entry_ids: result.entries.map((entry) => entry.entry_id),
count: result.entries.length,
},
details: {
action_id: 'time.request_entry_changes',
action_version: 1,
},
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.get_entry',
version: 1,
inputSchema: z.object({
entry_id: uuidSchema.describe('Time entry id to fetch'),
}),
outputSchema: z.object({
time_entry: z.object({
entry_id: uuidSchema,
user_id: uuidSchema,
work_item_id: uuidSchema.nullable(),
work_item_type: z.string().nullable(),
service_id: uuidSchema,
contract_line_id: uuidSchema.nullable(),
time_sheet_id: uuidSchema.nullable(),
start_time: isoDateTimeSchema,
end_time: isoDateTimeSchema,
total_minutes: z.number().int(),
billable_minutes: z.number().int(),
work_date: z.string(),
work_timezone: z.string(),
approval_status: z.string(),
invoiced: z.boolean(),
notes: z.string().nullable(),
})
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Get Time Entry',
category: 'Business Operations',
description: 'Load a single tenant-scoped time entry',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'read' });
try {
const entry = await getWorkflowTimeEntry({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
entryId: input.entry_id,
});
return { time_entry: entry };
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.find_entries',
version: 1,
inputSchema: z.object({
user_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Filter by entry owner user id', 'user'),
work_item_id: uuidSchema.optional().describe('Filter by work item id'),
work_item_type: z.enum(['ticket', 'project', 'project_task', 'interaction', 'ad_hoc', 'non_billable_category']).optional()
.describe('Filter by work item type'),
client_id: uuidSchema.optional().describe('Filter by client id inferred from linked work items'),
ticket_id: withTimeWorkflowPicker(uuidSchema.optional(), 'Filter by ticket id', 'ticket'),
project_task_id: uuidSchema.optional().describe('Filter by project task id'),
time_sheet_id: uuidSchema.optional().describe('Filter by time sheet id'),
service_id: uuidSchema.optional().describe('Filter by service id'),
contract_line_id: uuidSchema.optional().describe('Filter by contract line id'),
approval_status: z.enum(['DRAFT', 'SUBMITTED', 'APPROVED', 'CHANGES_REQUESTED']).optional()
.describe('Filter by approval status'),
billable: z.boolean().optional().describe('Filter billable vs non-billable entries'),
work_date_from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive lower work date bound (YYYY-MM-DD)'),
work_date_to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Inclusive upper work date bound (YYYY-MM-DD)'),
start_from: isoDateTimeSchema.optional().describe('Inclusive lower start timestamp bound'),
start_to: isoDateTimeSchema.optional().describe('Inclusive upper start timestamp bound'),
invoiced: z.boolean().optional().describe('Filter invoiced state'),
limit: z.number().int().min(1).max(200).default(50).describe('Maximum returned rows (1-200)'),
}),
outputSchema: z.object({
entries: z.array(z.object({
entry_id: uuidSchema,
user_id: uuidSchema,
work_item_id: uuidSchema.nullable(),
work_item_type: z.string().nullable(),
service_id: uuidSchema,
contract_line_id: uuidSchema.nullable(),
time_sheet_id: uuidSchema.nullable(),
start_time: isoDateTimeSchema,
end_time: isoDateTimeSchema,
total_minutes: z.number().int(),
billable_minutes: z.number().int(),
work_date: z.string(),
work_timezone: z.string(),
approval_status: z.string(),
invoiced: z.boolean(),
notes: z.string().nullable(),
})),
summary: z.object({
total_count: z.number().int(),
total_minutes: z.number(),
billable_minutes: z.number(),
}),
}),
sideEffectful: false,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Find Time Entries',
category: 'Business Operations',
description: 'Find tenant-scoped time entries with bounded filters and aggregate summary',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'read' });
try {
const result = await findWorkflowTimeEntries({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input: input as WorkflowTimeFindEntriesInput,
});
return result;
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
}),
});
registry.register({
id: 'time.update_entry',
version: 1,
inputSchema: z.object({
entry_id: uuidSchema.describe('Time entry id to update'),
start: isoDateTimeSchema.optional().describe('Updated start timestamp in ISO-8601 format'),
end: isoDateTimeSchema.optional().describe('Updated end timestamp in ISO-8601 format'),
duration_minutes: z.number().int().min(0).optional().describe('Duration in minutes to derive end timestamp when end is omitted'),
billable: z.boolean().optional().describe('Set billable mode; false forces billable duration to zero'),
billable_duration_minutes: z.number().int().min(0).optional().describe('Optional explicit billable duration override in minutes'),
link: timeEntryLinkSchema.optional().describe('Optional updated work item link'),
service_id: uuidSchema.optional().describe('Optional updated service id'),
contract_line_id: uuidSchema.nullable().optional().describe('Optional updated contract line id'),
tax_rate_id: uuidSchema.nullable().optional().describe('Optional updated tax rate id'),
notes: withTimeWorkflowTextarea(z.string().nullable().optional(), 'Optional updated notes'),
time_sheet_id: uuidSchema.nullable().optional().describe('Optional explicit time sheet id'),
attach_to_timesheet: z.boolean().optional().describe('When false, detaches from timesheet; when true, enforces association'),
}),
outputSchema: z.object({
time_entry: z.object({
entry_id: uuidSchema,
user_id: uuidSchema,
work_item_id: uuidSchema.nullable(),
work_item_type: z.string().nullable(),
service_id: uuidSchema,
contract_line_id: uuidSchema.nullable(),
time_sheet_id: uuidSchema.nullable(),
start_time: isoDateTimeSchema,
end_time: isoDateTimeSchema,
total_minutes: z.number().int(),
billable_minutes: z.number().int(),
work_date: z.string(),
work_timezone: z.string(),
approval_status: z.string(),
invoiced: z.boolean(),
notes: z.string().nullable(),
})
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Update Time Entry',
category: 'Business Operations',
description: 'Update a workflow-safe time entry using canonical time module behavior',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'update' });
try {
const updateInput: WorkflowTimeUpdateEntryInput = {
entry_id: input.entry_id,
start: input.start,
end: input.end,
duration_minutes: input.duration_minutes,
billable: input.billable,
billable_duration_minutes: input.billable_duration_minutes,
link: input.link ? { type: input.link.type, id: input.link.id } : undefined,
service_id: input.service_id,
contract_line_id: input.contract_line_id,
tax_rate_id: input.tax_rate_id,
notes: input.notes,
time_sheet_id: input.time_sheet_id,
attach_to_timesheet: input.attach_to_timesheet,
};
const updated = await updateWorkflowTimeEntry({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
input: updateInput,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.update_entry',
changedData: {
entry_id: updated.entry_id,
user_id: updated.user_id,
service_id: updated.service_id,
work_item_id: updated.work_item_id,
work_item_type: updated.work_item_type,
total_minutes: updated.total_minutes,
billable_minutes: updated.billable_minutes,
work_date: updated.work_date,
time_sheet_id: updated.time_sheet_id,
contract_line_id: updated.contract_line_id,
approval_status: updated.approval_status,
},
details: {
action_id: 'time.update_entry',
action_version: 1,
entry_id: updated.entry_id,
}
});
return { time_entry: updated };
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
})
});
registry.register({
id: 'time.delete_entry',
version: 1,
inputSchema: z.object({
entry_id: uuidSchema.describe('Time entry id to delete'),
}),
outputSchema: z.object({
time_entry: z.object({
entry_id: uuidSchema,
user_id: uuidSchema,
work_item_id: uuidSchema.nullable(),
work_item_type: z.string().nullable(),
service_id: uuidSchema,
contract_line_id: uuidSchema.nullable(),
billable_minutes: z.number().int(),
deleted: z.literal(true),
}),
}),
sideEffectful: true,
idempotency: { mode: 'engineProvided' },
ui: {
label: 'Delete Time Entry',
category: 'Business Operations',
description: 'Delete a workflow-safe time entry with canonical safeguards and side effects',
},
handler: async (input, ctx) => withTenantTransaction(ctx, async (tx) => {
await requirePermission(ctx, tx, { resource: 'timeentry', action: 'delete' });
try {
const deleted = await deleteWorkflowTimeEntry({
trx: tx.trx,
tenantId: tx.tenantId,
actorUserId: tx.actorUserId,
entryId: input.entry_id,
});
await writeRunAudit(ctx, tx, {
operation: 'workflow_action:time.delete_entry',
changedData: {
entry_id: deleted.entry_id,
user_id: deleted.user_id,
work_item_id: deleted.work_item_id,
work_item_type: deleted.work_item_type,
service_id: deleted.service_id,
contract_line_id: deleted.contract_line_id,
billable_minutes: deleted.billable_minutes,
deleted: true,
},
details: {
action_id: 'time.delete_entry',
action_version: 1,
entry_id: deleted.entry_id,
}
});
return { time_entry: deleted };
} catch (error) {
if (error instanceof WorkflowTimeDomainError) {
throwActionError(ctx, {
category: error.category,
code: error.code,
message: error.message,
details: error.details ?? undefined,
});
}
rethrowAsStandardError(ctx, error);
}
})
});
}