feat: expand sales opportunity workflow and metrics APIs

This commit is contained in:
2026-03-15 23:38:56 -05:00
parent 33b34d08a7
commit e764932c39
55 changed files with 3425 additions and 157 deletions
+2 -2
View File
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/contacts */
/* GET /v1/sales/opportunities/opportunity/:identifier/contacts */
export default createRoute(
"get",
["/opportunities/:identifier/contacts"],
["/opportunities/opportunity/:identifier/contacts"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
+2 -2
View File
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
import GenericError from "../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier */
/* DELETE /v1/sales/opportunities/opportunity/:identifier */
export default createRoute(
"delete",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
+2 -2
View File
@@ -20,10 +20,10 @@ import {
} from "../../../../modules/cache/opportunityCache";
import { generatedQuotes } from "../../../../managers/generatedQuotes";
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
/* GET /v1/sales/opportunities/opportunity/:identifier?include=notes,contacts,products,quotes */
export default createRoute(
"get",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const includeParam = c.req.query("include") ?? "";
@@ -6,10 +6,10 @@ import { authMiddleware } from "../../../../middleware/authorization";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* POST /v1/sales/opportunities/:identifier/notes */
/* POST /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"post",
["/opportunities/:identifier/notes"],
["/opportunities/opportunity/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
/* DELETE /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"delete",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
/* GET /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"get",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/notes */
/* GET /v1/sales/opportunities/opportunity/:identifier/notes */
export default createRoute(
"get",
["/opportunities/:identifier/notes"],
["/opportunities/opportunity/:identifier/notes"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
@@ -7,10 +7,10 @@ import GenericError from "../../../../../Errors/GenericError";
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/notes/:noteId */
export default createRoute(
"patch",
["/opportunities/:identifier/notes/:noteId"],
["/opportunities/opportunity/:identifier/notes/:noteId"],
async (c) => {
const identifier = c.req.param("identifier");
const noteId = Number(c.req.param("noteId"));
@@ -34,10 +34,10 @@ const addProductSchema = z.union([
z.array(productItemSchema).min(1, "At least one product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products */
/* POST /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"post",
["/opportunities/:identifier/products"],
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -30,10 +30,10 @@ const addLaborSchema = z
})
.strict();
/* POST /v1/sales/opportunities/:identifier/products/labor */
/* POST /v1/sales/opportunities/opportunity/:identifier/products/labor */
export default createRoute(
"post",
["/opportunities/:identifier/products/labor"],
["/opportunities/opportunity/:identifier/products/labor"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -27,10 +27,10 @@ const addSpecialOrderSchema = z.union([
.min(1, "At least one special-order product is required"),
]);
/* POST /v1/sales/opportunities/:identifier/products/special-order */
/* POST /v1/sales/opportunities/opportunity/:identifier/products/special-order */
export default createRoute(
"post",
["/opportunities/:identifier/products/special-order"],
["/opportunities/opportunity/:identifier/products/special-order"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -13,10 +13,10 @@ const cancelProductSchema = z
})
.strict();
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/cancel */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/cancel"],
["/opportunities/opportunity/:identifier/products/:productId/cancel"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import GenericError from "../../../../../Errors/GenericError";
/* DELETE /v1/sales/opportunities/:identifier/products/:productId */
/* DELETE /v1/sales/opportunities/opportunity/:identifier/products/:productId */
export default createRoute(
"delete",
["/opportunities/:identifier/products/:productId"],
["/opportunities/opportunity/:identifier/products/:productId"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products */
/* GET /v1/sales/opportunities/opportunity/:identifier/products */
export default createRoute(
"get",
["/opportunities/:identifier/products"],
["/opportunities/opportunity/:identifier/products"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchRecord(identifier);
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
/* GET /v1/sales/opportunities/opportunity/:identifier/products/labor/options */
export default createRoute(
"get",
["/opportunities/:identifier/products/labor/options"],
["/opportunities/opportunity/:identifier/products/labor/options"],
async (c) => {
const identifier = c.req.param("identifier");
@@ -5,10 +5,10 @@ import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
import { z } from "zod";
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/sequence */
export default createRoute(
"patch",
["/opportunities/:identifier/products/sequence"],
["/opportunities/opportunity/:identifier/products/sequence"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -56,10 +56,10 @@ const upsertCustomTextField = (
return next;
};
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
/* PATCH /v1/sales/opportunities/opportunity/:identifier/products/:productId/edit */
export default createRoute(
"patch",
["/opportunities/:identifier/products/:productId/edit"],
["/opportunities/opportunity/:identifier/products/:productId/edit"],
async (c) => {
const identifier = c.req.param("identifier");
const productId = Number(c.req.param("productId"));
@@ -19,10 +19,10 @@ const commitQuoteSchema = z
.strict()
.optional();
/* POST /v1/sales/opportunities/:identifier/quote/commit */
/* POST /v1/sales/opportunities/opportunity/:identifier/quote/commit */
export default createRoute(
"post",
["/opportunities/:identifier/quote/commit"],
["/opportunities/opportunity/:identifier/quote/commit"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json().catch(() => undefined);
@@ -9,10 +9,10 @@ import GenericError from "../../../../../Errors/GenericError";
const VALID_FETCH_ACTIONS = ["download", "print"] as const;
type FetchAction = (typeof VALID_FETCH_ACTIONS)[number];
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/download?fetchAction=download|print */
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/download?fetchAction=download|print */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/download"],
["/opportunities/opportunity/:identifier/quote/:quoteId/download"],
async (c) => {
const quoteId = c.req.param("quoteId");
const user = c.get("user");
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes */
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes */
export default createRoute(
"get",
["/opportunities/:identifier/quotes"],
["/opportunities/opportunity/:identifier/quotes"],
async (c) => {
const identifier = c.req.param("identifier");
const includeRegenData = c.req.query("includeRegenData") === "true";
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quotes/downloads */
/* GET /v1/sales/opportunities/opportunity/:identifier/quotes/downloads */
export default createRoute(
"get",
["/opportunities/:identifier/quotes/downloads"],
["/opportunities/opportunity/:identifier/quotes/downloads"],
async (c) => {
const identifier = c.req.param("identifier");
@@ -5,10 +5,10 @@ import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../../middleware/authorization";
/* GET /v1/sales/opportunities/:identifier/quote/:quoteId/preview */
/* GET /v1/sales/opportunities/opportunity/:identifier/quote/:quoteId/preview */
export default createRoute(
"get",
["/opportunities/:identifier/quote/:quoteId/preview"],
["/opportunities/opportunity/:identifier/quote/:quoteId/preview"],
async (c) => {
const identifier = c.req.param("identifier");
const quoteId = c.req.param("quoteId");
+2 -2
View File
@@ -4,10 +4,10 @@ import { apiResponse } from "../../../../modules/api-utils/apiResponse";
import { ContentfulStatusCode } from "hono/utils/http-status";
import { authMiddleware } from "../../../middleware/authorization";
/* POST /v1/sales/opportunities/:identifier/refresh */
/* POST /v1/sales/opportunities/opportunity/:identifier/refresh */
export default createRoute(
"post",
["/opportunities/:identifier/refresh"],
["/opportunities/opportunity/:identifier/refresh"],
async (c) => {
const identifier = c.req.param("identifier");
const item = await opportunities.fetchItem(identifier);
+2 -2
View File
@@ -34,10 +34,10 @@ const updateSchema = z
message: "At least one field must be provided",
});
/* PATCH /v1/sales/opportunities/:identifier */
/* PATCH /v1/sales/opportunities/opportunity/:identifier */
export default createRoute(
"patch",
["/opportunities/:identifier"],
["/opportunities/opportunity/:identifier"],
async (c) => {
const identifier = c.req.param("identifier");
const body = await c.req.json();
@@ -51,6 +51,10 @@ const dispatchSchema = z.discriminatedUnion("action", [
needsRevision: z.boolean().optional(),
}),
}),
z.object({
action: z.literal("markReadyToSend"),
payload: basePayload,
}),
z.object({
action: z.literal("confirmQuote"),
payload: basePayload,
@@ -91,10 +95,10 @@ const dispatchSchema = z.discriminatedUnion("action", [
// ── Route ─────────────────────────────────────────────────────────────────
/* POST /v1/sales/opportunities/:identifier/workflow */
/* POST /v1/sales/opportunities/opportunity/:identifier/workflow */
export default createRoute(
"post",
["/opportunities/:identifier/workflow"],
["/opportunities/opportunity/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");
@@ -73,10 +73,10 @@ function extractCloseDate(
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow/history */
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow/history */
export default createRoute(
"get",
["/opportunities/:identifier/workflow/history"],
["/opportunities/opportunity/:identifier/workflow/history"],
async (c) => {
try {
const identifier = c.req.param("identifier");
@@ -47,6 +47,15 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
],
[OpportunityStatus.New]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "sendQuote",
label: "Send Quote (skip review)",
@@ -81,9 +90,9 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
[OpportunityStatus.InternalReview]: [
{
action: "reviewDecision",
label: "Approve (move to Pending Sent)",
label: "Approve (move to Ready to Send)",
targetStatuses: [
{ key: "PendingSent", id: OpportunityStatus.PendingSent },
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: true,
requiresPermission: null,
@@ -117,7 +126,32 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
},
],
[OpportunityStatus.ReadyToSend]: [
{
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.PendingSent]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "sendQuote",
label: "Send Quote to Customer",
@@ -217,6 +251,15 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
],
[OpportunityStatus.Active]: [
{
action: "markReadyToSend",
label: "Mark Ready to Send",
targetStatuses: [
{ key: "ReadyToSend", id: OpportunityStatus.ReadyToSend },
],
requiresNote: false,
requiresPermission: null,
},
{
action: "resendQuote",
label: "Send Revised Quote",
@@ -294,10 +337,10 @@ const ACTION_MAP: Record<number, AvailableAction[]> = {
// ROUTE
// ═══════════════════════════════════════════════════════════════════════════
/* GET /v1/sales/opportunities/:identifier/workflow */
/* GET /v1/sales/opportunities/opportunity/:identifier/workflow */
export default createRoute(
"get",
["/opportunities/:identifier/workflow"],
["/opportunities/opportunity/:identifier/workflow"],
async (c) => {
try {
const identifier = c.req.param("identifier");