feat: restructure sales, add PDF quote generation and WebSocket support
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/contacts"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const data = await item.fetchContacts();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity contacts fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,187 @@
|
||||
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 { processObjectValuePerms } from "../../../../modules/permission-utils/processObjectPermissions";
|
||||
import GenericError from "../../../../Errors/GenericError";
|
||||
import { prisma } from "../../../../constants";
|
||||
import { computeSubResourceCacheTTL } from "../../../../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../../../../modules/algorithms/computeProductsCacheTTL";
|
||||
import {
|
||||
getCachedSite,
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
getCachedProducts,
|
||||
fetchAndCacheNotes,
|
||||
fetchAndCacheContacts,
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
} from "../../../../modules/cache/opportunityCache";
|
||||
import { generatedQuotes } from "../../../../managers/generatedQuotes";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeParam = c.req.query("include") ?? "";
|
||||
const includes = new Set(
|
||||
includeParam
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
|
||||
// ── Quick DB lookup (≈3ms) to get cwOpportunityId for pre-warming ──
|
||||
const isNumeric = /^\d+$/.test(identifier);
|
||||
const dbRecord = await prisma.opportunity.findFirst({
|
||||
where: isNumeric
|
||||
? { cwOpportunityId: Number(identifier) }
|
||||
: { id: identifier },
|
||||
select: {
|
||||
cwOpportunityId: true,
|
||||
companyCwId: true,
|
||||
siteCwId: true,
|
||||
closedFlag: true,
|
||||
closedDate: true,
|
||||
expectedCloseDate: true,
|
||||
cwLastUpdated: true,
|
||||
statusCwId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!dbRecord) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with identifier '${identifier}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
// Compute TTLs from DB state
|
||||
const subTtl = computeSubResourceCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
});
|
||||
const prodTtl = computeProductsCacheTTL({
|
||||
closedFlag: dbRecord.closedFlag,
|
||||
closedDate: dbRecord.closedDate,
|
||||
expectedCloseDate: dbRecord.expectedCloseDate,
|
||||
lastUpdated: dbRecord.cwLastUpdated,
|
||||
statusCwId: dbRecord.statusCwId,
|
||||
});
|
||||
|
||||
// ── Pre-warm sub-resources only on cache miss ───────────────────────
|
||||
// Check Redis first — if the background refresh has kept the keys warm,
|
||||
// skip the CW calls entirely. Only fetch-and-cache on a miss.
|
||||
const cwOppId = dbRecord.cwOpportunityId;
|
||||
const _ignoreErrors = (p: Promise<any>) => p.catch(() => {});
|
||||
|
||||
const prewarmPromises: Promise<any>[] = [];
|
||||
if (dbRecord.companyCwId && dbRecord.siteCwId) {
|
||||
const compId = dbRecord.companyCwId,
|
||||
siteId = dbRecord.siteCwId;
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedSite(compId, siteId).then(
|
||||
(c) => c ?? fetchAndCacheSite(compId, siteId),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (includes.has("notes") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedNotes(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheNotes(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("contacts") && subTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedContacts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheContacts(cwOppId, subTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (includes.has("products") && prodTtl)
|
||||
prewarmPromises.push(
|
||||
_ignoreErrors(
|
||||
getCachedProducts(cwOppId).then(
|
||||
(c) => c ?? fetchAndCacheProducts(cwOppId, prodTtl),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// fetchItem runs its own CW calls (opp, activities, company) —
|
||||
// these execute concurrently with the sub-resource pre-warming above.
|
||||
const [item] = await Promise.all([
|
||||
opportunities.fetchItem(identifier),
|
||||
...prewarmPromises,
|
||||
]);
|
||||
|
||||
// Sub-resources now hit warm Redis cache (near-instant)
|
||||
const subResourcePromises: Record<string, Promise<any>> = {
|
||||
_site: item.fetchSite(),
|
||||
};
|
||||
if (includes.has("notes")) {
|
||||
subResourcePromises.notes = item.fetchNotes();
|
||||
}
|
||||
if (includes.has("contacts")) {
|
||||
subResourcePromises.contacts = item.fetchContacts();
|
||||
}
|
||||
if (includes.has("products")) {
|
||||
subResourcePromises.products = item
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson()));
|
||||
}
|
||||
if (includes.has("quotes")) {
|
||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||
subResourcePromises.quotes = generatedQuotes
|
||||
.fetchByOpportunity(item.id)
|
||||
.then((quotes) =>
|
||||
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||
);
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
|
||||
|
||||
// Apply toJson after site is hydrated (side-effect from fetchSite)
|
||||
const gatedData = await processObjectValuePerms(
|
||||
item.toJson(),
|
||||
"obj.opportunity",
|
||||
c.get("user"),
|
||||
);
|
||||
|
||||
const originalOpportunityNoteText = (gatedData as any).notes;
|
||||
|
||||
// Attach sub-resources (skip the internal _site key)
|
||||
keys.forEach((k, i) => {
|
||||
if (k !== "_site") {
|
||||
(gatedData as any)[k] = results[i];
|
||||
}
|
||||
});
|
||||
|
||||
if (includes.has("notes")) {
|
||||
(gatedData as any).opportunityNoteText =
|
||||
typeof originalOpportunityNoteText === "string"
|
||||
? originalOpportunityNoteText
|
||||
: null;
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity fetched successfully!",
|
||||
gatedData,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,47 @@
|
||||
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 { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
text: z.string().min(1, "Note text is required"),
|
||||
flagged: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const created = await item.addNote(data.text, user.login, {
|
||||
flagged: data.flagged,
|
||||
});
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Opportunity note created successfully!",
|
||||
{
|
||||
id: created.id,
|
||||
text: created.text,
|
||||
type: created.type
|
||||
? { id: created.type.id, name: created.type.name }
|
||||
: null,
|
||||
flagged: created.flagged,
|
||||
enteredBy: await resolveMember(created.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.create"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
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/notes/:noteId */
|
||||
export default createRoute(
|
||||
"delete",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
await item.deleteNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note deleted successfully!",
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.delete"] }),
|
||||
);
|
||||
@@ -0,0 +1,34 @@
|
||||
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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const data = await item.fetchNote(noteId);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/notes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const data = await item.fetchNotes();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity notes fetched successfully!",
|
||||
data,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,57 @@
|
||||
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";
|
||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/notes/:noteId"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const noteId = Number(c.req.param("noteId"));
|
||||
|
||||
if (isNaN(noteId))
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidNoteId",
|
||||
message: "Note ID must be a number",
|
||||
});
|
||||
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
text: z.string().min(1).optional(),
|
||||
flagged: z.boolean().optional(),
|
||||
})
|
||||
.refine((d) => d.text !== undefined || d.flagged !== undefined, {
|
||||
message: "At least one of 'text' or 'flagged' must be provided",
|
||||
});
|
||||
|
||||
const data = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const updated = await item.updateNote(noteId, data);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity note updated successfully!",
|
||||
{
|
||||
id: updated.id,
|
||||
text: updated.text,
|
||||
type: updated.type
|
||||
? { id: updated.type.id, name: updated.type.name }
|
||||
: null,
|
||||
flagged: updated.flagged,
|
||||
enteredBy: await resolveMember(updated.enteredBy),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.note.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,69 @@
|
||||
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 { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
|
||||
import { z } from "zod";
|
||||
|
||||
const productItemSchema = z
|
||||
.object({
|
||||
catalogItem: z.object({ id: z.number().int().positive() }).optional(),
|
||||
forecastDescription: z.string().optional(),
|
||||
productDescription: z.string().optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
status: z.object({ id: z.number().int().positive() }).optional(),
|
||||
productClass: z.string().optional(),
|
||||
forecastType: z.string().optional(),
|
||||
revenue: z.number().optional(),
|
||||
cost: z.number().optional(),
|
||||
includeFlag: z.boolean().optional(),
|
||||
linkFlag: z.boolean().optional(),
|
||||
recurringFlag: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
recurringRevenue: z.number().optional(),
|
||||
recurringCost: z.number().optional(),
|
||||
cycles: z.number().int().min(0).optional(),
|
||||
sequenceNumber: z.number().int().min(0).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const addProductSchema = z.union([
|
||||
productItemSchema,
|
||||
z.array(productItemSchema).min(1, "At least one product is required"),
|
||||
]);
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const validated = addProductSchema.parse(body);
|
||||
const inputItems = Array.isArray(validated) ? validated : [validated];
|
||||
|
||||
// Gate each submitted field against user permissions.
|
||||
// Only fields the user has permission for are forwarded to ConnectWise.
|
||||
const user = c.get("user");
|
||||
const gatedItems = await Promise.all(
|
||||
inputItems.map((item) =>
|
||||
processObjectValuePerms(item, "sales.opportunity.product.field", user),
|
||||
),
|
||||
);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const created = await item.addProducts(gatedItems);
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
isBatch
|
||||
? `${created.length} product(s) added to opportunity successfully!`
|
||||
: "Product added to opportunity successfully!",
|
||||
isBatch ? created.map((p) => p.toJson()) : created[0]!.toJson(),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add"] }),
|
||||
);
|
||||
@@ -0,0 +1,147 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const LABOR_DEFAULT_RATE = {
|
||||
corporate: 100,
|
||||
residential: 85,
|
||||
} as const;
|
||||
|
||||
const roundMoney = (value: number) => Math.round(value * 100) / 100;
|
||||
|
||||
const addLaborSchema = z
|
||||
.object({
|
||||
laborStyle: z.enum(["field", "tech"]),
|
||||
customerType: z.enum(["corporate", "residential"]).optional(),
|
||||
hours: z.number().positive().optional(),
|
||||
rate: z.number().min(0).optional(),
|
||||
ppu: z.number().min(0).optional(),
|
||||
cpu: z.number().min(0).optional(),
|
||||
taxable: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
customerDescription: z.string().min(1).optional(),
|
||||
procurementNotes: z.string().optional(),
|
||||
productNarrative: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products/labor */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products/labor"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
const input = addLaborSchema.parse(body);
|
||||
|
||||
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||
const selectedCatalog =
|
||||
input.laborStyle === "tech" ? laborCatalog.tech : laborCatalog.field;
|
||||
|
||||
const customerType = input.customerType ?? "corporate";
|
||||
const defaultRate = LABOR_DEFAULT_RATE[customerType];
|
||||
const quantity = input.hours ?? 1;
|
||||
const ppu = input.ppu ?? input.rate ?? defaultRate;
|
||||
const cpu = input.cpu ?? roundMoney(ppu * 0.5);
|
||||
const taxableFlag =
|
||||
input.taxable ?? input.taxableFlag ?? selectedCatalog.salesTaxable;
|
||||
|
||||
const makeCustomField = (
|
||||
caption: string,
|
||||
value: string,
|
||||
fieldId: number,
|
||||
) => ({
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
...(input.procurementNotes || input.productNarrative
|
||||
? {
|
||||
customFields: [
|
||||
...(input.procurementNotes
|
||||
? [
|
||||
makeCustomField(
|
||||
"Procurement Notes",
|
||||
input.procurementNotes,
|
||||
29,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(input.productNarrative
|
||||
? [
|
||||
makeCustomField(
|
||||
"Product Narrative",
|
||||
input.productNarrative,
|
||||
46,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
catalogItem: { id: selectedCatalog.cwCatalogId },
|
||||
description:
|
||||
input.description ??
|
||||
selectedCatalog.name ??
|
||||
selectedCatalog.identifier ??
|
||||
`${input.laborStyle.toUpperCase()} Labor`,
|
||||
customerDescription: input.customerDescription,
|
||||
quantity,
|
||||
price: ppu,
|
||||
cost: cpu,
|
||||
taxableFlag,
|
||||
dropshipFlag: false,
|
||||
billableOption: "Billable",
|
||||
};
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const [created] = await opportunity.addProcurementProducts(payload);
|
||||
|
||||
const fields = Array.isArray(created?.customFields)
|
||||
? created.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
fields.find((f: any) => f?.id === 29)?.value ?? null;
|
||||
const productNarrative =
|
||||
fields.find((f: any) => f?.id === 46)?.value ?? null;
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Labor added to opportunity successfully!",
|
||||
{
|
||||
id: created?.id ?? null,
|
||||
forecastDetailId: created?.forecastDetailId ?? null,
|
||||
laborStyle: input.laborStyle,
|
||||
customerType,
|
||||
catalogItem: {
|
||||
id: selectedCatalog.cwCatalogId,
|
||||
identifier: selectedCatalog.identifier,
|
||||
name: selectedCatalog.name,
|
||||
},
|
||||
description: created?.description ?? payload.description,
|
||||
customerDescription:
|
||||
created?.customerDescription ?? input.customerDescription ?? null,
|
||||
quantity: created?.quantity ?? quantity,
|
||||
rate: ppu,
|
||||
ppu,
|
||||
cpu,
|
||||
revenue: roundMoney((created?.quantity ?? quantity) * ppu),
|
||||
cost: roundMoney((created?.quantity ?? quantity) * cpu),
|
||||
taxableFlag: created?.taxableFlag ?? taxableFlag,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||
);
|
||||
@@ -0,0 +1,134 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const specialOrderItemSchema = z
|
||||
.object({
|
||||
desc: z.string().min(1),
|
||||
customerDesc: z.string().min(1).optional(),
|
||||
qty: z.number().positive().optional(),
|
||||
price: z.number(),
|
||||
cost: z.number().optional(),
|
||||
taxable: z.boolean().optional(),
|
||||
taxableFlag: z.boolean().optional(),
|
||||
procurementNotes: z.string().optional(),
|
||||
productNarrative: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const addSpecialOrderSchema = z.union([
|
||||
specialOrderItemSchema,
|
||||
z
|
||||
.array(specialOrderItemSchema)
|
||||
.min(1, "At least one special-order product is required"),
|
||||
]);
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/products/special-order */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/products/special-order"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const validated = addSpecialOrderSchema.parse(body);
|
||||
const inputItems = Array.isArray(validated) ? validated : [validated];
|
||||
const specialOrderCatalogItem =
|
||||
await procurement.fetchItem("SPECIAL ORDER");
|
||||
|
||||
const makeCustomField = (
|
||||
caption: string,
|
||||
value: string,
|
||||
fieldId: number,
|
||||
) => ({
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
});
|
||||
|
||||
const normalizedItems = inputItems.map((item) => ({
|
||||
...(item.procurementNotes || item.productNarrative
|
||||
? {
|
||||
customFields: [
|
||||
...(item.procurementNotes
|
||||
? [
|
||||
makeCustomField(
|
||||
"Procurement Notes",
|
||||
item.procurementNotes,
|
||||
29,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(item.productNarrative
|
||||
? [
|
||||
makeCustomField(
|
||||
"Product Narrative",
|
||||
item.productNarrative,
|
||||
46,
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
catalogItem: { id: specialOrderCatalogItem.cwCatalogId },
|
||||
description: item.desc,
|
||||
customerDescription: item.customerDesc,
|
||||
quantity: item.qty ?? 1,
|
||||
price: item.price,
|
||||
cost: item.cost,
|
||||
taxableFlag:
|
||||
item.taxable ??
|
||||
item.taxableFlag ??
|
||||
specialOrderCatalogItem.salesTaxable,
|
||||
dropshipFlag: false,
|
||||
billableOption: "Billable",
|
||||
}));
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const created = await opportunity.addProcurementProducts(normalizedItems);
|
||||
|
||||
const serialized = created.map((item: any) => {
|
||||
const fields = Array.isArray(item?.customFields) ? item.customFields : [];
|
||||
const procurementNotes =
|
||||
fields.find((f: any) => f?.id === 29)?.value ?? null;
|
||||
const productNarrative =
|
||||
fields.find((f: any) => f?.id === 46)?.value ?? null;
|
||||
|
||||
return {
|
||||
id: item?.id ?? null,
|
||||
forecastDetailId: item?.forecastDetailId ?? null,
|
||||
description: item?.description ?? null,
|
||||
productDescription: item?.description ?? null,
|
||||
customerDescription: item?.customerDescription ?? null,
|
||||
quantity: item?.quantity ?? null,
|
||||
price: item?.price ?? null,
|
||||
revenue: item?.price ?? null,
|
||||
cost: item?.cost ?? null,
|
||||
taxableFlag: item?.taxableFlag ?? null,
|
||||
specialOrderFlag: item?.specialOrderFlag ?? null,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
};
|
||||
});
|
||||
|
||||
const isBatch = Array.isArray(body);
|
||||
const response = apiResponse.created(
|
||||
isBatch
|
||||
? `${created.length} special-order product(s) added successfully!`
|
||||
: "Special-order product added successfully!",
|
||||
isBatch ? serialized : serialized[0]!,
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({
|
||||
permissions: ["sales.opportunity.product.add.specialOrder"],
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,84 @@
|
||||
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";
|
||||
import { z } from "zod";
|
||||
|
||||
const cancelProductSchema = z
|
||||
.object({
|
||||
quantityCancelled: z.number().int().min(0),
|
||||
cancellationReason: z.string().nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/cancel */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/:productId/cancel"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const input = cancelProductSchema.parse(body);
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
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`,
|
||||
});
|
||||
}
|
||||
|
||||
const quantity = product.quantity ?? 0;
|
||||
if (input.quantityCancelled > quantity) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidCancelledQuantity",
|
||||
message: `quantityCancelled cannot exceed product quantity (${quantity})`,
|
||||
});
|
||||
}
|
||||
|
||||
await opportunity.setProductCancellation(productId, {
|
||||
quantityCancelled: input.quantityCancelled,
|
||||
cancellationReason: input.cancellationReason,
|
||||
});
|
||||
|
||||
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
|
||||
const updated = refreshedProducts.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiResponse.successful(
|
||||
input.quantityCancelled === 0
|
||||
? "Product uncancelled successfully!"
|
||||
: "Product cancellation updated successfully!",
|
||||
updated.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/products"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const data = await item.fetchProducts();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity products fetched successfully!",
|
||||
data.map((p) => p.toJson()),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,51 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { procurement } from "../../../../../managers/procurement";
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/products/labor/options"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
await opportunities.fetchRecord(identifier);
|
||||
|
||||
const laborCatalog = await procurement.fetchLaborCatalogItems();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Labor product options fetched successfully!",
|
||||
{
|
||||
defaults: {
|
||||
customerType: "corporate",
|
||||
rates: {
|
||||
corporate: 100,
|
||||
residential: 85,
|
||||
},
|
||||
cpuMultiplier: 0.5,
|
||||
quantity: 1,
|
||||
},
|
||||
options: {
|
||||
field: {
|
||||
cwCatalogId: laborCatalog.field.cwCatalogId,
|
||||
identifier: laborCatalog.field.identifier,
|
||||
name: laborCatalog.field.name,
|
||||
taxableFlag: laborCatalog.field.salesTaxable,
|
||||
},
|
||||
tech: {
|
||||
cwCatalogId: laborCatalog.tech.cwCatalogId,
|
||||
identifier: laborCatalog.tech.identifier,
|
||||
name: laborCatalog.tech.name,
|
||||
taxableFlag: laborCatalog.tech.salesTaxable,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.add.labor"] }),
|
||||
);
|
||||
@@ -0,0 +1,37 @@
|
||||
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 { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/sequence"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json();
|
||||
|
||||
const schema = z.object({
|
||||
orderedIds: z
|
||||
.array(z.number().int().positive())
|
||||
.min(1, "At least one forecast item ID is required"),
|
||||
});
|
||||
|
||||
const { orderedIds } = schema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const updated = await item.resequenceProducts(orderedIds);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Product sequence updated successfully!",
|
||||
{
|
||||
products: updated.map((p) => p.toJson()),
|
||||
},
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,242 @@
|
||||
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";
|
||||
import { z } from "zod";
|
||||
|
||||
const PRODUCT_NARRATIVE_FIELD_ID = 46;
|
||||
const PROCUREMENT_NOTES_FIELD_ID = 29;
|
||||
|
||||
const updateProductSchema = z
|
||||
.object({
|
||||
productDescription: z.string().min(1).optional(),
|
||||
quantity: z.number().positive().optional(),
|
||||
unitPrice: z.number().min(0).optional(),
|
||||
unitCost: z.number().min(0).optional(),
|
||||
customerDescription: z.string().nullable().optional(),
|
||||
productNarrative: z.string().nullable().optional(),
|
||||
procurementNotes: z.string().nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) =>
|
||||
Object.values(value).some((item) => item !== undefined && item !== null),
|
||||
"At least one editable field is required",
|
||||
);
|
||||
|
||||
const upsertCustomTextField = (
|
||||
fields: Array<Record<string, unknown>>,
|
||||
fieldId: number,
|
||||
caption: string,
|
||||
value: string,
|
||||
) => {
|
||||
const next = [...fields];
|
||||
const idx = next.findIndex((f) => Number(f.id) === fieldId);
|
||||
|
||||
const field = {
|
||||
id: fieldId,
|
||||
caption,
|
||||
type: "Text",
|
||||
entryMethod: "EntryField",
|
||||
value,
|
||||
};
|
||||
|
||||
if (idx === -1) {
|
||||
next.push(field);
|
||||
return next;
|
||||
}
|
||||
|
||||
next[idx] = {
|
||||
...next[idx],
|
||||
...field,
|
||||
};
|
||||
return next;
|
||||
};
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/:productId/edit */
|
||||
export default createRoute(
|
||||
"patch",
|
||||
["/opportunities/:identifier/products/:productId/edit"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const productId = Number(c.req.param("productId"));
|
||||
const body = await c.req.json();
|
||||
|
||||
if (!Number.isInteger(productId) || productId <= 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidProductId",
|
||||
message: "productId must be a positive integer",
|
||||
});
|
||||
}
|
||||
|
||||
const input = updateProductSchema.parse(body);
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
|
||||
const forecastItems = await opportunity.fetchProducts();
|
||||
const forecastItem = forecastItems.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!forecastItem) {
|
||||
throw new GenericError({
|
||||
status: 404,
|
||||
name: "ForecastItemNotFound",
|
||||
message: `Forecast item ${productId} not found on opportunity`,
|
||||
});
|
||||
}
|
||||
|
||||
const forecastJson = forecastItem.toJson();
|
||||
const effectiveQuantity = input.quantity ?? forecastJson.quantity ?? 1;
|
||||
|
||||
const forecastPatch: Record<string, unknown> = {};
|
||||
if (input.productDescription !== undefined) {
|
||||
forecastPatch.productDescription = input.productDescription;
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
forecastPatch.cost = Number(
|
||||
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
const existingProcurement =
|
||||
await opportunity.fetchProcurementProductByForecastItem(productId);
|
||||
|
||||
if (
|
||||
(input.productNarrative !== undefined ||
|
||||
input.procurementNotes !== undefined) &&
|
||||
!existingProcurement
|
||||
) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "ProcurementLinkRequired",
|
||||
message:
|
||||
"Product Narrative and Procurement Notes can only be updated on products linked to a procurement record",
|
||||
});
|
||||
}
|
||||
|
||||
let updatedProcurement = existingProcurement;
|
||||
if (existingProcurement) {
|
||||
const procurementPatch: Record<string, unknown> = {};
|
||||
if (input.productDescription !== undefined) {
|
||||
procurementPatch.description = input.productDescription;
|
||||
}
|
||||
if (input.quantity !== undefined) {
|
||||
procurementPatch.quantity = input.quantity;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
procurementPatch.price = input.unitPrice;
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
procurementPatch.cost = input.unitCost;
|
||||
}
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
procurementPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
|
||||
const existingFields = Array.isArray(existingProcurement.customFields)
|
||||
? existingProcurement.customFields.map((field) => ({ ...field }))
|
||||
: [];
|
||||
|
||||
let updatedFields = existingFields as Array<Record<string, unknown>>;
|
||||
if (
|
||||
input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PROCUREMENT_NOTES_FIELD_ID,
|
||||
"Procurement Notes",
|
||||
input.procurementNotes,
|
||||
);
|
||||
}
|
||||
if (
|
||||
input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PRODUCT_NARRATIVE_FIELD_ID,
|
||||
"Product Narrative",
|
||||
input.productNarrative,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null) ||
|
||||
(input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null)
|
||||
) {
|
||||
procurementPatch.customFields = updatedFields;
|
||||
}
|
||||
|
||||
if (Object.keys(procurementPatch).length > 0) {
|
||||
updatedProcurement =
|
||||
await opportunity.updateProcurementProductByForecastItem(
|
||||
productId,
|
||||
procurementPatch,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let updatedForecast = forecastJson;
|
||||
if (Object.keys(forecastPatch).length > 0) {
|
||||
const patched = await opportunity.updateProduct(productId, forecastPatch);
|
||||
updatedForecast = patched.toJson();
|
||||
}
|
||||
|
||||
const updatedFields = Array.isArray(updatedProcurement?.customFields)
|
||||
? updatedProcurement.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
const productNarrative =
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
|
||||
const quantity =
|
||||
updatedProcurement?.quantity ?? updatedForecast.quantity ?? null;
|
||||
const unitPrice = updatedProcurement?.price ?? null;
|
||||
const unitCost = updatedProcurement?.cost ?? null;
|
||||
|
||||
const response = apiResponse.successful("Product updated successfully!", {
|
||||
...updatedForecast,
|
||||
productDescription:
|
||||
updatedProcurement?.description ?? updatedForecast.productDescription,
|
||||
customerDescription:
|
||||
updatedProcurement?.customerDescription ??
|
||||
updatedForecast.customerDescription ??
|
||||
null,
|
||||
quantity,
|
||||
unitPrice,
|
||||
unitCost,
|
||||
procurementNotes,
|
||||
productNarrative,
|
||||
});
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.product.update"] }),
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
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 { z } from "zod";
|
||||
|
||||
const commitQuoteSchema = z
|
||||
.object({
|
||||
lineItemPricing: z.boolean().optional(),
|
||||
includeQuoteNarrative: z.boolean().optional(),
|
||||
includeItemNarratives: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/quote/commit */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/quote/commit"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const body = await c.req.json().catch(() => undefined);
|
||||
|
||||
const opts = commitQuoteSchema.parse(body);
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const user = c.get("user");
|
||||
|
||||
const quote = await item.commitQuote(opts ?? {}, user);
|
||||
|
||||
const response = apiResponse.created(
|
||||
"Quote committed successfully!",
|
||||
quote.toJson({ includeRegenData: true, includeRegenParams: true }),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.commit"] }),
|
||||
);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { apiResponse } from "../../../../../modules/api-utils/apiResponse";
|
||||
import { injectPdfMetadata } from "../../../../../modules/pdf-utils";
|
||||
import { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import { authMiddleware } from "../../../../middleware/authorization";
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quote/:quoteId/download"],
|
||||
async (c) => {
|
||||
const quoteId = c.req.param("quoteId");
|
||||
const user = c.get("user");
|
||||
const fetchAction = c.req.query("fetchAction") as FetchAction | undefined;
|
||||
|
||||
if (!fetchAction || !VALID_FETCH_ACTIONS.includes(fetchAction)) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "InvalidFetchAction",
|
||||
message: `Query parameter 'fetchAction' is required and must be one of: ${VALID_FETCH_ACTIONS.join(", ")}`,
|
||||
});
|
||||
}
|
||||
|
||||
const downloadedAt = new Date().toISOString();
|
||||
|
||||
const quote = await generatedQuotes.recordDownload(quoteId, {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
fetchAction,
|
||||
});
|
||||
|
||||
// Inject download-time metadata into the PDF's document properties
|
||||
const pdfWithMetadata = await injectPdfMetadata(quote.quoteFile, {
|
||||
downloadedAt,
|
||||
downloadedById: user.id,
|
||||
downloadedByName: user.name ?? undefined,
|
||||
downloadedByEmail: user.email ?? undefined,
|
||||
});
|
||||
|
||||
const response = apiResponse.successful("Quote downloaded successfully!", {
|
||||
id: quote.id,
|
||||
quoteFileName: quote.quoteFileName,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: Buffer.from(pdfWithMetadata).toString("base64"),
|
||||
});
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.download"] }),
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quotes"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const includeRegenData = c.req.query("includeRegenData") === "true";
|
||||
const includeRegenParams = c.req.query("includeRegenParams") === "true";
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const quotes = await generatedQuotes.fetchByOpportunity(item.id);
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Committed quotes fetched successfully!",
|
||||
quotes.map((q) => q.toJson({ includeRegenData, includeRegenParams })),
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.fetch"] }),
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quotes/downloads"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
|
||||
const opportunity = await opportunities.fetchRecord(identifier);
|
||||
const quotes = await generatedQuotes.fetchByOpportunity(opportunity.id);
|
||||
|
||||
const data = quotes.map((quote) => ({
|
||||
quoteId: quote.id,
|
||||
quoteFileName: quote.quoteFileName,
|
||||
createdById: quote.createdById,
|
||||
createdAt: quote.createdAt,
|
||||
downloads: quote.downloads,
|
||||
}));
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Quote download history fetched successfully!",
|
||||
data,
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.fetch_downloads"] }),
|
||||
);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRoute } from "../../../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../../../managers/opportunities";
|
||||
import { generatedQuotes } from "../../../../../managers/generatedQuotes";
|
||||
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 */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier/quote/:quoteId/preview"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const quoteId = c.req.param("quoteId");
|
||||
|
||||
const item = await opportunities.fetchRecord(identifier);
|
||||
const quote = await generatedQuotes.fetch(quoteId);
|
||||
|
||||
const regenData =
|
||||
quote.quoteRegenData && typeof quote.quoteRegenData === "object"
|
||||
? (quote.quoteRegenData as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const options =
|
||||
regenData.options && typeof regenData.options === "object"
|
||||
? (regenData.options as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const creator = await quote.fetchCreatedBy();
|
||||
|
||||
const previewBuffer = await item.generateQuote({
|
||||
lineItemPricing: options.lineItemPricing as boolean | undefined,
|
||||
includeQuoteNarrative: options.includeQuoteNarrative as
|
||||
| boolean
|
||||
| undefined,
|
||||
includeItemNarratives: options.includeItemNarratives as
|
||||
| boolean
|
||||
| undefined,
|
||||
showPreview: true,
|
||||
metadata: {
|
||||
quoteId: quote.id,
|
||||
createdById: quote.createdById ?? undefined,
|
||||
createdByName: creator?.name ?? undefined,
|
||||
createdByEmail: creator?.email ?? undefined,
|
||||
createdAt: quote.createdAt?.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Quote preview generated successfully!",
|
||||
{
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
},
|
||||
);
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.quote.preview"] }),
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
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";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
||||
export default createRoute(
|
||||
"post",
|
||||
["/opportunities/:identifier/refresh"],
|
||||
async (c) => {
|
||||
const identifier = c.req.param("identifier");
|
||||
const item = await opportunities.fetchItem(identifier);
|
||||
|
||||
const refreshed = await item.refreshFromCW();
|
||||
|
||||
const response = apiResponse.successful(
|
||||
"Opportunity refreshed from ConnectWise successfully!",
|
||||
refreshed.toJson(),
|
||||
);
|
||||
|
||||
return c.json(response, response.status as ContentfulStatusCode);
|
||||
},
|
||||
authMiddleware({ permissions: ["sales.opportunity.refresh"] }),
|
||||
);
|
||||
Reference in New Issue
Block a user