feat: expand sales opportunity workflow and metrics APIs
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user