Files
optima/api/src/workflows/wf.opportunity.ts
T
HoloPanio 38654601c9 feat: schedule entries, add time modal, and proxy routes
- Add CreateScheduleEntryModal with pill-based type selector and split date/time inputs
- Rewrite AddTimeModal to self-fetch activities with loading/empty states
- Empty state offers 'Create Schedule Entry' shortcut when no open activities
- Add SvelteKit proxy routes for /activities and /time endpoints
- Split datetime-local inputs into separate date+time fields across modals
- Fix CW date format: strip milliseconds from ISO strings (keep Z)
- Add ScheduleEntry to workflow history type whitelist
- Show open schedule entries panel in WorkflowPanel
- Auto-refresh ActivityTab after workflow actions
- Reduce activity dot size and fix connector width overflow
- Hide creation date row for Schedule Entry activities in timeline
2026-04-19 01:26:29 +00:00

2036 lines
67 KiB
TypeScript

/**
* @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<number, OpportunityStatusKey> =
Object.fromEntries(
Object.entries(OpportunityStatus).map(([k, v]) => [
v,
k as OpportunityStatusKey,
]),
) as Record<number, OpportunityStatusKey>;
/** Terminal (immutable) statuses. */
const TERMINAL_STATUSES = new Set<number>([
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<OptimaTypeValue>([
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<number, Set<number>> = {
[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.
*/
function buildCustomFields(
optimaType: OptimaTypeValue,
opts?: { quoteId?: string; closeDate?: string },
) {
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,
});
}
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;
}): Promise<ActivityController> {
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,
}),
},
];
if (!shouldStayOpen) {
patchOps.push({
op: "replace",
path: "status",
value: { id: 2 }, // Closed
});
}
const patched = await activity.update(patchOps);
return patched;
}
/**
* 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<void> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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 → PendingRevision ────────────────────────────
if (payload.needsRevision) {
const targetStatus = OpportunityStatus.PendingRevision;
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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;
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 } : {}),
});
// Set Optima_Type to Schedule Entry (stays open)
await activity.update([
{
op: "replace",
path: "customFields",
value: [
{
id: OptimaType.FIELD_ID,
caption: "Optima_Type",
type: "Text",
entryMethod: "List",
numberOfDecimals: 0,
value: OptimaType.ScheduleEntry,
},
],
},
]);
// 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<WorkflowResult> {
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<WorkflowResult> {
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<WorkflowResult> {
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<void> {
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<WorkflowResult> {
// ── 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;
}