feat: add opportunity workflows, delete routes, company sites, algorithms, and expanded test coverage

This commit is contained in:
2026-03-09 02:56:08 -05:00
parent c0a4d4f919
commit f53b390e18
50 changed files with 8837 additions and 63 deletions
+10
View File
@@ -5,6 +5,7 @@ import { default as count } from "./opportunities/count";
import { default as fetch } from "./opportunities/[id]/fetch";
import { default as refresh } from "./opportunities/[id]/refresh";
import { default as updateOpportunity } from "./opportunities/[id]/update";
import { default as deleteOpportunity } from "./opportunities/[id]/delete";
import { default as products } from "./opportunities/[id]/products/fetchAll";
import { default as addProduct } from "./opportunities/[id]/products/add";
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
@@ -13,6 +14,7 @@ import { default as laborOptions } from "./opportunities/[id]/products/laborOpti
import { default as resequenceProducts } from "./opportunities/[id]/products/resequence";
import { default as updateProduct } from "./opportunities/[id]/products/update";
import { default as cancelProduct } from "./opportunities/[id]/products/cancel";
import { default as deleteProduct } from "./opportunities/[id]/products/delete";
import { default as notes } from "./opportunities/[id]/notes/fetchAll";
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
import { default as createNote } from "./opportunities/[id]/notes/create";
@@ -24,6 +26,9 @@ import { default as fetchQuotes } from "./opportunities/[id]/quotes/fetchAll";
import { default as previewQuote } from "./opportunities/[id]/quotes/preview";
import { default as downloadQuote } from "./opportunities/[id]/quotes/download";
import { default as fetchDownloads } from "./opportunities/[id]/quotes/fetchDownloads";
import { default as workflowDispatch } from "./opportunities/[id]/workflow/dispatch";
import { default as workflowStatus } from "./opportunities/[id]/workflow/status";
import { default as workflowHistory } from "./opportunities/[id]/workflow/history";
export {
addProduct,
@@ -32,6 +37,7 @@ export {
addSpecialOrderProduct,
count,
createOpportunity,
deleteOpportunity,
fetch,
fetchAll,
fetchOpportunityTypes,
@@ -39,6 +45,7 @@ export {
resequenceProducts,
updateProduct,
cancelProduct,
deleteProduct,
notes,
fetchNote,
createNote,
@@ -52,4 +59,7 @@ export {
fetchDownloads,
refresh,
updateOpportunity,
workflowDispatch,
workflowStatus,
workflowHistory,
};
@@ -0,0 +1,50 @@
import { createRoute } from "../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../managers/opportunities";
import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier */
export default createRoute(
"delete",
["/opportunities/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
try {
await opportunities.deleteItem(identifier);
const response = apiResponse.successful(
"Opportunity deleted successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwMessage: string =
axiosErr.response?.data?.message ??
"Failed to delete the opportunity in ConnectWise";
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseDeleteError",
successful: false,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.delete"] }),
);
@@ -11,6 +11,7 @@ const productItemSchema = z
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
forecastDescription: z.string().optional(),
productDescription: z.string().optional(),
customerDescription: z.string().nullable().optional(),
quantity: z.number().positive().optional(),
status: z.object({ id: z.number().int().positive() }).optional(),
productClass: z.string().optional(),
@@ -54,7 +55,40 @@ export default createRoute(
);
const item = await opportunities.fetchRecord(identifier);
const created = await item.addProducts(gatedItems);
// Strip customerDescription from forecast payloads — CW only accepts
// it on procurement products, not forecast items.
const customerDescriptions = gatedItems.map(
(g: any) => g.customerDescription,
);
const forecastPayloads = gatedItems.map(
({ customerDescription, ...rest }: any) => rest,
);
const created = await item.addProducts(forecastPayloads);
// If any items included customerDescription, patch the linked
// procurement products after creation. This is best-effort since
// newly created forecast items may not have a linked procurement
// product yet.
const procurementUpdates = created
.map((product, idx) => ({
product,
customerDescription: customerDescriptions[idx],
}))
.filter((entry) => entry.customerDescription != null);
if (procurementUpdates.length > 0) {
await Promise.all(
procurementUpdates.map(({ product, customerDescription }) =>
item
.updateProcurementProductByForecastItem(product.cwForecastId, {
customerDescription,
})
.catch(() => null),
),
);
}
const isBatch = Array.isArray(body);
const response = apiResponse.created(
@@ -0,0 +1,72 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { opportunities } from "../../../../../managers/opportunities";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/products/:productId */
export default createRoute(
"delete",
["/opportunities/:identifier/products/:productId"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
if (!Number.isInteger(productId) || productId <= 0) {
throw new GenericError({
status: 400,
name: "InvalidProductId",
message: "productId must be a positive integer",
});
}
const opportunity = await opportunities.fetchRecord(identifier);
// Verify the forecast item exists before attempting deletion
const products = await opportunity.fetchProducts();
const product = products.find((item) => item.cwForecastId === productId);
if (!product) {
throw new GenericError({
status: 404,
name: "ForecastItemNotFound",
message: `Forecast item ${productId} not found on opportunity`,
});
}
try {
await opportunity.deleteProduct(productId);
const response = apiResponse.successful(
"Product deleted from opportunity successfully!",
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
const isAxios =
err != null && typeof err === "object" && "isAxiosError" in err;
if (isAxios) {
const axiosErr = err as any;
const cwStatus: number = axiosErr.response?.status ?? 502;
const cwMessage: string =
axiosErr.response?.data?.message ??
"Failed to delete the product in ConnectWise";
return c.json(
{
status: cwStatus,
message: cwMessage,
error: "ConnectWiseDeleteError",
successful: false,
meta: { timestamp: Date.now() },
},
cwStatus as ContentfulStatusCode,
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.product.delete"] }),
);
@@ -98,12 +98,6 @@ export default createRoute(
if (input.quantity !== undefined) {
forecastPatch.quantity = input.quantity;
}
if (
input.customerDescription !== undefined &&
input.customerDescription !== null
) {
forecastPatch.customerDescription = input.customerDescription;
}
if (input.unitPrice !== undefined) {
forecastPatch.revenue = Number(
(input.unitPrice * effectiveQuantity).toFixed(2),
@@ -4,6 +4,11 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
import { cwMembers } from "../../../../../managers/cwMembers";
import {
createWorkflowActivity,
OptimaType,
} from "../../../../../workflows/wf.opportunity";
const commitQuoteSchema = z
.object({
@@ -29,6 +34,34 @@ export default createRoute(
const quote = await item.commitQuote(opts ?? {}, user);
// Create a workflow activity for the generated quote
try {
let cwMemberId: number | null = null;
if (user.cwIdentifier) {
const cwMember = await cwMembers.fetch(user.cwIdentifier);
cwMemberId = cwMember.cwMemberId;
}
if (cwMemberId) {
await createWorkflowActivity({
name: `[Workflow] Quote generated — ${item.name}`,
opportunityCwId: item.cwOpportunityId,
companyCwId: item.companyCwId,
assignToCwMemberId: cwMemberId,
notes: `Quote "${quote.quoteFileName}" generated.`,
optimaType: OptimaType.QuoteGenerated,
quoteId: quote.id,
});
}
} catch (activityErr) {
console.error(
"[Quote Commit] Failed to create workflow activity:",
activityErr,
);
// Don't fail the quote commit if the activity fails
}
const response = apiResponse.created(
"Quote committed successfully!",
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
@@ -0,0 +1,192 @@
import { z } from "zod";
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import { cwMembers } from "../../../../../managers/cwMembers";
import GenericError from "../../../../../Errors/GenericError";
import {
processOpportunityAction,
type WorkflowAction,
type WorkflowUser,
} from "../../../../../workflows/wf.opportunity";
// ── Zod schemas ───────────────────────────────────────────────────────────
const basePayload = z.object({
note: z.string().optional(),
timeStarted: z.string().datetime().optional(),
timeEnded: z.string().datetime().optional(),
});
const noteRequiredPayload = z.object({
note: z.string().min(1, "A non-empty note is required."),
timeStarted: z.string().datetime().optional(),
timeEnded: z.string().datetime().optional(),
});
const dispatchSchema = z.discriminatedUnion("action", [
z.object({
action: z.literal("acceptNew"),
payload: basePayload,
}),
z.object({
action: z.literal("requestReview"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("reviewDecision"),
payload: noteRequiredPayload.extend({
decision: z.enum(["approve", "reject", "send", "cancel"]),
}),
}),
z.object({
action: z.literal("sendQuote"),
payload: basePayload.extend({
quoteConfirmed: z.boolean().optional(),
won: z.boolean().optional(),
lost: z.boolean().optional(),
finalize: z.boolean().optional(),
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("confirmQuote"),
payload: basePayload,
}),
z.object({
action: z.literal("finalize"),
payload: noteRequiredPayload.extend({
outcome: z.enum(["won", "lost"]),
}),
}),
z.object({
action: z.literal("resurrect"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("beginRevision"),
payload: basePayload,
}),
z.object({
action: z.literal("resendQuote"),
payload: basePayload.extend({
quoteConfirmed: z.boolean().optional(),
won: z.boolean().optional(),
lost: z.boolean().optional(),
finalize: z.boolean().optional(),
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("cancel"),
payload: noteRequiredPayload,
}),
z.object({
action: z.literal("reopen"),
payload: noteRequiredPayload,
}),
]);
// ── Route ─────────────────────────────────────────────────────────────────
/* POST /v1/sales/opportunities/:identifier/workflow */
export default createRoute(
"post",
["/opportunities/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const body = await c.req.json();
console.log(
"[Workflow Dispatch] Raw request body:",
JSON.stringify(body, null, 2),
);
const parsed = dispatchSchema.parse(body);
console.log(
"[Workflow Dispatch] Parsed payload:",
JSON.stringify(parsed.payload, null, 2),
);
const user = c.get("user");
// ── Resolve opportunity ────────────────────────────────────────────
const opportunity = await opportunities.fetchItem(identifier);
// ── Build WorkflowUser ─────────────────────────────────────────────
if (!user.cwIdentifier) {
throw new GenericError({
status: 400,
name: "MissingCwIdentifier",
message:
"Your account is not linked to a ConnectWise member. A CW member association is required to execute workflow actions.",
});
}
const cwMember = await cwMembers.fetch(user.cwIdentifier);
const permissions = await user.readAllPermissions();
const workflowUser: WorkflowUser = {
id: user.id,
cwMemberId: cwMember.cwMemberId,
permissions,
};
// ── Dispatch ───────────────────────────────────────────────────────
const result = await processOpportunityAction(
opportunity,
parsed as WorkflowAction,
workflowUser,
);
if (!result.success) {
console.error(
`[Workflow Dispatch] Transition failed for opportunity "${identifier}":`,
result.error,
);
const response = apiResponse.error(
new GenericError({
status: 422,
name: "WorkflowTransitionFailed",
message: result.error ?? "Workflow action failed.",
}),
);
return c.json(
{
...response,
data: {
previousStatusId: result.previousStatusId,
previousStatus: result.previousStatus,
newStatusId: result.newStatusId,
newStatus: result.newStatus,
},
},
response.status as ContentfulStatusCode,
);
}
const response = apiResponse.successful(
"Workflow action completed successfully.",
{
previousStatusId: result.previousStatusId,
previousStatus: result.previousStatus,
newStatusId: result.newStatusId,
newStatus: result.newStatus,
activitiesCreated: result.activitiesCreated.map((a) => a.toJson()),
coldCheck: result.coldCheck,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err: any) {
console.error("[Workflow Dispatch] Unhandled error:", err);
if (err?.response?.data) {
console.error(
"[Workflow Dispatch] CW response body:",
JSON.stringify(err.response.data, null, 2),
);
}
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.workflow"] }),
);
@@ -0,0 +1,150 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import { activityCw } from "../../../../../modules/cw-utils/activities/activities";
import { ActivityController } from "../../../../../controllers/ActivityController";
import { OptimaType } from "../../../../../workflows/wf.opportunity";
// ═══════════════════════════════════════════════════════════════════════════
// HELPERS
// ═══════════════════════════════════════════════════════════════════════════
const OPTIMA_TYPE_VALUES = new Set<string>([
OptimaType.OpportunityCreated,
OptimaType.OpportunitySetup,
OptimaType.OpportunityReview,
OptimaType.QuoteSent,
OptimaType.QuoteConfirmed,
OptimaType.QuoteSentConfirmed,
OptimaType.QuoteGenerated,
OptimaType.Revision,
OptimaType.Finalized,
OptimaType.Converted,
]);
/** QuoteID custom field ID (matches wf.opportunity.ts QUOTE_ID_FIELD_ID). */
const QUOTE_ID_FIELD_ID = 48;
/** Close Date custom field ID (matches wf.opportunity.ts CLOSE_DATE_FIELD_ID). */
const CLOSE_DATE_FIELD_ID = 49;
/**
* Extract the Optima_Type value from a CW activity's custom fields.
* Returns the string value if present, or null.
*/
function extractOptimaType(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === OptimaType.FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return OPTIMA_TYPE_VALUES.has(field.value) ? field.value : null;
}
/**
* Extract the QuoteID custom field value from a CW activity.
* Returns the string value or null.
*/
function extractQuoteId(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === QUOTE_ID_FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return field.value;
}
/**
* Extract the Close Date custom field value from a CW activity.
* Returns the ISO-8601 string or null.
*/
function extractCloseDate(
customFields: { id: number; value: unknown }[] | undefined,
): string | null {
if (!customFields) return null;
const field = customFields.find((f) => f.id === CLOSE_DATE_FIELD_ID);
if (!field?.value || typeof field.value !== "string") return null;
return field.value;
}
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow/history */
export default createRoute(
"get",
["/opportunities/:identifier/workflow/history"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const filterType = c.req.query("type") ?? null; // optional filter by Optima_Type value
// Resolve the opportunity to get the CW opportunity ID
const opportunity = await opportunities.fetchItem(identifier);
// Fetch all activities for this opportunity from CW
const activitiesCollection = await activityCw.fetchByOpportunity(
opportunity.cwOpportunityId,
);
// Filter to workflow activities (those with a valid Optima_Type)
const workflowActivities: {
activity: ReturnType<ActivityController["toJson"]>;
optimaType: string;
quoteId: string | null;
closed: boolean;
closedAt: string | null;
}[] = [];
for (const [, raw] of activitiesCollection) {
const controller = new ActivityController(raw);
const json = controller.toJson();
const optimaType = extractOptimaType(raw.customFields);
if (!optimaType) continue;
if (filterType && optimaType !== filterType) continue;
const quoteId = extractQuoteId(raw.customFields);
const closed = raw.status?.id === 2;
const closedAt = extractCloseDate(raw.customFields);
workflowActivities.push({
activity: json,
optimaType,
quoteId,
closed,
closedAt,
});
}
// Sort newest first
workflowActivities.sort((a, b) => {
const dateA = new Date(
a.activity.dateEnd ?? a.activity.dateStart ?? 0,
).getTime();
const dateB = new Date(
b.activity.dateEnd ?? b.activity.dateStart ?? 0,
).getTime();
return dateB - dateA;
});
const response = apiResponse.successful(
"Workflow history fetched successfully.",
{
opportunityId: opportunity.id,
cwOpportunityId: opportunity.cwOpportunityId,
totalActivities: workflowActivities.length,
activities: workflowActivities,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
console.error("[Workflow History] Unhandled error:", err);
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
@@ -0,0 +1,377 @@
import { createRoute } from "../../../../../modules/api-utils/createRoute";
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { opportunities } from "../../../../../managers/opportunities";
import {
OpportunityStatus,
StatusIdToKey,
WorkflowPermissions,
type OpportunityStatusKey,
} from "../../../../../workflows/wf.opportunity";
import {
checkColdStatus,
type ColdCheckResult,
} from "../../../../../modules/algorithms/algo.coldThreshold";
// ═══════════════════════════════════════════════════════════════════════════
// ACTION AVAILABILITY MAP
// ═══════════════════════════════════════════════════════════════════════════
/**
* Per-status list of actions the user can invoke.
*
* Each entry describes the action key, a human-readable label, the
* expected target status(es), whether a note is required, and any
* permission gate beyond the base workflow permission.
*/
interface AvailableAction {
action: string;
label: string;
targetStatuses: { key: OpportunityStatusKey; id: number }[];
requiresNote: boolean;
requiresPermission: string | null;
/** Extra payload fields that can/must be provided. */
payloadHints?: Record<string, string>;
}
const ACTION_MAP: Record<number, AvailableAction[]> = {
[OpportunityStatus.PendingNew]: [
{
action: "acceptNew",
label: "Accept / Set Up Opportunity",
targetStatuses: [{ key: "New", id: OpportunityStatus.New }],
requiresNote: false,
requiresPermission: null,
},
],
[OpportunityStatus.New]: [
{
action: "sendQuote",
label: "Send Quote (skip review)",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
{
action: "requestReview",
label: "Send to Internal Review",
targetStatuses: [
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
],
requiresNote: true,
requiresPermission: null,
},
{
action: "cancel",
label: "Cancel Opportunity",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
},
],
[OpportunityStatus.InternalReview]: [
{
action: "reviewDecision",
label: "Approve (move to Pending Sent)",
targetStatuses: [
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"approve"' },
},
{
action: "reviewDecision",
label: "Reject (move to Pending Revision)",
targetStatuses: [
{ key: "PendingRevision", id: OpportunityStatus.PendingRevision },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"reject"' },
},
{
action: "reviewDecision",
label: "Send Quote (reviewer sends directly)",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: true,
requiresPermission: null,
payloadHints: { decision: '"send"' },
},
{
action: "reviewDecision",
label: "Cancel from Review",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
payloadHints: { decision: '"cancel"' },
},
],
[OpportunityStatus.PendingSent]: [
{
action: "sendQuote",
label: "Send Quote to Customer",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
needsRevision: "boolean — needs revision",
},
},
],
[OpportunityStatus.PendingRevision]: [
{
action: "beginRevision",
label: "Begin Revision",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
},
],
[OpportunityStatus.QuoteSent]: [
{
action: "confirmQuote",
label: "Confirm Quote Receipt",
targetStatuses: [
{ key: "ConfirmedQuote", id: OpportunityStatus.ConfirmedQuote },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "finalize",
label: "Mark as Won",
targetStatuses: [
{ key: "Won", id: OpportunityStatus.Won },
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"won"' },
},
{
action: "finalize",
label: "Mark as Lost",
targetStatuses: [
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"lost"' },
},
{
action: "resendQuote",
label: "Revise & Re-send (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
payloadHints: { needsRevision: "true" },
},
],
[OpportunityStatus.ConfirmedQuote]: [
{
action: "finalize",
label: "Mark as Won",
targetStatuses: [
{ key: "Won", id: OpportunityStatus.Won },
{ key: "PendingWon", id: OpportunityStatus.PendingWon },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"won"' },
},
{
action: "finalize",
label: "Mark as Lost",
targetStatuses: [
{ key: "PendingLost", id: OpportunityStatus.PendingLost },
],
requiresNote: true,
requiresPermission: null,
payloadHints: { outcome: '"lost"' },
},
{
action: "resendQuote",
label: "Revise & Re-send (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: false,
requiresPermission: null,
payloadHints: { needsRevision: "true" },
},
],
[OpportunityStatus.Active]: [
{
action: "resendQuote",
label: "Send Revised Quote",
targetStatuses: [{ key: "QuoteSent", id: OpportunityStatus.QuoteSent }],
requiresNote: false,
requiresPermission: null,
payloadHints: {
quoteConfirmed: "boolean — mark confirmed simultaneously",
won: "boolean — immediate win",
lost: "boolean — immediate rejection",
},
},
{
action: "requestReview",
label: "Send to Internal Review",
targetStatuses: [
{ key: "InternalReview", id: OpportunityStatus.InternalReview },
],
requiresNote: true,
requiresPermission: null,
},
{
action: "cancel",
label: "Cancel Opportunity",
targetStatuses: [{ key: "Canceled", id: OpportunityStatus.Canceled }],
requiresNote: true,
requiresPermission: WorkflowPermissions.CANCEL,
},
],
[OpportunityStatus.PendingWon]: [
{
action: "finalize",
label: "Approve Won",
targetStatuses: [{ key: "Won", id: OpportunityStatus.Won }],
requiresNote: true,
requiresPermission: WorkflowPermissions.FINALIZE,
payloadHints: { outcome: '"won"' },
},
],
[OpportunityStatus.PendingLost]: [
{
action: "finalize",
label: "Approve Lost",
targetStatuses: [{ key: "Lost", id: OpportunityStatus.Lost }],
requiresNote: true,
requiresPermission: WorkflowPermissions.FINALIZE,
payloadHints: { outcome: '"lost"' },
},
{
action: "resurrect",
label: "Resurrect (back to Active)",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: true,
requiresPermission: null,
},
],
[OpportunityStatus.Won]: [],
[OpportunityStatus.Lost]: [],
[OpportunityStatus.Canceled]: [
{
action: "reopen",
label: "Re-open Opportunity",
targetStatuses: [{ key: "Active", id: OpportunityStatus.Active }],
requiresNote: true,
requiresPermission: null,
},
],
};
// ═══════════════════════════════════════════════════════════════════════════
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow */
export default createRoute(
"get",
["/opportunities/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
const user = c.get("user");
const opportunity = await opportunities.fetchItem(identifier);
const statusCwId = opportunity.statusCwId;
const statusKey: OpportunityStatusKey | null =
statusCwId != null ? (StatusIdToKey[statusCwId] ?? null) : null;
const isOptimaStage = opportunity.stageName === "Optima";
const isTerminal =
statusCwId === OpportunityStatus.Won ||
statusCwId === OpportunityStatus.Lost;
// ── Resolve available actions (permission-aware) ──────────────────
const rawActions =
statusCwId != null ? (ACTION_MAP[statusCwId] ?? []) : [];
const resolvedActions = await Promise.all(
rawActions.map(async (a) => {
const hasGate =
!a.requiresPermission ||
(await user.hasPermission(a.requiresPermission));
return { ...a, permitted: hasGate };
}),
);
// ── Cold check (only for QuoteSent / ConfirmedQuote) ──────────────
let coldCheck: ColdCheckResult | null = null;
if (
statusCwId === OpportunityStatus.QuoteSent ||
statusCwId === OpportunityStatus.ConfirmedQuote
) {
// Fetch activities to determine latest activity date
const activities = await opportunity.fetchActivities();
const latestDate =
activities.length > 0
? new Date(
Math.max(
...activities.map((a) => {
const json = a.toJson();
return new Date(
json.dateEnd ?? json.dateStart ?? 0,
).getTime();
}),
),
)
: null;
coldCheck = checkColdStatus({
statusCwId,
lastActivityDate: latestDate,
});
}
const response = apiResponse.successful(
"Workflow status fetched successfully.",
{
currentStatusId: statusCwId,
currentStatus: statusKey,
stageName: opportunity.stageName ?? null,
isOptimaStage,
isTerminal,
availableActions: isOptimaStage ? resolvedActions : [],
coldCheck,
},
);
return c.json(response, response.status as ContentfulStatusCode);
} catch (err) {
console.error("[Workflow Status] Unhandled error:", err);
throw err;
}
},
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
);
+33
View File
@@ -5,6 +5,11 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../middleware/authorization";
import GenericError from "../../../Errors/GenericError";
import { z } from "zod";
import { cwMembers } from "../../../managers/cwMembers";
import {
createWorkflowActivity,
OptimaType,
} from "../../../workflows/wf.opportunity";
const createSchema = z.object({
name: z.string().min(1),
@@ -41,6 +46,34 @@ export default createRoute(
try {
const item = await opportunities.createItem(data);
// Create a workflow activity for the new opportunity
try {
const user = c.get("user");
let cwMemberId: number | null = null;
if (user.cwIdentifier) {
const cwMember = await cwMembers.fetch(user.cwIdentifier);
cwMemberId = cwMember.cwMemberId;
}
if (cwMemberId) {
await createWorkflowActivity({
name: `[Workflow] Opportunity created — ${item.name}`,
opportunityCwId: item.cwOpportunityId,
companyCwId: item.companyCwId,
assignToCwMemberId: cwMemberId,
notes: "Opportunity created.",
optimaType: OptimaType.OpportunityCreated,
});
}
} catch (activityErr) {
console.error(
"[Opportunity Create] Failed to create workflow activity:",
activityErr,
);
// Don't fail the opportunity creation if the activity fails
}
const response = apiResponse.created(
"Opportunity created successfully!",
item.toJson(),