Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions packages/common/src/services/decision/buildExpectedTransitions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { ProcessInstance } from '@op/db/schema';

import { CommonError } from '../../utils';
import type { DecisionInstanceData } from './schemas/instanceData';
import type { ScheduledTransition } from './types';

/**
* Builds expected transition records from a process instance's phase data.
* Only creates transitions for phases with date-based advancement
* (rules.advancement.method === 'date').
*
* Shared by both `createTransitionsForProcess` and `updateTransitionsForProcess`.
*/
export function buildExpectedTransitions(
processInstance: ProcessInstance,
): ScheduledTransition[] {
// 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) {
throw new CommonError(
'Process instance must have at least one phase configured',
);
}

const transitions: ScheduledTransition[] = [];

phases.forEach((currentPhase, index) => {
const nextPhase = phases[index + 1];
// Skip last phase (no next phase to transition to)
if (!nextPhase) {
return;
}

// Only create transition if current phase uses date-based advancement
if (currentPhase.rules?.advancement?.method !== 'date') {
return;
}

// 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
transitions.push({
processInstanceId: processInstance.id,
fromStateId: currentPhase.phaseId,
toStateId: nextPhase.phaseId,
scheduledDate: new Date(scheduledDate).toISOString(),
});
});

return transitions;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { db, eq } from '@op/db/client';
import { db } from '@op/db/client';
import {
EntityType,
ProcessStatus,
Expand All @@ -12,7 +12,6 @@ import type { User } from '@op/supabase/lib';
import { CommonError, UnauthorizedError } from '../../utils';
import { assertUserByAuthId } from '../assert';
import { generateUniqueProfileSlug } from '../profile/utils';
import { createTransitionsForProcess } from './createTransitionsForProcess';
import { createDecisionRole } from './decisionRoles';
import { getTemplate } from './getTemplate';
import {
Expand Down Expand Up @@ -186,27 +185,13 @@ export const createInstanceFromTemplateCore = async ({
return newInstance;
});

// Create scheduled transitions for phases that have date-based advancement AND actual dates set
const hasScheduledDatePhases = instanceData.phases.some(
(phase) => phase.rules?.advancement?.method === 'date' && phase.startDate,
);

if (hasScheduledDatePhases) {
try {
await createTransitionsForProcess({ processInstance: instance });
} catch (error) {
// Log but don't fail instance creation if transitions can't be created
console.error(
'Failed to create transitions for process instance:',
error,
);
}
}
// Note: Transitions are NOT created here because the instance is created as DRAFT.
// Transitions are created when the instance is published via updateDecisionInstance.

// Fetch the profile with processInstance joined for the response
// profileId is guaranteed to be set since we just created it above
const profile = await db._query.profiles.findFirst({
where: eq(profiles.id, instance.profileId!),
const profile = await db.query.profiles.findFirst({
where: { id: instance.profileId! },
with: {
processInstance: true,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,74 +1,51 @@
import { db } from '@op/db/client';
import { type TransactionType, db } from '@op/db/client';
import { decisionProcessTransitions } from '@op/db/schema';
import type { ProcessInstance } from '@op/db/schema';

import { CommonError } from '../../utils';
import type { InstanceData, PhaseConfiguration } from './types';
import { buildExpectedTransitions } from './buildExpectedTransitions';

export interface CreateTransitionsInput {
/**
* Creates scheduled transition records for phases with date-based advancement.
* Each transition fires when the current phase's end date arrives.
*
* Rules are read from the instance's phase data (instanceData.phases[].rules),
* which are populated from the template when the instance is created.
*/
export async function createTransitionsForProcess({
processInstance,
tx,
}: {
processInstance: ProcessInstance;
}

export interface CreateTransitionsResult {
tx?: TransactionType;
}): Promise<{
transitions: Array<{
id: string;
fromStateId: string | null;
toStateId: string;
scheduledDate: Date;
}>;
}
}> {
const dbClient = tx ?? db;

/**
* 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<CreateTransitionsResult> {
try {
const instanceData = processInstance.instanceData as InstanceData;
const phases = instanceData.phases;
const transitionsToCreate = buildExpectedTransitions(processInstance);

if (!phases || phases.length === 0) {
throw new CommonError(
'Process instance must have at least one phase configured',
);
if (transitionsToCreate.length === 0) {
return { transitions: [] };
}

const transitionsToCreate = phases.flatMap(
(phase: PhaseConfiguration, index: number) => {
const scheduledDate = phase.endDate ?? phase.startDate;

// Skip phases that have no dates yet — they don't need transitions
if (!scheduledDate) {
return [];
}

const fromStateId = index > 0 ? phases[index - 1]?.phaseId : null;
const toStateId = phase.phaseId;

return [
{
processInstanceId: processInstance.id,
fromStateId,
toStateId,
scheduledDate: new Date(scheduledDate).toISOString(),
},
];
},
);

const createdTransitions = await db
const createdTransitions = await dbClient
.insert(decisionProcessTransitions)
.values(transitionsToCreate)
.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) {
Expand Down
7 changes: 7 additions & 0 deletions packages/common/src/services/decision/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export * from './getDecisionBySlug';
// Transition management
export { TransitionEngine } from './transitionEngine';
export type { TransitionCheckResult } from './transitionEngine';
export * from './buildExpectedTransitions';
export * from './createTransitionsForProcess';
export * from './updateTransitionsForProcess';
export * from './transitionMonitor';
Expand Down Expand Up @@ -78,3 +79,9 @@ export type {
DecisionInstanceData,
PhaseInstanceData,
} from './schemas/instanceData';
export type {
DecisionSchemaDefinition,
PhaseDefinition,
PhaseRules,
ProcessConfig,
} from './schemas/types';
6 changes: 0 additions & 6 deletions packages/common/src/services/decision/transitionEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,12 +73,6 @@ export class TransitionEngine {
const process = instance.process as any;
const processSchema = process.processSchema as ProcessSchema;
const instanceData = instance.instanceData as InstanceData;
console.log(
'TRANSITION',
instanceData.currentPhaseId,
instance.currentStateId,
instanceData,
);
const currentStateId =
instanceData.currentPhaseId || instance.currentStateId || '';

Expand Down
Loading