diff --git a/packages/common/src/services/decision/createInstance.ts b/packages/common/src/services/decision/createInstance.ts index 5bb9fe71a..abe62b20a 100644 --- a/packages/common/src/services/decision/createInstance.ts +++ b/packages/common/src/services/decision/createInstance.ts @@ -11,7 +11,6 @@ import { User } from '@op/supabase/lib'; import { CommonError, NotFoundError, UnauthorizedError } from '../../utils'; import { assertUserByAuthId } from '../assert'; import { generateUniqueProfileSlug } from '../profile/utils'; -import { createTransitionsForProcess } from './createTransitionsForProcess'; import type { InstanceData, ProcessSchema } from './types'; export interface CreateInstanceInput { @@ -93,9 +92,8 @@ export const createInstance = async ({ return newInstance; }); - // Create transitions for the process phases - // This is critical - if transitions can't be created, the process won't auto-advance - await createTransitionsForProcess({ processInstance: instance }); + // Note: Transitions are created when the instance is published (status changes from DRAFT to PUBLISHED) + // Draft instances don't need transitions since they won't be processed return instance; } catch (error) { diff --git a/packages/common/src/services/decision/createTransitionsForProcess.ts b/packages/common/src/services/decision/createTransitionsForProcess.ts index 1ccbf01a2..d2de3d031 100644 --- a/packages/common/src/services/decision/createTransitionsForProcess.ts +++ b/packages/common/src/services/decision/createTransitionsForProcess.ts @@ -3,30 +3,28 @@ import { decisionProcessTransitions } from '@op/db/schema'; import type { ProcessInstance } from '@op/db/schema'; import { CommonError } from '../../utils'; -import type { InstanceData, PhaseConfiguration } from './types'; +import type { DecisionInstanceData } from './schemas/instanceData'; -export interface CreateTransitionsInput { +/** + * Creates scheduled transition records for phases with date-based advancement. + * Each transition fires when the current phase's end date arrives. + */ +export async function createTransitionsForProcess({ + processInstance, +}: { processInstance: ProcessInstance; -} - -export interface CreateTransitionsResult { +}): Promise<{ transitions: Array<{ id: string; fromStateId: string | null; toStateId: string; scheduledDate: Date; }>; -} - -/** - * Creates transition records for all phases in a process instance. - * Each transition represents the end of one phase and the start of the next. - */ -export async function createTransitionsForProcess({ - processInstance, -}: CreateTransitionsInput): Promise { +}> { try { - const instanceData = processInstance.instanceData as InstanceData; + // Type assertion: instanceData is `unknown` in DB to support legacy formats for viewing, + // but this function is only called for new DecisionInstanceData processes + const instanceData = processInstance.instanceData as DecisionInstanceData; const phases = instanceData.phases; if (!phases || phases.length === 0) { @@ -35,27 +33,45 @@ export async function createTransitionsForProcess({ ); } - const transitionsToCreate = phases.map( - (phase: PhaseConfiguration, index: number) => { - const fromStateId = index > 0 ? phases[index - 1]?.phaseId : null; - const toStateId = phase.phaseId; - // For phases like 'results' that only have a start date (no end), use the start date - const scheduledDate = phase.startDate; + // Create transitions for phases that use date-based advancement + // A transition is created FROM a phase (when it ends) TO the next phase + const transitionsToCreate: Array<{ + processInstanceId: string; + fromStateId: string; + toStateId: string; + scheduledDate: string; + }> = []; - if (!scheduledDate) { - throw new CommonError( - `Phase ${index + 1} (${toStateId}) must have either a scheduled end date or start date`, - ); - } + for (let index = 0; index < phases.length - 1; index++) { + const currentPhase = phases[index]!; + const nextPhase = phases[index + 1]!; - return { - processInstanceId: processInstance.id, - fromStateId, - toStateId, - scheduledDate: new Date(scheduledDate).toISOString(), - }; - }, - ); + // Only create transition if current phase uses date-based advancement + if (currentPhase.rules?.advancement?.method !== 'date') { + continue; + } + + // Schedule transition when the current phase ends + const scheduledDate = currentPhase.endDate; + + if (!scheduledDate) { + throw new CommonError( + `Phase "${currentPhase.phaseId}" must have an end date for date-based advancement (instance: ${processInstance.id})`, + ); + } + + // DB columns are named fromStateId/toStateId but store phase IDs + transitionsToCreate.push({ + processInstanceId: processInstance.id, + fromStateId: currentPhase.phaseId, + toStateId: nextPhase.phaseId, + scheduledDate: new Date(scheduledDate).toISOString(), + }); + } + + if (transitionsToCreate.length === 0) { + return { transitions: [] }; + } const createdTransitions = await db .insert(decisionProcessTransitions) @@ -63,11 +79,11 @@ export async function createTransitionsForProcess({ .returning(); return { - transitions: createdTransitions.map((t) => ({ - id: t.id, - fromStateId: t.fromStateId, - toStateId: t.toStateId, - scheduledDate: new Date(t.scheduledDate), + transitions: createdTransitions.map((transition) => ({ + id: transition.id, + fromStateId: transition.fromStateId, + toStateId: transition.toStateId, + scheduledDate: new Date(transition.scheduledDate), })), }; } catch (error) { diff --git a/packages/common/src/services/decision/index.ts b/packages/common/src/services/decision/index.ts index 1100cade0..92aea1dbd 100644 --- a/packages/common/src/services/decision/index.ts +++ b/packages/common/src/services/decision/index.ts @@ -50,3 +50,9 @@ export type { VoteData } from '@op/db/schema'; // Types export * from './types'; +export type { + DecisionSchemaDefinition, + PhaseDefinition, + PhaseRules, + ProcessConfig, +} from './schemas/types'; diff --git a/packages/common/src/services/decision/transitionMonitor.ts b/packages/common/src/services/decision/transitionMonitor.ts index fc436f6af..3ea8b7122 100644 --- a/packages/common/src/services/decision/transitionMonitor.ts +++ b/packages/common/src/services/decision/transitionMonitor.ts @@ -1,14 +1,24 @@ -import { db, eq, lte, sql } from '@op/db/client'; +import { and, db, eq, isNull, lte, sql } from '@op/db/client'; import { + type DecisionProcess, + type DecisionProcessTransition, + type ProcessInstance, + ProcessStatus, decisionProcessTransitions, - decisionProcesses, processInstances, } from '@op/db/schema'; import pMap from 'p-map'; import { CommonError } from '../../utils'; // import { processResults } from './processResults'; -import type { ProcessSchema, StateDefinition } from './types'; +import type { DecisionInstanceData } from './schemas/instanceData'; + +/** Transition with nested process instance and process relations */ +type TransitionWithRelations = DecisionProcessTransition & { + processInstance: ProcessInstance & { + process: DecisionProcess; + }; +}; export interface ProcessDecisionsTransitionsResult { processed: number; @@ -33,14 +43,30 @@ export async function processDecisionsTransitions(): Promise + // Query due transitions, filtering to only include published instances + // Draft, completed, and cancelled instances should not have their transitions processed + const dueTransitions = await db + .select({ + id: decisionProcessTransitions.id, + processInstanceId: decisionProcessTransitions.processInstanceId, + fromStateId: decisionProcessTransitions.fromStateId, + toStateId: decisionProcessTransitions.toStateId, + scheduledDate: decisionProcessTransitions.scheduledDate, + completedAt: decisionProcessTransitions.completedAt, + }) + .from(decisionProcessTransitions) + .innerJoin( + processInstances, + eq(decisionProcessTransitions.processInstanceId, processInstances.id), + ) + .where( and( - isNull(transitions.completedAt), - lte(transitions.scheduledDate, now), + isNull(decisionProcessTransitions.completedAt), + lte(decisionProcessTransitions.scheduledDate, now), + eq(processInstances.status, ProcessStatus.PUBLISHED), ), - orderBy: (transitions, { asc }) => [asc(transitions.scheduledDate)], - }); + ) + .orderBy(decisionProcessTransitions.scheduledDate); // Group transitions by processInstanceId to avoid race conditions // within the same process instance @@ -57,7 +83,7 @@ export async function processDecisionsTransitions(): Promise { - const transition = await db._query.decisionProcessTransitions.findFirst({ - where: eq(decisionProcessTransitions.id, transitionId), - }); + // Fetch transition with related process instance and process in a single query + const transitionResult = await db._query.decisionProcessTransitions.findFirst( + { + where: eq(decisionProcessTransitions.id, transitionId), + with: { + processInstance: { + with: { + process: true, + }, + }, + }, + }, + ); - if (!transition) { + if (!transitionResult) { throw new CommonError( `Transition not found: ${transitionId}. It may have been deleted or the ID is invalid.`, ); } + // Type assertion for the nested relations (Drizzle's type inference doesn't handle nested `with` well) + const transition = transitionResult as TransitionWithRelations; + if (transition.completedAt) { return; } - // Get the process instance to check if we're transitioning to a final state - const processInstance = await db._query.processInstances.findFirst({ - where: eq(processInstances.id, transition.processInstanceId), - }); - + const processInstance = transition.processInstance; if (!processInstance) { throw new CommonError( `Process instance not found: ${transition.processInstanceId}`, ); } - // Get the process schema to check the state type - const process = await db._query.decisionProcesses.findFirst({ - where: eq(decisionProcesses.id, processInstance.processId), - }); - + const process = processInstance.process; if (!process) { throw new CommonError(`Process not found: ${processInstance.processId}`); } - const processSchema = process.processSchema as ProcessSchema; - const toState = processSchema.states.find( - (state: StateDefinition) => state.id === transition.toStateId, - ); - - const isTransitioningToFinalState = toState?.type === 'final'; + // Determine if transitioning to final state using instanceData.phases + // In the new schema format, the last phase is always the final state + const instanceData = processInstance.instanceData as DecisionInstanceData; + const phases = instanceData.phases; + const lastPhaseId = phases[phases.length - 1]?.phaseId; + const isTransitioningToFinalState = transition.toStateId === lastPhaseId; // Update both the process instance and transition in a single transaction // to ensure atomicity and prevent partial state updates diff --git a/packages/common/src/services/decision/updateDecisionInstance.ts b/packages/common/src/services/decision/updateDecisionInstance.ts index 4afa32b59..ff3fa8a57 100644 --- a/packages/common/src/services/decision/updateDecisionInstance.ts +++ b/packages/common/src/services/decision/updateDecisionInstance.ts @@ -10,6 +10,7 @@ import { assertAccess, permission } from 'access-zones'; import { CommonError, NotFoundError } from '../../utils'; import { getProfileAccessUser } from '../access'; +import { createTransitionsForProcess } from './createTransitionsForProcess'; import type { DecisionInstanceData, PhaseOverride, @@ -161,14 +162,20 @@ export const updateDecisionInstance = async ({ // Determine the final status (updated or existing) const finalStatus = status ?? existingInstance.status; + const wasPublished = + status === ProcessStatus.PUBLISHED && + existingInstance.status === ProcessStatus.DRAFT; // If status is DRAFT, remove all transitions if (finalStatus === ProcessStatus.DRAFT) { await tx .delete(decisionProcessTransitions) .where(eq(decisionProcessTransitions.processInstanceId, instanceId)); + } else if (wasPublished) { + // When publishing a draft, create transitions for all date-based phases + await createTransitionsForProcess({ processInstance: updatedInstance }); } else if (phases && phases.length > 0) { - // If phases were updated and not DRAFT, update the corresponding transitions + // If phases were updated and already published, update the corresponding transitions await updateTransitionsForProcess({ processInstance: updatedInstance, tx, diff --git a/packages/common/src/services/decision/updateTransitionsForProcess.ts b/packages/common/src/services/decision/updateTransitionsForProcess.ts index 91c7859bc..c3936b651 100644 --- a/packages/common/src/services/decision/updateTransitionsForProcess.ts +++ b/packages/common/src/services/decision/updateTransitionsForProcess.ts @@ -4,7 +4,7 @@ import type { ProcessInstance } from '@op/db/schema'; import pMap from 'p-map'; import { CommonError } from '../../utils'; -import type { InstanceData, PhaseConfiguration } from './types'; +import type { DecisionInstanceData } from './schemas/instanceData'; export interface UpdateTransitionsInput { processInstance: ProcessInstance; @@ -19,10 +19,11 @@ export interface UpdateTransitionsResult { /** * Updates transition records for a process instance when phase dates change. + * Only handles phases with date-based advancement (rules.advancement.method === 'date'). * This function: * - Updates existing transitions with new scheduled dates - * - Creates new transitions for newly added phases - * - Deletes transitions for removed phases + * - Creates new transitions for newly added date-based phases + * - Deletes transitions for phases that no longer use date-based advancement * - Prevents updates to completed transitions (results phase is locked) */ export async function updateTransitionsForProcess({ @@ -32,7 +33,9 @@ export async function updateTransitionsForProcess({ const dbClient = tx ?? db; try { - const instanceData = processInstance.instanceData as InstanceData; + // Type assertion: instanceData is `unknown` in DB to support legacy formats for viewing, + // but this function is only called for new DecisionInstanceData processes + const instanceData = processInstance.instanceData as DecisionInstanceData; const phases = instanceData.phases; if (!phases || phases.length === 0) { @@ -57,94 +60,122 @@ export async function updateTransitionsForProcess({ deleted: 0, }; - // Build a map of expected transitions from the current phases - const expectedTransitions = phases.map( - (phase: PhaseConfiguration, index: number) => { - const fromStateId = index > 0 ? phases[index - 1]?.phaseId : null; - const toStateId = phase.phaseId; - // For phases like 'results' that only have a start date (no end), use the start date - const scheduledDate = phase.startDate; - - if (!scheduledDate) { - throw new CommonError( - `Phase ${index + 1} (${toStateId}) must have either a scheduled end date or start date`, - ); - } - - return { - fromStateId, - toStateId, - scheduledDate: new Date(scheduledDate).toISOString(), - }; - }, - ); - - // Process each expected transition in parallel - const updateResults = await pMap( - expectedTransitions, - async (expected) => { - // Find matching existing transition by toStateId - const existing = existingTransitions.find( - (t) => t.toStateId === expected.toStateId, + // Build expected transitions for phases with date-based advancement + // A transition is created FROM a phase (when it ends) TO the next phase + const expectedTransitions: Array<{ + fromStateId: string; + toStateId: string; + scheduledDate: string; + }> = []; + + for (let index = 0; index < phases.length - 1; index++) { + const currentPhase = phases[index]!; + const nextPhase = phases[index + 1]!; + + // Only create transition if current phase uses date-based advancement + if (currentPhase.rules?.advancement?.method !== 'date') { + continue; + } + + // Schedule transition when the current phase ends + const scheduledDate = currentPhase.endDate; + + if (!scheduledDate) { + throw new CommonError( + `Phase "${currentPhase.phaseId}" must have an end date for date-based advancement (instance: ${processInstance.id})`, ); + } - if (existing) { - // If transition is already completed, don't update it (results phase is locked) - if (existing.completedAt) { - return { action: 'skipped' as const }; - } - - // Update the scheduled date if it changed - if (existing.scheduledDate !== expected.scheduledDate) { - await dbClient - .update(decisionProcessTransitions) - .set({ - scheduledDate: expected.scheduledDate, - }) - .where(eq(decisionProcessTransitions.id, existing.id)); - - return { action: 'updated' as const }; - } - - return { action: 'unchanged' as const }; - } else { - // Create new transition for this phase - await dbClient.insert(decisionProcessTransitions).values({ - processInstanceId: processInstance.id, - fromStateId: expected.fromStateId, - toStateId: expected.toStateId, - scheduledDate: expected.scheduledDate, - }); - - return { action: 'created' as const }; - } - }, - { concurrency: 5 }, - ); - - // Aggregate results - result.updated = updateResults.filter((r) => r.action === 'updated').length; - result.created = updateResults.filter((r) => r.action === 'created').length; + // DB columns are named fromStateId/toStateId but store phase IDs + expectedTransitions.push({ + fromStateId: currentPhase.phaseId, + toStateId: nextPhase.phaseId, + scheduledDate: new Date(scheduledDate).toISOString(), + }); + } - // Delete transitions that are no longer in the phases list - // But only delete uncompleted transitions - const expectedStateIds = new Set( - expectedTransitions.map((t) => t.toStateId), + // Calculate transitions to delete upfront (those not in expected set and not completed) + // Use composite key (fromStateId:toStateId) to match both fields + const expectedTransitionKeys = new Set( + expectedTransitions.map( + (transition) => `${transition.fromStateId}:${transition.toStateId}`, + ), ); const transitionsToDelete = existingTransitions.filter( - (t) => !expectedStateIds.has(t.toStateId) && !t.completedAt, + (transition) => + !expectedTransitionKeys.has( + `${transition.fromStateId}:${transition.toStateId}`, + ) && !transition.completedAt, ); - await pMap( - transitionsToDelete, - async (transition) => { - await dbClient - .delete(decisionProcessTransitions) - .where(eq(decisionProcessTransitions.id, transition.id)); - }, - { concurrency: 5 }, - ); + // Run update/create and delete operations in parallel since they operate on mutually exclusive sets + const [updateResults] = await Promise.all([ + // Update existing transitions or create new ones + pMap( + expectedTransitions, + async (expected) => { + // Find matching existing transition by both fromStateId and toStateId + const existing = existingTransitions.find( + (transition) => + transition.fromStateId === expected.fromStateId && + transition.toStateId === expected.toStateId, + ); + + if (existing) { + // If transition is already completed, don't update it (results phase is locked) + if (existing.completedAt) { + return { action: 'skipped' as const }; + } + + // Update the scheduled date if it changed (compare as timestamps to handle format differences) + if ( + new Date(existing.scheduledDate).getTime() !== + new Date(expected.scheduledDate).getTime() + ) { + await dbClient + .update(decisionProcessTransitions) + .set({ + scheduledDate: expected.scheduledDate, + }) + .where(eq(decisionProcessTransitions.id, existing.id)); + + return { action: 'updated' as const }; + } + + return { action: 'unchanged' as const }; + } else { + // Create new transition for this phase + await dbClient.insert(decisionProcessTransitions).values({ + processInstanceId: processInstance.id, + fromStateId: expected.fromStateId, + toStateId: expected.toStateId, + scheduledDate: expected.scheduledDate, + }); + + return { action: 'created' as const }; + } + }, + { concurrency: 5 }, + ), + // Delete transitions that are no longer in the expected set + pMap( + transitionsToDelete, + async (transition) => { + await dbClient + .delete(decisionProcessTransitions) + .where(eq(decisionProcessTransitions.id, transition.id)); + }, + { concurrency: 5 }, + ), + ]); + // Aggregate results + result.updated = updateResults.filter( + (update) => update.action === 'updated', + ).length; + result.created = updateResults.filter( + (update) => update.action === 'created', + ).length; result.deleted = transitionsToDelete.length; return result; diff --git a/packages/common/src/test/setup.ts b/packages/common/src/test/setup.ts index 28be37978..72f412e8e 100644 --- a/packages/common/src/test/setup.ts +++ b/packages/common/src/test/setup.ts @@ -20,6 +20,10 @@ export const mockDb = { decisions: { findFirst: vi.fn(), }, + decisionProcessTransitions: { + findFirst: vi.fn(), + findMany: vi.fn(), + }, }, insert: vi.fn().mockReturnValue({ values: vi.fn().mockReturnValue({ diff --git a/services/api/src/encoders/decision.ts b/services/api/src/encoders/decision.ts index 8ae5b7ed5..9bd076daa 100644 --- a/services/api/src/encoders/decision.ts +++ b/services/api/src/encoders/decision.ts @@ -22,7 +22,7 @@ const jsonSchemaEncoder = z.record(z.string(), z.unknown()); // DecisionSchemaDefinition format encoders // ============================================================================ -/** Phase behavior rules */ +/** Phase behavior rules */ const phaseRulesEncoder = z.object({ proposals: z .object({ @@ -108,14 +108,14 @@ export const decisionProcessWithSchemaEncoder = createSelectSchema( createdBy: baseProfileEncoder.optional(), }); -/** List encoder for decision processes with new schema format */ +/** List encoder for decision processes */ export const decisionProcessWithSchemaListEncoder = z.object({ processes: z.array(decisionProcessWithSchemaEncoder), total: z.number(), hasMore: z.boolean(), }); -/** Instance data encoder for new schema format */ +/** Instance data encoder */ const instanceDataWithSchemaEncoder = z.object({ budget: z.number().optional(), hideBudget: z.boolean().optional(), @@ -192,7 +192,7 @@ export const decisionProfileWithSchemaFilterSchema = z.object({ // Legacy format encoders (for backwards compatibility) // ============================================================================ -// Shared process phase schema +// Shared process phase schema (legacy format) export const processPhaseSchema = z.object({ id: z.string(), name: z.string(), @@ -207,71 +207,145 @@ export const processPhaseSchema = z.object({ type: z.enum(['initial', 'intermediate', 'final']).optional(), }); -// Process Schema Encoder -const processSchemaEncoder = z.object({ - name: z.string(), - description: z.string().optional(), - budget: z.number().optional(), - fields: jsonSchemaEncoder.optional(), - states: z.array( - processPhaseSchema.extend({ - fields: jsonSchemaEncoder.optional(), - config: z +// ============================================================================= +// Legacy Phase Rules Encoder (for backwards compatibility) +// ============================================================================= +const legacyPhaseRulesEncoder = z + .object({ + proposals: z + .object({ + submit: z.boolean().optional(), + edit: z.boolean().optional(), + }) + .passthrough() + .optional(), + voting: z + .object({ + submit: z.boolean().optional(), + edit: z.boolean().optional(), + }) + .passthrough() + .optional(), + advancement: z + .object({ + method: z.enum(['date', 'manual']), + endDate: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const legacySelectionPipelineEncoder = z + .object({ + steps: z.array( + z .object({ - allowProposals: z.boolean().optional(), - allowDecisions: z.boolean().optional(), - visibleComponents: z.array(z.string()).optional(), + type: z.string(), + config: z.record(z.string(), z.unknown()).optional(), }) - .optional(), - }), - ), - transitions: z.array( - z.object({ - id: z.string(), - name: z.string(), - from: z.union([z.string(), z.array(z.string())]), - to: z.string(), - rules: z - .object({ - type: z.enum(['manual', 'automatic']), - conditions: z + .passthrough(), + ), + }) + .passthrough(); + +const legacyPhaseDefinitionEncoder = z + .object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + rules: legacyPhaseRulesEncoder, + selectionPipeline: legacySelectionPipelineEncoder.optional(), + settings: jsonSchemaEncoder.optional(), + }) + .passthrough(); + +// ============================================================================= +// Process Schema Encoder (supports both legacy and new formats) +// ============================================================================= +const processSchemaEncoder = z + .object({ + name: z.string(), + description: z.string().optional(), + + // --- New format fields (DecisionSchemaDefinition) --- + id: z.string().optional(), + version: z.string().optional(), + config: z + .object({ + hideBudget: z.boolean().optional(), + }) + .passthrough() + .optional(), + phases: z.array(legacyPhaseDefinitionEncoder).optional(), + + // --- DEPRECATED: Legacy format fields (to be removed after migration) --- + budget: z.number().optional(), + fields: jsonSchemaEncoder.optional(), + states: z + .array( + processPhaseSchema.extend({ + fields: jsonSchemaEncoder.optional(), + config: z + .object({ + allowProposals: z.boolean().optional(), + allowDecisions: z.boolean().optional(), + visibleComponents: z.array(z.string()).optional(), + }) + .optional(), + }), + ) + .optional(), + transitions: z + .array( + z.object({ + id: z.string(), + name: z.string(), + from: z.union([z.string(), z.array(z.string())]), + to: z.string(), + rules: z + .object({ + type: z.enum(['manual', 'automatic']), + conditions: z + .array( + z.object({ + type: z.enum([ + 'time', + 'proposalCount', + 'participationCount', + 'approvalRate', + 'customField', + ]), + operator: z.enum([ + 'equals', + 'greaterThan', + 'lessThan', + 'between', + ]), + value: z.unknown().optional(), + field: z.string().optional(), + }), + ) + .optional(), + requireAll: z.boolean().optional(), + }) + .optional(), + actions: z .array( z.object({ - type: z.enum([ - 'time', - 'proposalCount', - 'participationCount', - 'approvalRate', - 'customField', - ]), - operator: z.enum([ - 'equals', - 'greaterThan', - 'lessThan', - 'between', - ]), - value: z.unknown().optional(), - field: z.string().optional(), + type: z.enum(['notify', 'updateField', 'createRecord']), + config: z.record(z.string(), z.unknown()), }), ) .optional(), - requireAll: z.boolean().optional(), - }) - .optional(), - actions: z - .array( - z.object({ - type: z.enum(['notify', 'updateField', 'createRecord']), - config: z.record(z.string(), z.unknown()), - }), - ) - .optional(), - }), - ), - initialState: z.string(), - decisionDefinition: jsonSchemaEncoder, - proposalTemplate: jsonSchemaEncoder, -}); + }), + ) + .optional(), + initialState: z.string().optional(), + decisionDefinition: jsonSchemaEncoder.optional(), + proposalTemplate: jsonSchemaEncoder.optional(), + }) + .passthrough(); // Instance Data Encoder that supports both new and legacy field names const instanceDataEncoder = z.preprocess( diff --git a/services/api/src/routers/decision/transitions/processTransitions.test.ts b/services/api/src/routers/decision/transitions/processTransitions.test.ts new file mode 100644 index 000000000..f9b5c6ca3 --- /dev/null +++ b/services/api/src/routers/decision/transitions/processTransitions.test.ts @@ -0,0 +1,866 @@ +import { processDecisionsTransitions } from '@op/common'; +import type { DecisionSchemaDefinition } from '@op/common'; +import { db, eq } from '@op/db/client'; +import { + ProcessStatus, + decisionProcessTransitions, + decisionProcesses, + processInstances, +} from '@op/db/schema'; +import { describe, expect, it } from 'vitest'; + +import { TestDecisionsDataManager } from '../../../test/helpers/TestDecisionsDataManager'; +import { + createIsolatedSession, + createTestContextWithSession, +} from '../../../test/supabase-utils'; +import { createCallerFactory } from '../../../trpcFactory'; +import { appRouter } from '../../index'; + +const createCaller = createCallerFactory(appRouter); + +async function createAuthenticatedCaller(email: string) { + const { session } = await createIsolatedSession(email); + return createCaller(await createTestContextWithSession(session)); +} + +// Helper to create dates relative to now +const createPastDate = (daysAgo: number) => { + const date = new Date(); + date.setDate(date.getDate() - daysAgo); + return date.toISOString(); +}; + +const createFutureDate = (daysFromNow: number) => { + const date = new Date(); + date.setDate(date.getDate() + daysFromNow); + return date.toISOString(); +}; + +// Standard 4-phase schema for transition tests using DecisionSchemaDefinition +const createDecisionSchema = (): DecisionSchemaDefinition => ({ + id: 'test-transition-schema', + version: '1.0.0', + name: 'Transition Test Process', + phases: [ + { + id: 'submission', + name: 'Submission', + rules: { advancement: { method: 'date' } }, + }, + { + id: 'review', + name: 'Review', + rules: { advancement: { method: 'date' } }, + }, + { + id: 'voting', + name: 'Voting', + rules: { advancement: { method: 'date' } }, + }, + { id: 'results', name: 'Results', rules: {} }, + ], +}); + +// Simple instance data (rules get stripped by API validation anyway) +const createSimpleInstanceData = (currentPhaseId: string) => ({ + currentPhaseId, + phases: [ + { + phaseId: 'submission', + startDate: createPastDate(14), + endDate: createPastDate(7), + }, + { + phaseId: 'review', + startDate: createPastDate(7), + endDate: createPastDate(1), + }, + { + phaseId: 'voting', + startDate: createPastDate(1), + endDate: createFutureDate(7), + }, + { phaseId: 'results', startDate: createFutureDate(7) }, + ], +}); + +/** + * Helper to create a test instance with a manually inserted due transition. + * This bypasses the API's Zod validation which strips `rules` from instance data. + * By default, creates instances with 'published' status so transitions are processed. + */ +/** + * Helper to get the current phase ID for an instance via direct DB query. + * Avoids API encoder validation issues with new schema format. + */ +async function getInstanceCurrentPhaseId(instanceId: string): Promise { + const [instance] = await db + .select() + .from(processInstances) + .where(eq(processInstances.id, instanceId)); + + if (!instance) { + throw new Error(`Instance not found: ${instanceId}`); + } + + const instanceData = instance.instanceData as { currentPhaseId: string }; + return instanceData.currentPhaseId; +} + +async function createInstanceWithDueTransition( + testData: TestDecisionsDataManager, + setup: Awaited>, + caller: Awaited>, + options: { + name: string; + currentPhaseId: string; + fromStateId: string; + toStateId: string; + scheduledDate: string; + status?: ProcessStatus; + }, +) { + const instance = await testData.createInstanceWithCustomData({ + caller, + processId: setup.process.id, + name: options.name, + instanceData: createSimpleInstanceData(options.currentPhaseId), + status: options.status ?? ProcessStatus.PUBLISHED, + }); + + // Update currentStateId to match currentPhaseId + await db + .update(processInstances) + .set({ currentStateId: options.currentPhaseId }) + .where(eq(processInstances.id, instance.instance.id)); + + // Manually insert the transition record + await db.insert(decisionProcessTransitions).values({ + processInstanceId: instance.instance.id, + fromStateId: options.fromStateId, + toStateId: options.toStateId, + scheduledDate: options.scheduledDate, + }); + + await testData.grantProfileAccess( + instance.profileId, + setup.user.id, + setup.userEmail, + ); + + return instance; +} + +describe.concurrent('processDecisionsTransitions integration', () => { + describe('processing due transitions', () => { + it('should advance phase when transition is due', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Transition Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const instance = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Due Transition Test', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), // Due yesterday + }, + ); + + // Verify transition exists and is due + const [transition] = await db + .select() + .from(decisionProcessTransitions) + .where( + eq( + decisionProcessTransitions.processInstanceId, + instance.instance.id, + ), + ); + + expect(transition).toBeDefined(); + expect(transition!.completedAt).toBeNull(); + + // Process all due transitions + const result = await processDecisionsTransitions(); + + expect(result.processed).toBeGreaterThanOrEqual(1); + expect(result.failed).toBe(0); + + // Verify the instance state advanced + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('review'); + }); + + it('should update both currentStateId and currentPhaseId', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'State Update Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const instance = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'State Update Test', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + }, + ); + + await processDecisionsTransitions(); + + // Verify via direct DB query that both fields are updated + const [updatedInstance] = await db + .select() + .from(processInstances) + .where(eq(processInstances.id, instance.instance.id)); + + expect(updatedInstance).toBeDefined(); + expect(updatedInstance!.currentStateId).toBe('review'); + const instanceData = updatedInstance!.instanceData as { + currentPhaseId: string; + }; + expect(instanceData.currentPhaseId).toBe('review'); + }); + + it('should mark transition as completed', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Completion Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const instance = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Completion Test', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + }, + ); + + // Get the transition before processing + const [transitionBefore] = await db + .select() + .from(decisionProcessTransitions) + .where( + eq( + decisionProcessTransitions.processInstanceId, + instance.instance.id, + ), + ); + + expect(transitionBefore).toBeDefined(); + expect(transitionBefore!.completedAt).toBeNull(); + + await processDecisionsTransitions(); + + // Verify the transition is marked as completed + const [transitionAfter] = await db + .select() + .from(decisionProcessTransitions) + .where(eq(decisionProcessTransitions.id, transitionBefore!.id)); + + expect(transitionAfter).toBeDefined(); + expect(transitionAfter!.completedAt).not.toBeNull(); + }); + }); + + describe('final phase transitions', () => { + it('should correctly transition to final phase (results)', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Final Phase Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const instance = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Final Phase Test', + currentPhaseId: 'voting', + fromStateId: 'voting', + toStateId: 'results', + scheduledDate: createPastDate(1), + }, + ); + + const result = await processDecisionsTransitions(); + + expect(result.processed).toBeGreaterThanOrEqual(1); + expect(result.failed).toBe(0); + + // Verify instance advanced to results (final phase) + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('results'); + }); + + it('should not process transitions when instance is already in final phase', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'No Final Transitions', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Create instance already in results phase (no transition inserted) + const instance = await testData.createInstanceWithCustomData({ + caller, + processId: setup.process.id, + name: 'Already Final', + instanceData: { + currentPhaseId: 'results', + phases: [ + { + phaseId: 'submission', + startDate: createPastDate(21), + endDate: createPastDate(14), + }, + { + phaseId: 'review', + startDate: createPastDate(14), + endDate: createPastDate(7), + }, + { + phaseId: 'voting', + startDate: createPastDate(7), + endDate: createPastDate(1), + }, + { phaseId: 'results', startDate: createPastDate(1) }, + ], + }, + }); + + await db + .update(processInstances) + .set({ currentStateId: 'results' }) + .where(eq(processInstances.id, instance.instance.id)); + + await testData.grantProfileAccess( + instance.profileId, + setup.user.id, + setup.userEmail, + ); + + // Verify no transitions exist for this instance + const transitions = await db.query.decisionProcessTransitions.findMany({ + where: eq( + decisionProcessTransitions.processInstanceId, + instance.instance.id, + ), + }); + + expect(transitions).toHaveLength(0); + + // Verify instance remains in results phase + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('results'); + }); + }); + + describe('skipping non-due transitions', () => { + it('should not process future transitions', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Future Transitions', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Create instance with a future transition + const instance = await testData.createInstanceWithCustomData({ + caller, + processId: setup.process.id, + name: 'Future Instance', + instanceData: createSimpleInstanceData('submission'), + status: ProcessStatus.PUBLISHED, // Must be published for meaningful test + }); + + await db + .update(processInstances) + .set({ currentStateId: 'submission' }) + .where(eq(processInstances.id, instance.instance.id)); + + // Insert a future transition + await db.insert(decisionProcessTransitions).values({ + processInstanceId: instance.instance.id, + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createFutureDate(7), // Not due yet + }); + + await testData.grantProfileAccess( + instance.profileId, + setup.user.id, + setup.userEmail, + ); + + // Process transitions - future transition should not be processed + await processDecisionsTransitions(); + + // Verify the instance state has not changed + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('submission'); + + // Verify transition is still pending + const [transition] = await db + .select() + .from(decisionProcessTransitions) + .where( + eq( + decisionProcessTransitions.processInstanceId, + instance.instance.id, + ), + ); + + expect(transition).toBeDefined(); + expect(transition!.completedAt).toBeNull(); + }); + + it('should skip already completed transitions', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Completed Transitions', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + const instance = await testData.createInstanceWithCustomData({ + caller, + processId: setup.process.id, + name: 'Completed Test', + instanceData: createSimpleInstanceData('submission'), + status: ProcessStatus.PUBLISHED, // Must be published for meaningful test + }); + + await db + .update(processInstances) + .set({ currentStateId: 'submission' }) + .where(eq(processInstances.id, instance.instance.id)); + + // Insert an already completed transition + await db.insert(decisionProcessTransitions).values({ + processInstanceId: instance.instance.id, + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + completedAt: createPastDate(1), // Already completed + }); + + await testData.grantProfileAccess( + instance.profileId, + setup.user.id, + setup.userEmail, + ); + + // Process transitions - completed transition should be skipped + const result = await processDecisionsTransitions(); + + // This instance's transition should not contribute to the processed count + expect(result.failed).toBe(0); + }); + }); + + describe('multi-instance processing', () => { + it('should process transitions across multiple instances', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Multi-Instance Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Create two instances with due transitions + const instance1 = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Multi Instance 1', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + }, + ); + + const instance2 = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Multi Instance 2', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + }, + ); + + // Process all due transitions + const result = await processDecisionsTransitions(); + + // Result includes transitions from all concurrent tests, so just verify no failures + expect(result.failed).toBe(0); + + // Verify BOTH of our specific instances advanced (this is the important check) + const currentPhaseId1 = await getInstanceCurrentPhaseId( + instance1.instance.id, + ); + const currentPhaseId2 = await getInstanceCurrentPhaseId( + instance2.instance.id, + ); + + expect(currentPhaseId1).toBe('review'); + expect(currentPhaseId2).toBe('review'); + + // Verify both transitions were marked completed + const transition1 = await db.query.decisionProcessTransitions.findFirst({ + where: eq( + decisionProcessTransitions.processInstanceId, + instance1.instance.id, + ), + }); + const transition2 = await db.query.decisionProcessTransitions.findFirst({ + where: eq( + decisionProcessTransitions.processInstanceId, + instance2.instance.id, + ), + }); + + expect(transition1?.completedAt).toBeTruthy(); + expect(transition2?.completedAt).toBeTruthy(); + }); + + it('should process multiple sequential transitions within same instance', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Sequential Transitions', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Create instance with TWO due transitions + const instance = await testData.createInstanceWithCustomData({ + caller, + processId: setup.process.id, + name: 'Sequential Test', + instanceData: createSimpleInstanceData('submission'), + status: ProcessStatus.PUBLISHED, + }); + + await db + .update(processInstances) + .set({ currentStateId: 'submission' }) + .where(eq(processInstances.id, instance.instance.id)); + + // Insert two transitions - both past due + await db.insert(decisionProcessTransitions).values([ + { + processInstanceId: instance.instance.id, + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(14), // Due 2 weeks ago + }, + { + processInstanceId: instance.instance.id, + fromStateId: 'review', + toStateId: 'voting', + scheduledDate: createPastDate(7), // Due 1 week ago + }, + ]); + + await testData.grantProfileAccess( + instance.profileId, + setup.user.id, + setup.userEmail, + ); + + // Process transitions - should process both sequentially + const result = await processDecisionsTransitions(); + + expect(result.processed).toBeGreaterThanOrEqual(2); + expect(result.failed).toBe(0); + + // Verify the instance advanced through both transitions to voting + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('voting'); + }); + }); + + describe('error handling', () => { + it('should return correct processed/failed counts', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Count Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Instance is needed to create a due transition, but we only care about the result counts + await createInstanceWithDueTransition(testData, setup, caller, { + name: 'Count Test', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + }); + + const result = await processDecisionsTransitions(); + + // Verify result object structure + expect(typeof result.processed).toBe('number'); + expect(typeof result.failed).toBe('number'); + expect(Array.isArray(result.errors)).toBe(true); + expect(result.processed).toBeGreaterThanOrEqual(1); + }); + }); + + describe('instance status filtering', () => { + it('should not process transitions for draft instances', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Draft Status Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Create a DRAFT instance with a due transition + const instance = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Draft Instance Test', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + status: ProcessStatus.DRAFT, // Explicitly set to draft + }, + ); + + // Process transitions - draft instance should be skipped + await processDecisionsTransitions(); + + // Verify the instance state has NOT changed + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('submission'); + + // Verify the transition is still pending (not completed) + const [transition] = await db + .select() + .from(decisionProcessTransitions) + .where( + eq( + decisionProcessTransitions.processInstanceId, + instance.instance.id, + ), + ); + + expect(transition).toBeDefined(); + expect(transition!.completedAt).toBeNull(); + }); + + it('should process transitions when instance is published', async ({ + task, + onTestFinished, + }) => { + const testData = new TestDecisionsDataManager(task.id, onTestFinished); + + const setup = await testData.createDecisionSetup({ + processName: 'Published Status Test', + instanceCount: 0, + }); + + await db + .update(decisionProcesses) + .set({ processSchema: createDecisionSchema() }) + .where(eq(decisionProcesses.id, setup.process.id)); + + const caller = await createAuthenticatedCaller(setup.userEmail); + + // Create a PUBLISHED instance with a due transition + const instance = await createInstanceWithDueTransition( + testData, + setup, + caller, + { + name: 'Published Instance Test', + currentPhaseId: 'submission', + fromStateId: 'submission', + toStateId: 'review', + scheduledDate: createPastDate(1), + status: ProcessStatus.PUBLISHED, // Explicitly set to published + }, + ); + + // Process transitions - published instance should be processed + await processDecisionsTransitions(); + + // Verify the instance state HAS changed + const currentPhaseId = await getInstanceCurrentPhaseId( + instance.instance.id, + ); + expect(currentPhaseId).toBe('review'); + + // Verify the transition is completed + const [transition] = await db + .select() + .from(decisionProcessTransitions) + .where( + eq( + decisionProcessTransitions.processInstanceId, + instance.instance.id, + ), + ); + + expect(transition).toBeDefined(); + expect(transition!.completedAt).not.toBeNull(); + }); + }); +}); diff --git a/services/api/src/test/helpers/TestDecisionsDataManager.ts b/services/api/src/test/helpers/TestDecisionsDataManager.ts index ca10448b1..907bba637 100644 --- a/services/api/src/test/helpers/TestDecisionsDataManager.ts +++ b/services/api/src/test/helpers/TestDecisionsDataManager.ts @@ -1,3 +1,4 @@ +import { createTransitionsForProcess } from '@op/common'; import { db } from '@op/db/client'; import { ProcessStatus, @@ -21,6 +22,7 @@ import type { decisionSchemaDefinitionEncoder, processInstanceWithSchemaEncoder, } from '../../encoders/decision'; +import type { legacyProcessInstanceEncoder } from '../../encoders/legacyDecision'; import { appRouter } from '../../routers'; import { createCallerFactory } from '../../trpcFactory'; import { @@ -41,6 +43,9 @@ interface CreateDecisionSetupOptions { } type EncodedProcessInstance = z.infer; +type LegacyEncodedProcessInstance = z.infer< + typeof legacyProcessInstanceEncoder +>; interface CreatedInstance { instance: EncodedProcessInstance; @@ -48,6 +53,12 @@ interface CreatedInstance { slug: string; } +interface LegacyCreatedInstance { + instance: LegacyEncodedProcessInstance; + profileId: string; + slug: string; +} + type EncodedDecisionProcess = z.infer; type DecisionSchemaDefinition = z.infer; @@ -372,6 +383,16 @@ export class TestDecisionsDataManager { .update(processInstances) .set({ status }) .where(eq(processInstances.id, profile.processInstance.id)); + + // If publishing, create transitions for the instance + if (status === ProcessStatus.PUBLISHED) { + const fullInstance = await db.query.processInstances.findFirst({ + where: eq(processInstances.id, profile.processInstance.id), + }); + if (fullInstance) { + await createTransitionsForProcess({ processInstance: fullInstance }); + } + } } // Update budget if needed (instanceData may not include budget from template) @@ -404,6 +425,122 @@ export class TestDecisionsDataManager { }; } + /** + * Creates a process instance with custom instanceData. + * Use this when you need to control phase dates, advancement methods, or other + * instance configuration for testing specific scenarios like transition processing. + * + * @example + * ```ts + * const instance = await testData.createInstanceWithCustomData({ + * caller, + * processId: process.id, + * name: 'Test Instance', + * instanceData: { + * currentPhaseId: 'submission', + * phases: [ + * { + * phaseId: 'submission', + * startDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), + * endDate: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), // Past date + * rules: { advancement: { method: 'date' } }, + * }, + * // ... more phases + * ], + * }, + * }); + * ``` + */ + async createInstanceWithCustomData({ + caller, + processId, + process, + user, + name, + instanceData, + status, + }: { + caller?: Awaited< + ReturnType + >; + processId?: string; + process?: EncodedDecisionProcess; + user?: User; + name: string; + instanceData: Record; + status?: ProcessStatus; + }): Promise { + this.ensureCleanupRegistered(); + + // Resolve caller - either use provided caller or create one from user + let resolvedCaller = caller; + if (!resolvedCaller && user?.email) { + resolvedCaller = await this.createAuthenticatedCaller(user.email); + } + if (!resolvedCaller) { + throw new Error('Either caller or user with email must be provided'); + } + + // Resolve processId - either use provided processId or get from process object + const resolvedProcessId = processId ?? process?.id; + if (!resolvedProcessId) { + throw new Error('Either processId or process must be provided'); + } + + const instance = await resolvedCaller.decision.createInstance({ + processId: resolvedProcessId, + name: this.generateUniqueName(name), + description: `Test instance ${name}`, + instanceData, + }); + + // Query the database for the profileId (not exposed in the API response) + const [instanceRecord] = await db + .select({ profileId: processInstances.profileId }) + .from(processInstances) + .where(eq(processInstances.id, instance.id)); + + const profileId = instanceRecord?.profileId; + if (!profileId) { + throw new Error(`Could not find profileId for instance ${instance.id}`); + } + + this.createdProfileIds.push(profileId); + + // Update status if provided (direct DB update since there's no router for this) + if (status) { + await db + .update(processInstances) + .set({ status }) + .where(eq(processInstances.id, instance.id)); + + // If publishing, create transitions for the instance + if (status === ProcessStatus.PUBLISHED) { + const fullInstance = await db.query.processInstances.findFirst({ + where: eq(processInstances.id, instance.id), + }); + if (fullInstance) { + await createTransitionsForProcess({ processInstance: fullInstance }); + } + } + } + + // Fetch the profile to get the slug + const profile = await db.query.profiles.findFirst({ + where: eq(profiles.id, profileId), + }); + + if (!profile) { + throw new Error(`Profile not found for instance: ${instance.id}`); + } + + return { + instance, + profileId, + slug: profile.slug, + }; + } + /** * Grants profile access to a user * @param profileId - The profile to grant access to @@ -565,10 +702,12 @@ export class TestDecisionsDataManager { } /** - * Generates a unique name with UUID first to avoid truncation issues with slug generation + * Generates a unique name with UUID first to survive slug truncation. + * Profile slugs are truncated to ~30 chars, so UUID must be at the start. */ private generateUniqueName(baseName: string): string { - return `${randomUUID()}-${baseName}-${this.testId}`; + // UUID first ensures uniqueness survives slug truncation + return `${randomUUID().substring(0, 8)}-${baseName}`; } /**