/** * @module wf.opportunity * * Opportunity Workflow * ==================== * * Central workflow engine for the opportunity lifecycle. All state * transitions, follow-up scheduling, cold detection, and CW activity * creation are driven through this file. * * ## Ground rules * * 1. Every state transition creates a CW activity as the audit trail. * 2. Activities carry an `Optima_Type` custom field to tag their type. * 3. The opportunity's CW status is the source of truth for current state. * 4. The activity history IS the metadata — timestamps, notes, and * state changes are all derived from activities. * 5. When `timeSpent` is provided on an activity close, a CW time entry * is automatically submitted. * 6. The opportunity's stage MUST be "Optima" before any workflow * action is allowed. * * ## Statuses (CW IDs) * * | Enum Key | CW ID | CW Name | * |-------------------|-------|------------------------| * | PendingNew | 37 | 00. Pending New | * | New | 24 | 01. New | * | InternalReview | 56 | 02. Internal Review | * | QuoteSent | 43 | 03. Quote Sent | * | ConfirmedQuote | 57 | 04. Confirmed Quote | * | Active | 58 | 05. Active | * | ReadyToSend | 63 | Ready to Send | * | PendingSent | 60 | Pending Sent (Deprecated) | * | PendingRevision | 61 | Pending Revision | * | PendingWon | 49 | 91. Pending Won | * | Won | 29 | 95. Won | * | PendingLost | 50 | 98. Pending Lost | * | Lost | 53 | 99. Lost | * | Canceled | 59 | Canceled | */ import { OpportunityController } from "../controllers/OpportunityController"; import { ActivityController } from "../controllers/ActivityController"; import { activityCw } from "../modules/cw-utils/activities/activities"; import { checkColdStatus, type ColdCheckResult, } from "../modules/algorithms/algo.coldThreshold"; import { submitTimeEntry, syncOpportunityStatus, } from "../services/cw.opportunityService"; // ═══════════════════════════════════════════════════════════════════════════ // CONSTANTS & ENUMS // ═══════════════════════════════════════════════════════════════════════════ /** * Canonical status enum mapping workflow names → CW status IDs. */ export const OpportunityStatus = { PendingNew: 37, New: 24, InternalReview: 56, QuoteSent: 43, ConfirmedQuote: 57, Active: 58, ReadyToSend: 63, PendingSent: 60, PendingRevision: 61, PendingWon: 49, Won: 29, PendingLost: 50, Lost: 53, Canceled: 59, } as const; export type OpportunityStatusKey = keyof typeof OpportunityStatus; export type OpportunityStatusId = (typeof OpportunityStatus)[OpportunityStatusKey]; /** Reverse lookup: CW ID → workflow key. */ export const StatusIdToKey: Record = Object.fromEntries( Object.entries(OpportunityStatus).map(([k, v]) => [ v, k as OpportunityStatusKey, ]), ) as Record; /** Terminal (immutable) statuses. */ const TERMINAL_STATUSES = new Set([ OpportunityStatus.Won, OpportunityStatus.Lost, ]); /** * CW Activity custom field for Optima_Type. * * Field ID 45, with list options: * 58 = Quote Setup * 58 = Opportunity Setup * 59 = Quote Sent * 60 = Quote Confirmed * 61 = Revision * 62 = Finalized * 63 = Converted * 64 = Opportunity Created * 65 = Opportunity Review * 66 = Quote Generated * 67 = Quote Sent & Confirmed */ export const OptimaType = { FIELD_ID: 45, OpportunityCreated: "Opportunity Created", OpportunitySetup: "Opportunity Setup", OpportunityReview: "Opportunity Review", QuoteSent: "Quote Sent", QuoteConfirmed: "Quote Confirmed", QuoteSentConfirmed: "Quote Sent & Confirmed", QuoteGenerated: "Quote Generated", Revision: "Revision", Finalized: "Finalized", Converted: "Converted", ScheduleEntry: "Schedule Entry", } as const; /** CW custom field ID for the QuoteID field on activities. */ const QUOTE_ID_FIELD_ID = 48; /** CW custom field ID for the Close Date field on activities. */ const CLOSE_DATE_FIELD_ID = 49; /** CW custom field ID for the Parent Activity field on activities. */ export const PARENT_ACTIVITY_FIELD_ID = 50; /** * Optima_Type values whose activities should remain Open until the * next workflow transition closes them automatically. */ const STAYS_OPEN_TYPES = new Set([ OptimaType.OpportunitySetup, OptimaType.OpportunityReview, OptimaType.Revision, OptimaType.ScheduleEntry, ]); export type OptimaTypeValue = | typeof OptimaType.OpportunityCreated | typeof OptimaType.OpportunitySetup | typeof OptimaType.OpportunityReview | typeof OptimaType.QuoteSent | typeof OptimaType.QuoteConfirmed | typeof OptimaType.QuoteSentConfirmed | typeof OptimaType.QuoteGenerated | typeof OptimaType.Revision | typeof OptimaType.Finalized | typeof OptimaType.Converted | typeof OptimaType.ScheduleEntry; /** Permission nodes required by gated transitions. */ export const WorkflowPermissions = { FINALIZE: "sales.opportunity.finalize", CANCEL: "sales.opportunity.cancel", REVIEW: "sales.opportunity.review", SEND: "sales.opportunity.send", REOPEN: "sales.opportunity.reopen", WIN: "sales.opportunity.win", LOSE: "sales.opportunity.lose", } as const; /** Required Optima stage name. */ const REQUIRED_STAGE = "Optima"; // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION MAP // ═══════════════════════════════════════════════════════════════════════════ /** * Defines which target statuses are reachable from each source status. * * This map covers DIRECT transitions only. Some transitions are * compound (e.g. QuoteSent with `won: true` goes through Won/PendingWon), * which are handled inside the individual transition functions. */ const ALLOWED_TRANSITIONS: Record> = { [OpportunityStatus.PendingNew]: new Set([OpportunityStatus.New]), [OpportunityStatus.New]: new Set([ OpportunityStatus.ReadyToSend, OpportunityStatus.InternalReview, OpportunityStatus.QuoteSent, OpportunityStatus.Canceled, ]), [OpportunityStatus.InternalReview]: new Set([ OpportunityStatus.ReadyToSend, OpportunityStatus.PendingRevision, OpportunityStatus.QuoteSent, // reviewer manually sends OpportunityStatus.Canceled, ]), [OpportunityStatus.ReadyToSend]: new Set([OpportunityStatus.QuoteSent]), [OpportunityStatus.PendingSent]: new Set([ OpportunityStatus.ReadyToSend, OpportunityStatus.QuoteSent, ]), [OpportunityStatus.PendingRevision]: new Set([OpportunityStatus.Active]), [OpportunityStatus.QuoteSent]: new Set([ OpportunityStatus.ConfirmedQuote, OpportunityStatus.Won, OpportunityStatus.PendingWon, OpportunityStatus.PendingLost, OpportunityStatus.Active, OpportunityStatus.PendingRevision, // needs revision OpportunityStatus.InternalReview, // cold automation only ]), [OpportunityStatus.ConfirmedQuote]: new Set([ OpportunityStatus.Won, OpportunityStatus.PendingWon, OpportunityStatus.PendingLost, OpportunityStatus.Active, OpportunityStatus.InternalReview, // cold automation only OpportunityStatus.PendingRevision, // send back for revision ]), [OpportunityStatus.Active]: new Set([ OpportunityStatus.ReadyToSend, OpportunityStatus.QuoteSent, OpportunityStatus.InternalReview, OpportunityStatus.Canceled, ]), [OpportunityStatus.PendingWon]: new Set([ OpportunityStatus.Won, OpportunityStatus.Active, // admin revision ]), [OpportunityStatus.PendingLost]: new Set([ OpportunityStatus.Lost, OpportunityStatus.Active, // resurrection ]), [OpportunityStatus.Won]: new Set(), // terminal [OpportunityStatus.Lost]: new Set(), // terminal [OpportunityStatus.Canceled]: new Set([ OpportunityStatus.Active, // re-open ]), }; // ═══════════════════════════════════════════════════════════════════════════ // TYPES // ═══════════════════════════════════════════════════════════════════════════ /** User context passed into every workflow action. */ export interface WorkflowUser { /** Internal user ID. */ id: string; /** CW member ID for activity assignment. */ cwMemberId: number; /** Resolved permission node strings the user holds. */ permissions: string[]; } /** Base payload fields common to all actions. */ interface BaseActionPayload { /** Required note for state-change activities. */ note?: string; /** ISO-8601 datetime when work started. Both timeStarted and timeEnded must be provided to submit a time entry. */ timeStarted?: string; /** ISO-8601 datetime when work ended. Both timeStarted and timeEnded must be provided to submit a time entry. */ timeEnded?: string; } /** Transition to New. */ export interface AcceptNewPayload extends BaseActionPayload {} /** Transition to InternalReview. */ export interface RequestReviewPayload extends BaseActionPayload { note: string; // required } /** Review decision payload (approve / reject / send / cancel). */ export interface ReviewDecisionPayload extends BaseActionPayload { note: string; // required decision: "approve" | "reject" | "send" | "cancel"; } /** Transition from ReadyToSend/PendingSent(deprecated) → QuoteSent. */ export interface SendQuotePayload extends BaseActionPayload { /** * If true, marks sent AND confirmed simultaneously. * Skips receipt confirmation follow-up. Transitions to ConfirmedQuote. */ quoteConfirmed?: boolean; /** * Quote was presented and won immediately. * Without finalize: transitions to PendingWon. * With finalize (requires FINALIZE perm): transitions directly to Won. * Satisfies QuoteSent and ConfirmedQuote simultaneously. */ won?: boolean; /** * Quote rejected immediately upon presentation. * Without finalize: transitions to PendingLost. * With finalize (requires FINALIZE perm): transitions directly to Lost. */ lost?: boolean; /** * Explicitly finalize the outcome (skip pending state). * Requires `sales.opportunity.finalize` permission. * Used with `won` or `lost` to go directly to Won/Lost * instead of PendingWon/PendingLost. */ finalize?: boolean; /** * Quote needs revision. * Creates a revision activity and transitions to PendingRevision. */ needsRevision?: boolean; } /** Mark opportunity as ready to send quote from send-capable statuses. */ export interface MarkReadyToSendPayload extends BaseActionPayload {} /** Confirm receipt of a quote. */ export interface ConfirmQuotePayload extends BaseActionPayload {} /** Mark opportunity as won or lost. */ export interface FinalizePayload extends BaseActionPayload { note: string; // required outcome: "won" | "lost"; } /** Resurrect from PendingWon / PendingLost → Active. */ export interface ResurrectPayload extends BaseActionPayload { note: string; // required } /** Begin revision from PendingRevision → Active. */ export interface BeginRevisionPayload extends BaseActionPayload {} /** Send back for revision from ConfirmedQuote → PendingRevision. */ export interface SendBackForRevisionPayload extends BaseActionPayload { note: string; // required } /** Create a Schedule Entry activity (no status change). */ export interface CreateScheduleEntryPayload extends BaseActionPayload { /** Activity type value: Follow-Up, Appointment, or Admin. */ activityTypeValue: "Follow-Up" | "Appointment" | "Admin"; /** ISO-8601 due date. */ dueDate?: string; /** ISO-8601 start time. */ startTime?: string; /** ISO-8601 end time. */ endTime?: string; /** Optional notes for the schedule entry. */ note?: string; } /** Re-send from Active → QuoteSent. */ export interface ResendQuotePayload extends SendQuotePayload {} /** Cancel payload. */ export interface CancelPayload extends BaseActionPayload { note: string; // required } /** Re-open from Canceled → Active. */ export interface ReopenPayload extends BaseActionPayload { note: string; // required } // ----------------------------------------------------------------------- // Action discriminator // ----------------------------------------------------------------------- export type WorkflowAction = | { action: "acceptNew"; payload: AcceptNewPayload } | { action: "requestReview"; payload: RequestReviewPayload } | { action: "reviewDecision"; payload: ReviewDecisionPayload } | { action: "markReadyToSend"; payload: MarkReadyToSendPayload } | { action: "sendQuote"; payload: SendQuotePayload } | { action: "confirmQuote"; payload: ConfirmQuotePayload } | { action: "finalize"; payload: FinalizePayload } | { action: "resurrect"; payload: ResurrectPayload } | { action: "beginRevision"; payload: BeginRevisionPayload } | { action: "resendQuote"; payload: ResendQuotePayload } | { action: "cancel"; payload: CancelPayload } | { action: "reopen"; payload: ReopenPayload } | { action: "sendBackForRevision"; payload: SendBackForRevisionPayload } | { action: "createScheduleEntry"; payload: CreateScheduleEntryPayload }; // ----------------------------------------------------------------------- // Result // ----------------------------------------------------------------------- export interface WorkflowResult { success: boolean; /** Previous CW status ID, null when the status did not change. */ previousStatusId: number | null; /** New CW status ID after the transition, null on failure. */ newStatusId: number | null; /** Human-readable status keys. */ previousStatus: OpportunityStatusKey | null; newStatus: OpportunityStatusKey | null; /** Activities created during the transition. */ activitiesCreated: ActivityController[]; /** Cold-check result, when applicable. */ coldCheck: ColdCheckResult | null; /** Error message on failure. */ error: string | null; } // ═══════════════════════════════════════════════════════════════════════════ // HELPERS // ═══════════════════════════════════════════════════════════════════════════ function hasPermission(user: WorkflowUser, node: string): boolean { return user.permissions.includes("*") || user.permissions.includes(node); } function fail( message: string, currentStatusId?: number | null, ): WorkflowResult { return { success: false, previousStatusId: currentStatusId ?? null, newStatusId: null, previousStatus: currentStatusId != null ? (StatusIdToKey[currentStatusId] ?? null) : null, newStatus: null, activitiesCreated: [], coldCheck: null, error: message, }; } function ok( previousStatusId: number, newStatusId: number, activities: ActivityController[], coldCheck: ColdCheckResult | null = null, ): WorkflowResult { return { success: true, previousStatusId, newStatusId, previousStatus: StatusIdToKey[previousStatusId] ?? null, newStatus: StatusIdToKey[newStatusId] ?? null, activitiesCreated: activities, coldCheck, error: null, }; } /** * Build the `customFields` array for a CW activity with Optima_Type set, * and optionally a QuoteID, CloseDate, or ParentActivity. */ function buildCustomFields( optimaType: OptimaTypeValue, opts?: { quoteId?: string; closeDate?: string; parentActivityCwId?: number }, ) { const fields: any[] = [ { id: OptimaType.FIELD_ID, caption: "Optima_Type", type: "Text", entryMethod: "List", numberOfDecimals: 0, value: optimaType, }, ]; if (opts?.quoteId) { fields.push({ id: QUOTE_ID_FIELD_ID, caption: "QuoteID", type: "Text", entryMethod: "EntryField", numberOfDecimals: 0, value: opts.quoteId, }); } if (opts?.closeDate) { fields.push({ id: CLOSE_DATE_FIELD_ID, caption: "Close Date", type: "Text", entryMethod: "Date", numberOfDecimals: 0, value: opts.closeDate, }); } if (opts?.parentActivityCwId != null) { fields.push({ id: PARENT_ACTIVITY_FIELD_ID, caption: "Parent_Activity", type: "Text", entryMethod: "EntryField", numberOfDecimals: 0, value: String(opts.parentActivityCwId), }); } return fields; } /** * Create a CW activity for a workflow transition. */ export async function createWorkflowActivity(opts: { name: string; opportunityCwId: number; companyCwId: number | null; assignToCwMemberId: number; notes: string; optimaType: OptimaTypeValue; quoteId?: string; dateStart?: string; dateEnd?: string; parentActivityCwId?: number | null; }): Promise { const shouldStayOpen = STAYS_OPEN_TYPES.has(opts.optimaType); const activity = await ActivityController.create({ name: opts.name, type: { id: 3 }, // HistoricEntry opportunity: { id: opts.opportunityCwId }, ...(opts.companyCwId ? { company: { id: opts.companyCwId } } : {}), assignTo: { id: opts.assignToCwMemberId }, ...(shouldStayOpen ? {} : { status: { id: 2 } }), // Closed unless stays-open notes: opts.notes, }); // Set custom fields (Optima_Type + optional QuoteID + closeDate for closed activities). // For non-stays-open types, also re-assert Closed status. const now = shouldStayOpen ? undefined : new Date().toISOString(); const patchOps: any[] = [ { op: "replace", path: "customFields", value: buildCustomFields(opts.optimaType, { quoteId: opts.quoteId, closeDate: now, parentActivityCwId: opts.parentActivityCwId ?? undefined, }), }, ]; if (!shouldStayOpen) { patchOps.push({ op: "replace", path: "status", value: { id: 2 }, // Closed }); } const patched = await activity.update(patchOps); return patched; } /** * Resolve the parent activity CW ID for a newly generated quote activity. * * Finds the most recently created workflow activity (by CW ID descending) for * the opportunity, excluding QuoteGenerated and ScheduleEntry types. This * ensures the quote activity is nested under the current workflow state's * activity regardless of whether that activity is open or closed. */ export async function resolveQuoteParentActivityCwId( opportunityCwId: number, ): Promise { try { const existingActivities = await activityCw.fetchByOpportunityDirect(opportunityCwId); // Sort descending by CW id so the most recently created comes first const sorted = [...existingActivities].sort((a, b) => (b.id ?? 0) - (a.id ?? 0)); for (const raw of sorted) { const optimaField = raw.customFields?.find( (f: any) => f.id === OptimaType.FIELD_ID, ); if (!optimaField?.value) continue; // Skip QuoteGenerated and ScheduleEntry — these should not be parents if (optimaField.value === OptimaType.QuoteGenerated) continue; if (optimaField.value === OptimaType.ScheduleEntry) continue; return raw.id; } return null; } catch (err) { console.warn(`[Workflow:QuoteParent] Could not resolve parent activity: ${err}`); return null; } } /** * Handle optional time entry: submit to CW if timeStart and timeEnd are provided. */ async function handleTimeEntry( activityCwId: number | undefined, cwMemberId: number, payload: BaseActionPayload, notes: string, ): Promise { console.log( `[Workflow:TimeEntry] Called — activityCwId=${activityCwId}, cwMemberId=${cwMemberId}, timeStarted=${payload.timeStarted ?? "MISSING"}, timeEnded=${payload.timeEnded ?? "MISSING"}`, ); if (activityCwId == null) { console.warn( `[Workflow:TimeEntry] SKIPPED — activityCwId is ${activityCwId} (activity was not created or has no CW ID)`, ); return; } if (!payload.timeStarted || !payload.timeEnded) { console.warn( `[Workflow:TimeEntry] SKIPPED — timeStarted or timeEnded not provided (timeStarted=${payload.timeStarted ?? "undefined"}, timeEnded=${payload.timeEnded ?? "undefined"})`, ); return; } console.log( `[Workflow:TimeEntry] Submitting — activity=${activityCwId}, member=${cwMemberId}, start=${payload.timeStarted}, end=${payload.timeEnded}, notes="${notes}"`, ); try { const result = await submitTimeEntry({ activityId: activityCwId, cwMemberId, timeStart: payload.timeStarted, timeEnd: payload.timeEnded, notes, }); if (result.success) { console.log( `[Workflow:TimeEntry] SUCCESS — cwTimeEntryId=${result.cwTimeEntryId}, message="${result.message}"`, ); } else { console.error(`[Workflow:TimeEntry] FAILED — ${result.message}`); } } catch (err: any) { console.error( `[Workflow:TimeEntry] EXCEPTION —`, err?.response?.data ?? err?.message ?? err, ); } } // ═══════════════════════════════════════════════════════════════════════════ // GUARDS // ═══════════════════════════════════════════════════════════════════════════ /** * Top-level guard: opportunity stage must be "Optima". */ function assertOptimaStage(opportunity: OpportunityController): string | null { if (opportunity.stageName !== REQUIRED_STAGE) { return `Workflow actions require the opportunity stage to be "${REQUIRED_STAGE}". Current stage: "${opportunity.stageName ?? "(none)"}"`; } return null; } /** * Guard: opportunity must not be in a terminal status. */ function assertNotTerminal(statusCwId: number | null): string | null { if (statusCwId != null && TERMINAL_STATUSES.has(statusCwId)) { const key = StatusIdToKey[statusCwId] ?? "Unknown"; return `Opportunity is in terminal status "${key}" and cannot be modified.`; } return null; } /** * Guard: transition must be allowed by the transition map. */ function assertTransitionAllowed( fromStatusId: number, toStatusId: number, ): string | null { const allowed = ALLOWED_TRANSITIONS[fromStatusId]; if (!allowed || !allowed.has(toStatusId)) { const from = StatusIdToKey[fromStatusId] ?? String(fromStatusId); const to = StatusIdToKey[toStatusId] ?? String(toStatusId); return `Transition from "${from}" to "${to}" is not allowed.`; } return null; } /** * Guard: note must be non-empty. */ function assertNotePresent(note: string | undefined): string | null { if (!note || note.trim().length === 0) { return "A non-empty note is required for this action."; } return null; } // ═══════════════════════════════════════════════════════════════════════════ // TRANSITION FUNCTIONS // ═══════════════════════════════════════════════════════════════════════════ /** * PendingNew → New * * Accept/setup an opportunity. */ export async function transitionToNew( opportunity: OpportunityController, user: WorkflowUser, payload: AcceptNewPayload, ): Promise { const currentStatus = opportunity.statusCwId ?? OpportunityStatus.PendingNew; const targetStatus = OpportunityStatus.New; const err = assertTransitionAllowed(currentStatus, targetStatus); if (err) return fail(err, currentStatus); const activities: ActivityController[] = []; // Create setup activity const activity = await createWorkflowActivity({ name: `[Workflow] Opportunity accepted — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? "Opportunity accepted and moved to New.", optimaType: OptimaType.OpportunitySetup, }); activities.push(activity); // Sync status to CW await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Opportunity accepted.", ); return ok(currentStatus, targetStatus, activities); } /** * Any pre-confirmed status → InternalReview * * Manually request internal review. Requires a note. */ export async function transitionToInternalReview( opportunity: OpportunityController, user: WorkflowUser, payload: RequestReviewPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const targetStatus = OpportunityStatus.InternalReview; const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); if (!hasPermission(user, WorkflowPermissions.REVIEW)) { return fail( `User lacks the "${WorkflowPermissions.REVIEW}" permission required to submit an opportunity for internal review.`, currentStatus, ); } const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const activity = await createWorkflowActivity({ name: `[Workflow] Sent to Internal Review — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.OpportunityReview, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } /** * InternalReview → ReadyToSend | PendingRevision | QuoteSent | Canceled * * Reviewer makes a decision on an opportunity in InternalReview. */ export async function handleReviewDecision( opportunity: OpportunityController, user: WorkflowUser, payload: ReviewDecisionPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); if (currentStatus !== OpportunityStatus.InternalReview) { return fail( `Review decisions can only be made when the opportunity is in InternalReview. Current status: "${StatusIdToKey[currentStatus] ?? "Unknown"}"`, currentStatus, ); } const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); const activities: ActivityController[] = []; switch (payload.decision) { // ── Approve → ReadyToSend ────────────────────────────────────────── case "approve": { const targetStatus = OpportunityStatus.ReadyToSend; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activity = await createWorkflowActivity({ name: `[Workflow] Review approved — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.OpportunityReview, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } // ── Reject → PendingRevision ────────────────────────────────────── case "reject": { const targetStatus = OpportunityStatus.PendingRevision; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activity = await createWorkflowActivity({ name: `[Workflow] Review rejected — revision required — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Revision, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } // ── Send directly (reviewer manually sends) ────────────────────── case "send": { const targetStatus = OpportunityStatus.QuoteSent; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); // Create approved activity const approvedActivity = await createWorkflowActivity({ name: `[Workflow] Review approved — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: `Review approved: ${payload.note}`, optimaType: OptimaType.OpportunityReview, }); activities.push(approvedActivity); // Create quote-sent activity const sentActivity = await createWorkflowActivity({ name: `[Workflow] Quote sent (by reviewer) — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: `Quote sent by reviewer: ${payload.note}`, optimaType: OptimaType.QuoteSent, }); activities.push(sentActivity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } // ── Cancel from review ──────────────────────────────────────────── case "cancel": { const targetStatus = OpportunityStatus.Canceled; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); if (!hasPermission(user, WorkflowPermissions.CANCEL)) { return fail( `User lacks the "${WorkflowPermissions.CANCEL}" permission required to cancel an opportunity.`, currentStatus, ); } const activity = await createWorkflowActivity({ name: `[Workflow] Canceled from review — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Finalized, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } default: return fail( `Unknown review decision: "${(payload as any).decision}"`, currentStatus, ); } } /** * ReadyToSend/PendingSent(deprecated) → QuoteSent (and its compound transitions) * * Also handles New → QuoteSent (direct send, skipping review) and * Active → QuoteSent (re-send after revision). * * Payload flags: * quoteConfirmed — sent + confirmed simultaneously → ConfirmedQuote * won — presented and won → Won/PendingWon * lost — rejected immediately → PendingLost * needsRevision — needs revision → Active */ export async function transitionToQuoteSent( opportunity: OpportunityController, user: WorkflowUser, payload: SendQuotePayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); if (!hasPermission(user, WorkflowPermissions.SEND)) { return fail( `User lacks the "${WorkflowPermissions.SEND}" permission required to send a quote.`, currentStatus, ); } // Validate source status allows transition to QuoteSent const transErr = assertTransitionAllowed( currentStatus, OpportunityStatus.QuoteSent, ); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const now = new Date().toISOString(); // ── won flag: presented and won immediately ───────────────────────── if (payload.won) { if (!hasPermission(user, WorkflowPermissions.WIN)) { return fail( `User lacks the "${WorkflowPermissions.WIN}" permission required to mark an opportunity as won.`, currentStatus, ); } const wantsFinalize = !!payload.finalize; const canFinalize = wantsFinalize && hasPermission(user, WorkflowPermissions.FINALIZE); if (wantsFinalize && !canFinalize) { return fail( `User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`, currentStatus, ); } const targetStatus = canFinalize ? OpportunityStatus.Won : OpportunityStatus.PendingWon; // Combined Sent & Confirmed activity const sentConfirmedActivity = await createWorkflowActivity({ name: `[Workflow] Quote sent & confirmed — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: `Quote sent and confirmed simultaneously (immediate win). ${payload.note ?? ""}`.trim(), optimaType: OptimaType.QuoteSentConfirmed, }); activities.push(sentConfirmedActivity); // Won/PendingWon activity const outcomeType = canFinalize ? OptimaType.Converted : OptimaType.Finalized; const wonActivity = await createWorkflowActivity({ name: `[Workflow] ${canFinalize ? "Won" : "Pending Won"} — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? `Opportunity ${canFinalize ? "won" : "pending won"}.`, optimaType: outcomeType, }); activities.push(wonActivity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Immediate win.", ); return ok(currentStatus, targetStatus, activities); } // ── lost flag: rejected immediately ───────────────────────────────── if (payload.lost) { if (!hasPermission(user, WorkflowPermissions.LOSE)) { return fail( `User lacks the "${WorkflowPermissions.LOSE}" permission required to mark an opportunity as lost.`, currentStatus, ); } const wantsFinalize = !!payload.finalize; const canFinalize = wantsFinalize && hasPermission(user, WorkflowPermissions.FINALIZE); if (wantsFinalize && !canFinalize) { return fail( `User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`, currentStatus, ); } const targetStatus = canFinalize ? OpportunityStatus.Lost : OpportunityStatus.PendingLost; const wasAlsoConfirmed = !!payload.quoteConfirmed; const sentActivity = await createWorkflowActivity({ name: wasAlsoConfirmed ? `[Workflow] Quote sent & confirmed — ${opportunity.name}` : `[Workflow] Quote sent — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: wasAlsoConfirmed ? `Quote sent and confirmed, but rejected. ${payload.note ?? ""}`.trim() : `Quote sent. ${payload.note ?? ""}`.trim(), optimaType: wasAlsoConfirmed ? OptimaType.QuoteSentConfirmed : OptimaType.QuoteSent, }); activities.push(sentActivity); const lostActivity = await createWorkflowActivity({ name: canFinalize ? `[Workflow] Lost (immediate rejection) — ${opportunity.name}` : `[Workflow] Pending Lost (immediate rejection) — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? "Quote rejected immediately upon presentation.", optimaType: canFinalize ? OptimaType.Converted : OptimaType.Finalized, }); activities.push(lostActivity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Immediate rejection.", ); return ok(currentStatus, targetStatus, activities); } // ── needsRevision flag → Active ────────────────────────────────────── if (payload.needsRevision) { const targetStatus = OpportunityStatus.Active; const wasAlsoConfirmed = !!payload.quoteConfirmed; const sentActivity = await createWorkflowActivity({ name: wasAlsoConfirmed ? `[Workflow] Quote sent & confirmed — ${opportunity.name}` : `[Workflow] Quote sent — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: wasAlsoConfirmed ? `Quote sent and confirmed, but revision needed. ${payload.note ?? ""}`.trim() : `Quote sent. ${payload.note ?? ""}`.trim(), optimaType: wasAlsoConfirmed ? OptimaType.QuoteSentConfirmed : OptimaType.QuoteSent, }); activities.push(sentActivity); const revisionActivity = await createWorkflowActivity({ name: `[Workflow] Revision needed — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? "Revision requested after quote presentation.", optimaType: OptimaType.Revision, }); activities.push(revisionActivity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Needs revision.", ); return ok(currentStatus, targetStatus, activities); } // ── quoteConfirmed flag → ConfirmedQuote ───────────────────────────── if (payload.quoteConfirmed) { const targetStatus = OpportunityStatus.ConfirmedQuote; const sentConfirmedActivity = await createWorkflowActivity({ name: `[Workflow] Quote sent & confirmed — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: `Quote sent and confirmed simultaneously. ${payload.note ?? ""}`.trim(), optimaType: OptimaType.QuoteSentConfirmed, }); activities.push(sentConfirmedActivity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Quote sent and confirmed.", ); return ok(currentStatus, targetStatus, activities); } // ── Default: plain QuoteSent (no flags) ────────────────────────────── const targetStatus = OpportunityStatus.QuoteSent; const sentActivity = await createWorkflowActivity({ name: `[Workflow] Quote sent — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: `Quote sent. ${payload.note ?? ""}`.trim(), optimaType: OptimaType.QuoteSent, }); activities.push(sentActivity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Quote sent.", ); return ok(currentStatus, targetStatus, activities); } /** * New/Active/PendingSent(deprecated) → ReadyToSend * * Allows staging an opportunity as ready without immediately sending. */ export async function transitionToReadyToSend( opportunity: OpportunityController, user: WorkflowUser, payload: MarkReadyToSendPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); if (!hasPermission(user, WorkflowPermissions.SEND)) { return fail( `User lacks the "${WorkflowPermissions.SEND}" permission required to mark an opportunity ready to send.`, currentStatus, ); } const targetStatus = OpportunityStatus.ReadyToSend; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activity = await createWorkflowActivity({ name: `[Workflow] Marked ready to send — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? "Marked ready to send.", optimaType: OptimaType.OpportunityReview, }); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activity.cwActivityId, user.cwMemberId, payload, payload.note ?? "Marked ready to send.", ); return ok(currentStatus, targetStatus, [activity]); } /** * QuoteSent → ConfirmedQuote * * Customer has acknowledged receipt of the quote. */ export async function transitionToConfirmedQuote( opportunity: OpportunityController, user: WorkflowUser, payload: ConfirmQuotePayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const targetStatus = OpportunityStatus.ConfirmedQuote; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const activity = await createWorkflowActivity({ name: `[Workflow] Quote confirmed — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? "Customer confirmed receipt of quote.", optimaType: OptimaType.QuoteConfirmed, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Quote confirmed.", ); return ok(currentStatus, targetStatus, activities); } /** * PendingWon → Won OR PendingLost → Lost * * Finalize from a pending state. Requires `sales.opportunity.finalize`. * * Also used for direct Won/Lost from QuoteSent/ConfirmedQuote when * the user holds the finalize permission (called internally). */ export async function finalizeOpportunity( opportunity: OpportunityController, user: WorkflowUser, payload: FinalizePayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); const requiredPerm = payload.outcome === "won" ? WorkflowPermissions.WIN : WorkflowPermissions.LOSE; if (!hasPermission(user, requiredPerm)) { return fail( `User lacks the "${requiredPerm}" permission required to mark an opportunity as ${payload.outcome}.`, currentStatus, ); } if (!hasPermission(user, WorkflowPermissions.FINALIZE)) { return fail( `User lacks the "${WorkflowPermissions.FINALIZE}" permission required to finalize an opportunity.`, currentStatus, ); } const targetStatus = payload.outcome === "won" ? OpportunityStatus.Won : OpportunityStatus.Lost; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const optimaType = payload.outcome === "won" ? OptimaType.Converted : OptimaType.Finalized; const activity = await createWorkflowActivity({ name: `[Workflow] ${payload.outcome === "won" ? "Won" : "Lost"} — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } /** * ConfirmedQuote/QuoteSent → PendingWon | PendingLost * * When the user DOES NOT have finalize permission, win/lose actions * route to the pending variant. */ export async function transitionToPending( opportunity: OpportunityController, user: WorkflowUser, payload: FinalizePayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); const requiredPerm = payload.outcome === "won" ? WorkflowPermissions.WIN : WorkflowPermissions.LOSE; if (!hasPermission(user, requiredPerm)) { return fail( `User lacks the "${requiredPerm}" permission required to mark an opportunity as ${payload.outcome}.`, currentStatus, ); } const targetStatus = payload.outcome === "won" ? OpportunityStatus.PendingWon : OpportunityStatus.PendingLost; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const activity = await createWorkflowActivity({ name: `[Workflow] ${payload.outcome === "won" ? "Pending Won" : "Pending Lost"} — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Finalized, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } /** * PendingWon / PendingLost → Active (resurrection / revision) * * Allows an admin (with finalize permission) to send a pending * opportunity back to Active for revision. */ export async function resurrectOpportunity( opportunity: OpportunityController, user: WorkflowUser, payload: ResurrectPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); // Only finalize-permissioned users can resurrect from PendingWon if ( currentStatus === OpportunityStatus.PendingWon && !hasPermission(user, WorkflowPermissions.FINALIZE) ) { return fail( "You do not have permission to send a Pending Won opportunity back for revision.", currentStatus, ); } const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); const targetStatus = OpportunityStatus.Active; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const fromLabel = currentStatus === OpportunityStatus.PendingWon ? "Pending Won" : "Pending Lost"; const activity = await createWorkflowActivity({ name: `[Workflow] Resurrected from ${fromLabel} — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Revision, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } /** * PendingRevision → Active * * Rep begins revising the opportunity after review rejection. */ export async function beginRevision( opportunity: OpportunityController, user: WorkflowUser, payload: BeginRevisionPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const targetStatus = OpportunityStatus.Active; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const activity = await createWorkflowActivity({ name: `[Workflow] Revision started — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note ?? "Revision started after review rejection.", optimaType: OptimaType.Revision, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note ?? "Revision started.", ); return ok(currentStatus, targetStatus, activities); } /** * ConfirmedQuote → PendingRevision * * Sends the opportunity back for revision from ConfirmedQuote. * Requires a mandatory note explaining why. */ export async function sendBackForRevision( opportunity: OpportunityController, user: WorkflowUser, payload: SendBackForRevisionPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); const targetStatus = OpportunityStatus.PendingRevision; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activity = await createWorkflowActivity({ name: `[Workflow] Sent back for revision — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Revision, }); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activity.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, [activity]); } /** * Create a Schedule Entry activity without changing the opportunity status. * * Schedule Entry activities stay open until time is logged against them. */ export async function createScheduleEntry( opportunity: OpportunityController, user: WorkflowUser, payload: CreateScheduleEntryPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); // CW activities require ISO-8601 without milliseconds, e.g. "2026-04-19T20:15:00Z" const toCwDateTime = (iso: string): string => iso.replace(/\.\d+Z$/, "Z"); const dateStart = payload.startTime ? toCwDateTime(payload.startTime) : payload.dueDate ? toCwDateTime(payload.dueDate) : undefined; const dateEnd = payload.endTime ? toCwDateTime(payload.endTime) : undefined; // Find the currently open workflow activity (OpportunitySetup, OpportunityReview, // or Revision) to use as the parent for this schedule entry. let parentActivityCwId: number | null = null; try { const existingActivities = await activityCw.fetchByOpportunityDirect( opportunity.cwOpportunityId, ); for (const raw of existingActivities) { if (raw.status?.id === 2) continue; // already closed const optimaField = raw.customFields?.find( (f: any) => f.id === OptimaType.FIELD_ID, ); if (!optimaField?.value) continue; if (optimaField.value === OptimaType.ScheduleEntry) continue; // skip other schedule entries if (STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) { parentActivityCwId = raw.id; break; } } } catch (err) { // Non-fatal — schedule entry will be created without a parent console.warn( `[Workflow:ScheduleEntry] Could not resolve parent activity: ${err}`, ); } const activity = await ActivityController.create({ name: `[Schedule Entry] ${payload.activityTypeValue} — ${opportunity.name}`, type: { id: 3 }, // HistoricEntry opportunity: { id: opportunity.cwOpportunityId }, ...(opportunity.companyCwId ? { company: { id: opportunity.companyCwId } } : {}), assignTo: { id: user.cwMemberId }, notes: payload.note ?? "", ...(dateStart ? { dateStart } : {}), ...(dateEnd ? { dateEnd } : {}), }); // Build custom fields: always Optima_Type, plus Parent_Activity when resolved const customFields: any[] = [ { id: OptimaType.FIELD_ID, caption: "Optima_Type", type: "Text", entryMethod: "List", numberOfDecimals: 0, value: OptimaType.ScheduleEntry, }, ]; if (parentActivityCwId != null) { customFields.push({ id: PARENT_ACTIVITY_FIELD_ID, caption: "Parent_Activity", type: "Text", entryMethod: "EntryField", numberOfDecimals: 0, value: String(parentActivityCwId), }); } // Set Optima_Type (+ Parent_Activity) on the new schedule entry await activity.update([ { op: "replace", path: "customFields", value: customFields, }, ]); // Return a no-transition result (status unchanged) return { success: true, previousStatusId: currentStatus, newStatusId: currentStatus, previousStatus: StatusIdToKey[currentStatus] ?? null, newStatus: StatusIdToKey[currentStatus] ?? null, activitiesCreated: [activity], coldCheck: null, error: null, }; } /** * Any cancelable status → Canceled * * Requires `sales.opportunity.cancel` permission. */ export async function cancelOpportunity( opportunity: OpportunityController, user: WorkflowUser, payload: CancelPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); if (!hasPermission(user, WorkflowPermissions.CANCEL)) { return fail( `User lacks the "${WorkflowPermissions.CANCEL}" permission required to cancel an opportunity.`, currentStatus, ); } const targetStatus = OpportunityStatus.Canceled; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const activity = await createWorkflowActivity({ name: `[Workflow] Canceled — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Finalized, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } /** * Canceled → Active (re-open) * * Dedicated re-open action. Creates an audit trail activity with a * required note explaining why the opportunity is being re-opened. */ export async function reopenCancelledOpportunity( opportunity: OpportunityController, user: WorkflowUser, payload: ReopenPayload, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const noteErr = assertNotePresent(payload.note); if (noteErr) return fail(noteErr, currentStatus); if (!hasPermission(user, WorkflowPermissions.REOPEN)) { return fail( `User lacks the "${WorkflowPermissions.REOPEN}" permission required to re-open a cancelled opportunity.`, currentStatus, ); } const targetStatus = OpportunityStatus.Active; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) return fail(transErr, currentStatus); const activities: ActivityController[] = []; const activity = await createWorkflowActivity({ name: `[Workflow] Re-opened from Canceled — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: user.cwMemberId, notes: payload.note, optimaType: OptimaType.Revision, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); await handleTimeEntry( activities[0]?.cwActivityId, user.cwMemberId, payload, payload.note, ); return ok(currentStatus, targetStatus, activities); } /** * Cold detection automation trigger. * * → InternalReview (system-generated) * * Called by automation/scheduler, not by a user action. * Evaluates the cold threshold and, if cold, transitions the * opportunity to InternalReview with a system-generated activity. */ export async function triggerColdDetection( opportunity: OpportunityController, lastActivityDate: Date | null, ): Promise { const currentStatus = opportunity.statusCwId; if (currentStatus == null) return fail("Opportunity has no current status."); const coldResult = checkColdStatus({ statusCwId: currentStatus, lastActivityDate, }); if (!coldResult.cold) { return { success: true, previousStatusId: currentStatus, newStatusId: currentStatus, previousStatus: StatusIdToKey[currentStatus] ?? null, newStatus: StatusIdToKey[currentStatus] ?? null, activitiesCreated: [], coldCheck: coldResult, error: null, }; } const targetStatus = OpportunityStatus.InternalReview; const transErr = assertTransitionAllowed(currentStatus, targetStatus); if (transErr) { return { ...fail(transErr, currentStatus), coldCheck: coldResult, }; } const activities: ActivityController[] = []; // System-generated activity — no user assignment, use a system marker. // We use assignTo with a placeholder; the caller should provide a // system member ID when integrating. const trigger = coldResult.triggeredBy!; const activity = await createWorkflowActivity({ name: `[Workflow][Auto] Cold detected — ${opportunity.name}`, opportunityCwId: opportunity.cwOpportunityId, companyCwId: opportunity.companyCwId, assignToCwMemberId: 0, // system — will be resolved by caller notes: `Opportunity went cold. Status "${trigger.statusName}" exceeded ` + `${trigger.thresholdDays}-day threshold (stale for ${trigger.staleDays} days). ` + `Automatically moved to Internal Review.`, optimaType: OptimaType.OpportunityReview, }); activities.push(activity); await syncOpportunityStatus({ opportunityId: opportunity.cwOpportunityId, statusCwId: targetStatus, }); return { ...ok(currentStatus, targetStatus, activities), coldCheck: coldResult, }; } // ═══════════════════════════════════════════════════════════════════════════ // CLOSE OPEN WORKFLOW ACTIVITIES // ═══════════════════════════════════════════════════════════════════════════ /** * Close any open workflow activities for this opportunity. * * Called at the start of every workflow transition so that activities * marked as "stays open" (Opportunity Setup, Opportunity Review, Revision) * are closed when the opportunity moves to the next stage. */ async function closeOpenWorkflowActivities( opportunityCwId: number, ): Promise { try { const activities = await activityCw.fetchByOpportunityDirect(opportunityCwId); for (const raw of activities) { // Only close Open activities (status id !== 2) if (raw.status?.id === 2) continue; // Only close activities that have a workflow Optima_Type custom field const optimaField = raw.customFields?.find( (f: any) => f.id === OptimaType.FIELD_ID, ); if (!optimaField?.value) continue; // Only close activities whose type is in the stays-open set if (!STAYS_OPEN_TYPES.has(optimaField.value as OptimaTypeValue)) continue; // Never auto-close Schedule Entry activities — they close only when time is logged if (optimaField.value === OptimaType.ScheduleEntry) continue; const closeDate = new Date().toISOString(); const existingFields = (raw.customFields ?? []).map((f: any) => f.id === CLOSE_DATE_FIELD_ID ? { ...f, value: closeDate } : f, ); // Add Close Date if it wasn't already present if (!existingFields.some((f: any) => f.id === CLOSE_DATE_FIELD_ID)) { existingFields.push({ id: CLOSE_DATE_FIELD_ID, caption: "Close Date", type: "Text", entryMethod: "Date", numberOfDecimals: 0, value: closeDate, }); } const controller = new ActivityController(raw); await controller.update([ { op: "replace", path: "status", value: { id: 2 } }, { op: "replace", path: "customFields", value: existingFields }, ]); } } catch (err) { console.error( `[Workflow] Failed to close open activities for opportunity ${opportunityCwId}:`, err, ); // Non-fatal — don't block the transition } } // ═══════════════════════════════════════════════════════════════════════════ // MASTER DISPATCHER // ═══════════════════════════════════════════════════════════════════════════ /** * Process an opportunity workflow action. * * This is the single entry point for all workflow transitions. It: * 1. Validates the opportunity stage is "Optima". * 2. Validates the opportunity is not in a terminal status. * 3. Routes the action to the correct transition function. * 4. On success, refreshes the opportunity from CW to keep * the local database in sync. * * @param opportunity - The OpportunityController instance to act on. * @param action - The discriminated action union. * @param user - The authenticated user performing the action. */ export async function processOpportunityAction( opportunity: OpportunityController, { action, payload }: WorkflowAction, user: WorkflowUser, ): Promise { // ── Stage gate ────────────────────────────────────────────────────── const stageErr = assertOptimaStage(opportunity); if (stageErr) return fail(stageErr, opportunity.statusCwId); // ── Terminal gate (except reopen which starts from Canceled) ──────── if (action !== "reopen") { const termErr = assertNotTerminal(opportunity.statusCwId); if (termErr) return fail(termErr, opportunity.statusCwId); } // ── Close any open workflow activities from previous stage ────────── // Skip for createScheduleEntry — we intentionally preserve open activities. if (action !== "createScheduleEntry") { await closeOpenWorkflowActivities(opportunity.cwOpportunityId); } // ── Route to transition function ──────────────────────────────────── let result: WorkflowResult; switch (action) { case "acceptNew": result = await transitionToNew(opportunity, user, payload); break; case "requestReview": result = await transitionToInternalReview(opportunity, user, payload); break; case "reviewDecision": result = await handleReviewDecision(opportunity, user, payload); break; case "markReadyToSend": result = await transitionToReadyToSend(opportunity, user, payload); break; case "sendQuote": result = await transitionToQuoteSent(opportunity, user, payload); break; case "confirmQuote": result = await transitionToConfirmedQuote(opportunity, user, payload); break; case "finalize": { // If user has finalize permission, go directly to Won/Lost. // Otherwise, go to PendingWon/PendingLost. result = hasPermission(user, WorkflowPermissions.FINALIZE) ? await finalizeOpportunity(opportunity, user, payload) : await transitionToPending(opportunity, user, payload); break; } case "resurrect": result = await resurrectOpportunity(opportunity, user, payload); break; case "beginRevision": result = await beginRevision(opportunity, user, payload); break; case "resendQuote": result = await transitionToQuoteSent(opportunity, user, payload); break; case "cancel": result = await cancelOpportunity(opportunity, user, payload); break; case "reopen": result = await reopenCancelledOpportunity(opportunity, user, payload); break; case "sendBackForRevision": result = await sendBackForRevision(opportunity, user, payload); break; case "createScheduleEntry": // Schedule Entry does not close open activities — skip that step. // We call it directly rather than falling through closeOpenWorkflowActivities. result = await createScheduleEntry(opportunity, user, payload); break; default: { const _exhaustive: never = action; return fail(`Unknown workflow action: "${_exhaustive}"`); } } // ── Post-transition: refresh opportunity from CW → local DB ──────── if (result.success) { try { await opportunity.refreshFromCW(); } catch (refreshErr) { console.error( "[Workflow] Failed to refresh opportunity from CW after transition:", refreshErr, ); // Don't fail the workflow — the transition itself succeeded. } } return result; }