feat: restructure sales, add PDF quote generation and WebSocket support
This commit is contained in:
@@ -18,8 +18,9 @@ import {
|
||||
fetchAndCacheProducts,
|
||||
fetchAndCacheSite,
|
||||
} from "../../../modules/cache/opportunityCache";
|
||||
import { generatedQuotes } from "../../../managers/generatedQuotes";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products */
|
||||
/* GET /v1/sales/opportunities/:identifier?include=notes,contacts,products,quotes */
|
||||
export default createRoute(
|
||||
"get",
|
||||
["/opportunities/:identifier"],
|
||||
@@ -140,6 +141,11 @@ export default createRoute(
|
||||
.fetchProducts()
|
||||
.then((products) => products.map((p) => p.toJson()));
|
||||
}
|
||||
if (includes.has("quotes")) {
|
||||
subResourcePromises.quotes = generatedQuotes
|
||||
.fetchByOpportunity(item.id)
|
||||
.then((quotes) => quotes.map((q) => q.toJson()));
|
||||
}
|
||||
|
||||
const keys = Object.keys(subResourcePromises);
|
||||
const results = await Promise.all(keys.map((k) => subResourcePromises[k]));
|
||||
|
||||
+28
-18
@@ -1,22 +1,27 @@
|
||||
import { default as fetchAll } from "./fetchAll";
|
||||
import { default as fetchAll } from "./opportunities/fetchAll";
|
||||
import { default as fetchOpportunityTypes } from "./fetchOpportunityTypes";
|
||||
import { default as count } from "./count";
|
||||
import { default as fetch } from "./[id]/fetch";
|
||||
import { default as refresh } from "./[id]/refresh";
|
||||
import { default as products } from "./[id]/products";
|
||||
import { default as addProduct } from "./[id]/addProduct";
|
||||
import { default as addSpecialOrderProduct } from "./[id]/addSpecialOrderProduct";
|
||||
import { default as addLabor } from "./[id]/addLabor";
|
||||
import { default as laborOptions } from "./[id]/laborOptions";
|
||||
import { default as resequenceProducts } from "./[id]/resequenceProducts";
|
||||
import { default as updateProduct } from "./[id]/updateProduct";
|
||||
import { default as cancelProduct } from "./[id]/cancelProduct";
|
||||
import { default as notes } from "./[id]/notes";
|
||||
import { default as fetchNote } from "./[id]/fetchNote";
|
||||
import { default as createNote } from "./[id]/createNote";
|
||||
import { default as updateNote } from "./[id]/updateNote";
|
||||
import { default as deleteNote } from "./[id]/deleteNote";
|
||||
import { default as contacts } from "./[id]/contacts";
|
||||
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 products } from "./opportunities/[id]/products/fetchAll";
|
||||
import { default as addProduct } from "./opportunities/[id]/products/add";
|
||||
import { default as addSpecialOrderProduct } from "./opportunities/[id]/products/addSpecialOrder";
|
||||
import { default as addLabor } from "./opportunities/[id]/products/addLabor";
|
||||
import { default as laborOptions } from "./opportunities/[id]/products/laborOptions";
|
||||
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 notes } from "./opportunities/[id]/notes/fetchAll";
|
||||
import { default as fetchNote } from "./opportunities/[id]/notes/fetch";
|
||||
import { default as createNote } from "./opportunities/[id]/notes/create";
|
||||
import { default as updateNote } from "./opportunities/[id]/notes/update";
|
||||
import { default as deleteNote } from "./opportunities/[id]/notes/delete";
|
||||
import { default as contacts } from "./opportunities/[id]/contacts";
|
||||
import { default as commitQuote } from "./opportunities/[id]/quotes/commit";
|
||||
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";
|
||||
|
||||
export {
|
||||
addProduct,
|
||||
@@ -37,5 +42,10 @@ export {
|
||||
updateNote,
|
||||
deleteNote,
|
||||
contacts,
|
||||
commitQuote,
|
||||
fetchQuotes,
|
||||
previewQuote,
|
||||
downloadQuote,
|
||||
fetchDownloads,
|
||||
refresh,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/contacts */
|
||||
export default createRoute(
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { resolveMember } from "../../../../../modules/cw-utils/members/memberCache";
|
||||
import { z } from "zod";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/notes */
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* DELETE /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes/:noteId */
|
||||
export default createRoute(
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/notes */
|
||||
export default createRoute(
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { 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 */
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../../../modules/permission-utils/processObjectPermissions";
|
||||
import { z } from "zod";
|
||||
|
||||
const productItemSchema = z
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
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 { 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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const LABOR_DEFAULT_RATE = {
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
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 { 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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
const specialOrderItemSchema = z
|
||||
+8
-6
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const cancelProductSchema = z
|
||||
@@ -59,7 +59,9 @@ export default createRoute(
|
||||
});
|
||||
|
||||
const refreshedProducts = await opportunity.fetchProducts({ fresh: true });
|
||||
const updated = refreshedProducts.find((item) => item.cwForecastId === productId);
|
||||
const updated = refreshedProducts.find(
|
||||
(item) => item.cwForecastId === productId,
|
||||
);
|
||||
|
||||
if (!updated) {
|
||||
throw new GenericError({
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products */
|
||||
export default createRoute(
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
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 { 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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/:identifier/products/labor/options */
|
||||
export default createRoute(
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import { z } from "zod";
|
||||
|
||||
/* PATCH /v1/sales/opportunities/:identifier/products/sequence */
|
||||
+43
-34
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../../middleware/authorization";
|
||||
import GenericError from "../../../../../Errors/GenericError";
|
||||
import { z } from "zod";
|
||||
|
||||
const PRODUCT_NARRATIVE_FIELD_ID = 46;
|
||||
@@ -22,9 +22,7 @@ const updateProductSchema = z
|
||||
.strict()
|
||||
.refine(
|
||||
(value) =>
|
||||
Object.values(value).some(
|
||||
(item) => item !== undefined && item !== null,
|
||||
),
|
||||
Object.values(value).some((item) => item !== undefined && item !== null),
|
||||
"At least one editable field is required",
|
||||
);
|
||||
|
||||
@@ -100,7 +98,10 @@ export default createRoute(
|
||||
if (input.quantity !== undefined) {
|
||||
forecastPatch.quantity = input.quantity;
|
||||
}
|
||||
if (input.customerDescription !== undefined && input.customerDescription !== null) {
|
||||
if (
|
||||
input.customerDescription !== undefined &&
|
||||
input.customerDescription !== null
|
||||
) {
|
||||
forecastPatch.customerDescription = input.customerDescription;
|
||||
}
|
||||
if (input.unitPrice !== undefined) {
|
||||
@@ -109,7 +110,9 @@ export default createRoute(
|
||||
);
|
||||
}
|
||||
if (input.unitCost !== undefined) {
|
||||
forecastPatch.cost = Number((input.unitCost * effectiveQuantity).toFixed(2));
|
||||
forecastPatch.cost = Number(
|
||||
(input.unitCost * effectiveQuantity).toFixed(2),
|
||||
);
|
||||
}
|
||||
|
||||
const existingProcurement =
|
||||
@@ -155,7 +158,10 @@ export default createRoute(
|
||||
: [];
|
||||
|
||||
let updatedFields = existingFields as Array<Record<string, unknown>>;
|
||||
if (input.procurementNotes !== undefined && input.procurementNotes !== null) {
|
||||
if (
|
||||
input.procurementNotes !== undefined &&
|
||||
input.procurementNotes !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PROCUREMENT_NOTES_FIELD_ID,
|
||||
@@ -163,7 +169,10 @@ export default createRoute(
|
||||
input.procurementNotes,
|
||||
);
|
||||
}
|
||||
if (input.productNarrative !== undefined && input.productNarrative !== null) {
|
||||
if (
|
||||
input.productNarrative !== undefined &&
|
||||
input.productNarrative !== null
|
||||
) {
|
||||
updatedFields = upsertCustomTextField(
|
||||
updatedFields,
|
||||
PRODUCT_NARRATIVE_FIELD_ID,
|
||||
@@ -199,33 +208,33 @@ export default createRoute(
|
||||
? updatedProcurement.customFields
|
||||
: [];
|
||||
const procurementNotes =
|
||||
updatedFields.find((field: any) => field?.id === PROCUREMENT_NOTES_FIELD_ID)
|
||||
?.value ?? null;
|
||||
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;
|
||||
updatedFields.find(
|
||||
(field: any) => field?.id === PRODUCT_NARRATIVE_FIELD_ID,
|
||||
)?.value ?? null;
|
||||
|
||||
const quantity = updatedProcurement?.quantity ?? updatedForecast.quantity ?? 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,
|
||||
},
|
||||
);
|
||||
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);
|
||||
},
|
||||
@@ -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"] }),
|
||||
);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
import { apiResponse } from "../../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../../middleware/authorization";
|
||||
|
||||
/* POST /v1/sales/opportunities/:identifier/refresh */
|
||||
export default createRoute(
|
||||
@@ -1,8 +1,8 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../managers/opportunities";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../middleware/authorization";
|
||||
|
||||
/* GET /v1/sales/opportunities/count */
|
||||
export default createRoute(
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createRoute } from "../../modules/api-utils/createRoute";
|
||||
import { opportunities } from "../../managers/opportunities";
|
||||
import { apiResponse } from "../../modules/api-utils/apiResponse";
|
||||
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 { authMiddleware } from "../../middleware/authorization";
|
||||
import { processObjectValuePerms } from "../../../modules/permission-utils/processObjectPermissions";
|
||||
|
||||
/* GET /v1/sales/opportunities */
|
||||
export default createRoute(
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Socket } from "socket.io";
|
||||
import { attachSocketEventPermissions } from "../middleware/authorization";
|
||||
import { opportunities } from "../../../managers/opportunities";
|
||||
|
||||
const LIVE_QUOTE_PREVIEW_PERMISSION = "sales.opportunity.fetch";
|
||||
|
||||
export const registerLiveQuotePreviewHandlers = (socket: Socket) => {
|
||||
attachSocketEventPermissions(socket, {
|
||||
"opp:live_quote_preview": [LIVE_QUOTE_PREVIEW_PERMISSION],
|
||||
});
|
||||
|
||||
const registeredLivePreviewEvents = new Set<string>();
|
||||
|
||||
socket.on(
|
||||
"opp:live_quote_preview",
|
||||
async (
|
||||
payload: { id?: string | number },
|
||||
ack?: (response: { ok: boolean; event?: string; error?: string }) => void,
|
||||
) => {
|
||||
const oppId = payload?.id;
|
||||
const normalizedId =
|
||||
typeof oppId === "string" || typeof oppId === "number"
|
||||
? `${oppId}`
|
||||
: "";
|
||||
|
||||
if (!normalizedId) {
|
||||
if (ack) return ack({ ok: false, error: "Missing opportunity id" });
|
||||
socket.emit("opp:live_quote_preview:error", {
|
||||
message: "Missing opportunity id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const dataEvent = `opp:live_quote_preview:${normalizedId}:data`;
|
||||
const previewEvent = `opp:live_quote_preview:${normalizedId}:preview`;
|
||||
const roomName = `opp:live_quote_preview:${normalizedId}`;
|
||||
|
||||
if (!registeredLivePreviewEvents.has(dataEvent)) {
|
||||
registeredLivePreviewEvents.add(dataEvent);
|
||||
socket.join(roomName);
|
||||
|
||||
socket.on(dataEvent, async (data: any) => {
|
||||
socket.to(roomName).emit(dataEvent, data);
|
||||
|
||||
try {
|
||||
const opportunity = await opportunities.fetchRecord(normalizedId);
|
||||
const opts =
|
||||
data?.options && typeof data.options === "object"
|
||||
? data.options
|
||||
: data;
|
||||
|
||||
const previewBuffer = await opportunity.generateQuote({
|
||||
lineItemPricing: opts?.lineItemPricing,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative,
|
||||
includeItemNarratives: opts?.includeItemNarratives,
|
||||
logoPath: opts?.logoPath,
|
||||
showPreview: true,
|
||||
});
|
||||
|
||||
const previewBase64 = Buffer.from(previewBuffer).toString("base64");
|
||||
|
||||
socket.to(roomName).emit(previewEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.to(roomName).emit(dataEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.emit(previewEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
|
||||
socket.emit(dataEvent, {
|
||||
id: normalizedId,
|
||||
mimeType: "application/pdf",
|
||||
contentBase64: previewBase64,
|
||||
});
|
||||
} catch (err: any) {
|
||||
socket.emit("opp:live_quote_preview:error", {
|
||||
message: err?.message ?? "Failed to generate live quote preview",
|
||||
id: normalizedId,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (ack) return ack({ ok: true, event: dataEvent });
|
||||
|
||||
socket.emit("opp:live_quote_preview:ready", {
|
||||
id: normalizedId,
|
||||
event: dataEvent,
|
||||
});
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { setupSecureNamespace } from "./secure";
|
||||
|
||||
export const setupSockets = () => {
|
||||
setupSecureNamespace();
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Socket } from "socket.io";
|
||||
import UserController from "../../../controllers/UserController";
|
||||
|
||||
type SecureSocket = Socket & {
|
||||
data: {
|
||||
user?: UserController;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export const attachSocketEventPermissions = (
|
||||
socket: Socket,
|
||||
eventPermissions: Record<string, string[]>,
|
||||
): boolean => {
|
||||
const user = (socket.data?.user as UserController | undefined) ?? undefined;
|
||||
if (!user) return false;
|
||||
|
||||
socket.use(async (packet, packetNext) => {
|
||||
const eventName = packet[0];
|
||||
|
||||
if (typeof eventName !== "string") return packetNext();
|
||||
|
||||
const eventRequiredPermissions = eventPermissions[eventName] ?? [];
|
||||
if (eventRequiredPermissions.length === 0) return packetNext();
|
||||
|
||||
const eventChecks = await Promise.all(
|
||||
eventRequiredPermissions.map((permission) =>
|
||||
user.hasPermission(permission),
|
||||
),
|
||||
);
|
||||
|
||||
if (eventChecks.includes(false)) {
|
||||
return packetNext(new Error("Forbidden: insufficient permissions"));
|
||||
}
|
||||
|
||||
return packetNext();
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const socketAuthMiddleware = (permParams?: {
|
||||
permissions?: string[];
|
||||
eventPermissions?: Record<string, string[]>;
|
||||
}) => {
|
||||
return async (socket: SecureSocket, next: (err?: Error) => void) => {
|
||||
const user = socket.data.user;
|
||||
if (!user) return next(new Error("Unauthorized"));
|
||||
|
||||
const requiredPermissions = permParams?.permissions ?? [];
|
||||
|
||||
if (requiredPermissions.length > 0) {
|
||||
const permissionChecks = await Promise.all(
|
||||
requiredPermissions.map((permission) => user.hasPermission(permission)),
|
||||
);
|
||||
|
||||
if (permissionChecks.includes(false)) {
|
||||
return next(new Error("Forbidden: insufficient permissions"));
|
||||
}
|
||||
}
|
||||
|
||||
const eventPermissions = permParams?.eventPermissions;
|
||||
|
||||
if (eventPermissions) {
|
||||
const attached = attachSocketEventPermissions(socket, eventPermissions);
|
||||
if (!attached) return next(new Error("Unauthorized"));
|
||||
}
|
||||
|
||||
return next();
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Namespace } from "socket.io";
|
||||
import { io, prisma } from "../../constants";
|
||||
import { sessions } from "../../managers/sessions";
|
||||
import { socketAuthMiddleware } from "./middleware/authorization";
|
||||
import { registerLiveQuotePreviewHandlers } from "./events/liveQuotePreview";
|
||||
|
||||
const SESSION_ENFORCEMENT_INTERVAL_MS = 60 * 1000;
|
||||
const MAX_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
const AUTH_HEADER_REGEX =
|
||||
/^(Bearer|Key)\s([a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+)$/;
|
||||
|
||||
const resolveAuthorization = (handshake: {
|
||||
auth?: Record<string, unknown>;
|
||||
headers?: Record<string, unknown>;
|
||||
}): string | null => {
|
||||
const headerAuth = handshake.headers?.authorization;
|
||||
if (typeof headerAuth === "string" && headerAuth.length > 0)
|
||||
return headerAuth;
|
||||
|
||||
const authAuthorization = handshake.auth?.authorization;
|
||||
if (typeof authAuthorization === "string" && authAuthorization.length > 0)
|
||||
return authAuthorization;
|
||||
|
||||
const authToken = handshake.auth?.token;
|
||||
if (typeof authToken === "string" && authToken.length > 0)
|
||||
return `Bearer ${authToken}`;
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const setupSecureNamespace = (): Namespace => {
|
||||
const secureNamespace = io.of("/secure");
|
||||
|
||||
secureNamespace.use(async (socket, next) => {
|
||||
try {
|
||||
const authorization = resolveAuthorization(socket.handshake as any);
|
||||
if (!authorization)
|
||||
return next(new Error("Unauthorized: missing authorization"));
|
||||
|
||||
const components = authorization.match(AUTH_HEADER_REGEX);
|
||||
if (!components)
|
||||
return next(new Error("Unauthorized: invalid authorization format"));
|
||||
|
||||
const authValue = components[2] ?? "";
|
||||
const session = await sessions.fetch({ accessToken: authValue });
|
||||
const user = await session.fetchUser();
|
||||
|
||||
socket.data.user = user;
|
||||
socket.data.session = session;
|
||||
|
||||
return next();
|
||||
} catch {
|
||||
return next(new Error("Unauthorized"));
|
||||
}
|
||||
});
|
||||
|
||||
secureNamespace.use(socketAuthMiddleware());
|
||||
|
||||
secureNamespace.on("connection", (socket) => {
|
||||
const sessionId = socket.data.session?.id as string | undefined;
|
||||
const sessionExpiresAt = socket.data.session?.expires
|
||||
? new Date(socket.data.session.expires).getTime()
|
||||
: null;
|
||||
|
||||
const disconnectForSession = () => {
|
||||
if (socket.disconnected) return;
|
||||
socket.emit("secure:session:expired");
|
||||
socket.disconnect(true);
|
||||
};
|
||||
|
||||
let expiryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleExpiryDisconnect = () => {
|
||||
if (sessionExpiresAt === null) return;
|
||||
|
||||
const remainingMs = sessionExpiresAt - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const delayMs = Math.min(remainingMs, MAX_TIMEOUT_MS);
|
||||
expiryTimeout = setTimeout(scheduleExpiryDisconnect, delayMs);
|
||||
};
|
||||
|
||||
scheduleExpiryDisconnect();
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
if (!sessionId) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await prisma.session.findFirst({
|
||||
where: { id: sessionId },
|
||||
select: { id: true, expires: true, invalidatedAt: true },
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.invalidatedAt) {
|
||||
disconnectForSession();
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.expires.getTime() <= Date.now()) {
|
||||
disconnectForSession();
|
||||
}
|
||||
}, SESSION_ENFORCEMENT_INTERVAL_MS);
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
clearInterval(interval);
|
||||
if (expiryTimeout) clearTimeout(expiryTimeout);
|
||||
});
|
||||
|
||||
registerLiveQuotePreviewHandlers(socket);
|
||||
|
||||
socket.emit("secure:connected", {
|
||||
userId: socket.data.user?.id ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
return secureNamespace;
|
||||
};
|
||||
@@ -23,6 +23,8 @@ export class ForecastProductController {
|
||||
public catalogItemIdentifier: string | null;
|
||||
|
||||
public productDescription: string;
|
||||
public customerDescription: string | null;
|
||||
public productNarrative: string | null;
|
||||
public productClass: string;
|
||||
public forecastType: string;
|
||||
|
||||
@@ -74,6 +76,9 @@ export class ForecastProductController {
|
||||
this.catalogItemIdentifier = data.catalogItem?.identifier ?? null;
|
||||
|
||||
this.productDescription = data.productDescription;
|
||||
this.customerDescription = data.customerDescription ?? null;
|
||||
this.productNarrative =
|
||||
data.customFields?.find((f) => f.id === 46)?.value?.toString() ?? null;
|
||||
this.productClass = data.productClass;
|
||||
this.forecastType = data.forecastType;
|
||||
|
||||
@@ -118,6 +123,24 @@ export class ForecastProductController {
|
||||
* Enriches this forecast product with cancellation data from the
|
||||
* procurement products endpoint.
|
||||
*/
|
||||
/**
|
||||
* Apply Procurement Custom Fields
|
||||
*
|
||||
* Enriches this forecast product with custom field data from the
|
||||
* procurement products endpoint (the forecast endpoint does not
|
||||
* return customFields).
|
||||
*/
|
||||
public applyProcurementCustomFields(data: {
|
||||
customFields?: Array<{ id: number; value?: unknown }>;
|
||||
}): void {
|
||||
const narrative = data.customFields
|
||||
?.find((f) => f.id === 46)
|
||||
?.value?.toString();
|
||||
if (narrative) {
|
||||
this.productNarrative = narrative;
|
||||
}
|
||||
}
|
||||
|
||||
public applyCancellationData(data: {
|
||||
cancelledFlag?: boolean;
|
||||
quantityCancelled?: number;
|
||||
@@ -154,6 +177,38 @@ export class ForecastProductController {
|
||||
return this.revenue - this.cost;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Quantity
|
||||
*
|
||||
* Returns the quantity adjusted for cancellations (minimum 0).
|
||||
*/
|
||||
public get effectiveQuantity(): number {
|
||||
if (this.cancellationType === "full") return 0;
|
||||
return Math.max(0, this.quantity - this.quantityCancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Revenue
|
||||
*
|
||||
* Returns the revenue adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveRevenue(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitPrice = this.revenue / this.quantity;
|
||||
return unitPrice * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Effective Cost
|
||||
*
|
||||
* Returns the cost adjusted proportionally for cancelled units.
|
||||
*/
|
||||
public get effectiveCost(): number {
|
||||
if (this.cancellationType === "full" || this.quantity <= 0) return 0;
|
||||
const unitCost = this.cost / this.quantity;
|
||||
return unitCost * this.effectiveQuantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancelled
|
||||
*
|
||||
@@ -201,12 +256,17 @@ export class ForecastProductController {
|
||||
? { id: this.catalogItemCwId, identifier: this.catalogItemIdentifier }
|
||||
: null,
|
||||
productDescription: this.productDescription,
|
||||
customerDescription: this.customerDescription,
|
||||
productNarrative: this.productNarrative,
|
||||
productClass: this.productClass,
|
||||
forecastType: this.forecastType,
|
||||
revenue: this.revenue,
|
||||
cost: this.cost,
|
||||
margin: this.margin,
|
||||
profit: this.profit,
|
||||
effectiveQuantity: this.effectiveQuantity,
|
||||
effectiveRevenue: this.effectiveRevenue,
|
||||
effectiveCost: this.effectiveCost,
|
||||
percentage: this.percentage,
|
||||
includeFlag: this.includeFlag,
|
||||
linkFlag: this.linkFlag,
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
GeneratedQuotes,
|
||||
Opportunity,
|
||||
Role,
|
||||
User,
|
||||
} from "../../generated/prisma/client";
|
||||
import { prisma } from "../constants";
|
||||
import { OpportunityController } from "./OpportunityController";
|
||||
import UserController from "./UserController";
|
||||
|
||||
export class GeneratedQuoteController {
|
||||
public readonly id: string;
|
||||
|
||||
public quoteRegenData: unknown;
|
||||
public quoteRegenParams: unknown;
|
||||
public quoteRegenHash: string;
|
||||
|
||||
public downloads: unknown[];
|
||||
|
||||
public quoteFile: Uint8Array;
|
||||
public quoteFileName: string;
|
||||
|
||||
public opportunityId: string;
|
||||
public createdById: string | null;
|
||||
|
||||
public createdAt: Date;
|
||||
public updatedAt: Date;
|
||||
|
||||
private _opportunity: OpportunityController | null;
|
||||
private _createdBy: UserController | null;
|
||||
|
||||
constructor(
|
||||
data: GeneratedQuotes & {
|
||||
opportunity?: Opportunity | null;
|
||||
createdBy?: (User & { roles: Role[] }) | null;
|
||||
},
|
||||
) {
|
||||
this.id = data.id;
|
||||
|
||||
this.quoteRegenData = data.quoteRegenData;
|
||||
this.quoteRegenParams = data.quoteRegenParams;
|
||||
this.quoteRegenHash = data.quoteRegenHash;
|
||||
|
||||
this.downloads = Array.isArray(data.downloads)
|
||||
? (data.downloads as unknown[])
|
||||
: [];
|
||||
|
||||
this.quoteFile = data.quoteFile;
|
||||
this.quoteFileName = data.quoteFileName;
|
||||
|
||||
this.opportunityId = data.opportunityId;
|
||||
this.createdById = data.createdById;
|
||||
|
||||
this.createdAt = data.createdAt;
|
||||
this.updatedAt = data.updatedAt;
|
||||
|
||||
this._opportunity = data.opportunity
|
||||
? new OpportunityController(data.opportunity)
|
||||
: null;
|
||||
|
||||
this._createdBy = data.createdBy
|
||||
? new UserController(data.createdBy)
|
||||
: null;
|
||||
}
|
||||
|
||||
public async fetchOpportunity(): Promise<OpportunityController | null> {
|
||||
if (this._opportunity) return this._opportunity;
|
||||
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: this.opportunityId },
|
||||
});
|
||||
|
||||
if (!opportunity) return null;
|
||||
|
||||
this._opportunity = new OpportunityController(opportunity);
|
||||
return this._opportunity;
|
||||
}
|
||||
|
||||
public async fetchCreatedBy(): Promise<UserController | null> {
|
||||
if (this._createdBy) return this._createdBy;
|
||||
if (!this.createdById) return null;
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: this.createdById },
|
||||
include: { roles: true },
|
||||
});
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
this._createdBy = new UserController(user);
|
||||
return this._createdBy;
|
||||
}
|
||||
|
||||
public toJson(opts?: {
|
||||
includeFile?: boolean;
|
||||
encodeFileAsBase64?: boolean;
|
||||
includeRegenData?: boolean;
|
||||
includeRegenParams?: boolean;
|
||||
includeDownloads?: boolean;
|
||||
includeOpportunity?: boolean;
|
||||
includeCreatedBy?: boolean;
|
||||
}): Record<string, any> {
|
||||
return {
|
||||
id: this.id,
|
||||
quoteFileName: this.quoteFileName,
|
||||
quoteRegenHash: this.quoteRegenHash,
|
||||
opportunityId: this.opportunityId,
|
||||
createdById: this.createdById,
|
||||
downloads: opts?.includeDownloads ? this.downloads : undefined,
|
||||
quoteRegenData: opts?.includeRegenData ? this.quoteRegenData : undefined,
|
||||
quoteRegenParams: opts?.includeRegenParams
|
||||
? this.quoteRegenParams
|
||||
: undefined,
|
||||
quoteFile: !opts?.includeFile
|
||||
? undefined
|
||||
: opts?.encodeFileAsBase64
|
||||
? Buffer.from(this.quoteFile).toString("base64")
|
||||
: this.quoteFile,
|
||||
opportunity:
|
||||
opts?.includeOpportunity && this._opportunity
|
||||
? this._opportunity.toJson()
|
||||
: undefined,
|
||||
createdBy:
|
||||
opts?.includeCreatedBy && this._createdBy
|
||||
? this._createdBy.toJson()
|
||||
: undefined,
|
||||
createdAt: this.createdAt,
|
||||
updatedAt: this.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -20,11 +20,13 @@ import {
|
||||
import {
|
||||
resolveMember,
|
||||
resolveMembers,
|
||||
getMemberCache,
|
||||
} from "../modules/cw-utils/members/memberCache";
|
||||
import { ForecastProductController } from "./ForecastProductController";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { computeSubResourceCacheTTL } from "../modules/algorithms/computeSubResourceCacheTTL";
|
||||
import { computeProductsCacheTTL } from "../modules/algorithms/computeProductsCacheTTL";
|
||||
import UserController from "./UserController";
|
||||
import {
|
||||
getCachedNotes,
|
||||
getCachedContacts,
|
||||
@@ -37,6 +39,11 @@ import {
|
||||
invalidateNotesCache,
|
||||
invalidateProductsCache,
|
||||
} from "../modules/cache/opportunityCache";
|
||||
import {
|
||||
generateQuote as generateQuotePdf,
|
||||
type QuoteMetadata,
|
||||
} from "../modules/pdf-utils";
|
||||
import { generatedQuotes } from "../managers/generatedQuotes";
|
||||
|
||||
/**
|
||||
* Opportunity Controller
|
||||
@@ -81,6 +88,7 @@ export class OpportunityController {
|
||||
public customerPO: string | null;
|
||||
|
||||
public totalSalesTax: number;
|
||||
public probability: number;
|
||||
|
||||
public locationName: string | null;
|
||||
public locationCwId: number | null;
|
||||
@@ -131,6 +139,29 @@ export class OpportunityController {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve primary sales rep info for quote generation.
|
||||
*
|
||||
* Looks up the primary sales rep in the CW member cache and returns
|
||||
* their name and email. Returns undefined if no rep is assigned.
|
||||
*/
|
||||
private async _resolveSalesRep(): Promise<
|
||||
{ name: string; email?: string } | undefined
|
||||
> {
|
||||
if (!this.primarySalesRepIdentifier) return undefined;
|
||||
const cache = await getMemberCache();
|
||||
const member = cache.get(this.primarySalesRepIdentifier);
|
||||
const name = member
|
||||
? `${member.firstName} ${member.lastName}`.trim() ||
|
||||
this.primarySalesRepName ||
|
||||
this.primarySalesRepIdentifier
|
||||
: (this.primarySalesRepName ?? this.primarySalesRepIdentifier);
|
||||
return {
|
||||
name,
|
||||
email: member?.officeEmail ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
constructor(
|
||||
data: Opportunity & { company?: Company | null },
|
||||
opts?: {
|
||||
@@ -174,6 +205,7 @@ export class OpportunityController {
|
||||
this.customerPO = data.customerPO;
|
||||
|
||||
this.totalSalesTax = data.totalSalesTax;
|
||||
this.probability = data.probability;
|
||||
|
||||
this.locationName = data.locationName;
|
||||
this.locationCwId = data.locationCwId;
|
||||
@@ -203,6 +235,18 @@ export class OpportunityController {
|
||||
this._activities = opts?.activities ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hydrate Custom Fields
|
||||
*
|
||||
* Lazily fetches the opportunity's custom fields from ConnectWise
|
||||
* if they haven't been loaded yet.
|
||||
*/
|
||||
private async _hydrateCustomFields(): Promise<void> {
|
||||
if (this._customFields !== null) return;
|
||||
const cwData = await fetchOpportunity(this.cwOpportunityId);
|
||||
this._customFields = cwData.customFields ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Company
|
||||
*
|
||||
@@ -297,6 +341,7 @@ export class OpportunityController {
|
||||
customerPO: item.customerPO ?? null,
|
||||
|
||||
totalSalesTax: item.totalSalesTax ?? 0,
|
||||
probability: Number(item.probability?.name) || 0,
|
||||
|
||||
locationName: item.location?.name ?? null,
|
||||
locationCwId: item.location?.id ?? null,
|
||||
@@ -536,6 +581,372 @@ export class OpportunityController {
|
||||
return this._buildProductControllers(forecast, procProducts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Quote PDF
|
||||
*
|
||||
* Builds a customer-facing quote PDF using the opportunity, company, site,
|
||||
* and product data available to this controller.
|
||||
*/
|
||||
public async generateQuote(opts?: {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
showPreview?: boolean; // INTERNAL ONLY
|
||||
logoPath?: string;
|
||||
metadata?: QuoteMetadata;
|
||||
}): Promise<Buffer> {
|
||||
const options = {
|
||||
lineItemPricing: opts?.lineItemPricing ?? true,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
|
||||
includeItemNarratives: opts?.includeItemNarratives ?? true,
|
||||
showPreview: opts?.showPreview ?? false,
|
||||
logoPath: opts?.logoPath,
|
||||
};
|
||||
|
||||
const products = await this.fetchProducts();
|
||||
const activeProducts = products.filter(
|
||||
(item) => item.includeFlag && item.cancellationType !== "full",
|
||||
);
|
||||
|
||||
if (activeProducts.length === 0) {
|
||||
throw new GenericError({
|
||||
status: 400,
|
||||
name: "QuoteGenerationError",
|
||||
message: "Cannot generate a quote with no included line items",
|
||||
});
|
||||
}
|
||||
|
||||
const company = await this.fetchCompany();
|
||||
const companyJson = company?.toJson({
|
||||
includeAddress: true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: false,
|
||||
});
|
||||
const site = await this.fetchSite();
|
||||
|
||||
const siteAddress = [
|
||||
site?.address?.line1,
|
||||
site?.address?.line2,
|
||||
[site?.address?.city, site?.address?.state, site?.address?.zip]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const companyAddress = [
|
||||
companyJson?.cw_Data?.address?.line1,
|
||||
companyJson?.cw_Data?.address?.line2,
|
||||
[
|
||||
companyJson?.cw_Data?.address?.city,
|
||||
companyJson?.cw_Data?.address?.state,
|
||||
companyJson?.cw_Data?.address?.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const addressLines = siteAddress.length > 0 ? siteAddress : companyAddress;
|
||||
|
||||
const lineItems = activeProducts.map((item) => {
|
||||
const isLabor = item.productClass === "Service";
|
||||
const quantity = item.effectiveQuantity > 0 ? item.effectiveQuantity : 1;
|
||||
const lineTotal = Number.isFinite(item.revenue) ? item.revenue : 0;
|
||||
const unitPrice = isLabor ? lineTotal : lineTotal / quantity;
|
||||
|
||||
const itemNarrative = item.productNarrative || null;
|
||||
|
||||
const shouldIncludeNarrative =
|
||||
options.includeItemNarratives && !!itemNarrative;
|
||||
|
||||
return {
|
||||
qty: isLabor ? 1 : quantity,
|
||||
description: item.productDescription || "Line Item",
|
||||
unitPrice,
|
||||
narrative: shouldIncludeNarrative ? itemNarrative : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
const quoteDescription = this.name;
|
||||
|
||||
const primaryContactFullName = [
|
||||
companyJson?.cw_Data?.primaryContact?.firstName,
|
||||
companyJson?.cw_Data?.primaryContact?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
const customerName =
|
||||
this.contactName ||
|
||||
primaryContactFullName ||
|
||||
this.companyName ||
|
||||
"Customer";
|
||||
|
||||
const subTotal = lineItems.reduce(
|
||||
(sum, item) => sum + item.qty * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
const normalizedTaxRate =
|
||||
subTotal > 0 ? Math.max(0, this.totalSalesTax / subTotal) : 0;
|
||||
const taxLabel =
|
||||
normalizedTaxRate > 0
|
||||
? `Sales Tax (${(normalizedTaxRate * 100).toFixed(2)}%)`
|
||||
: "Sales Tax";
|
||||
|
||||
await this._hydrateCustomFields();
|
||||
|
||||
const quoteNarrative = options.includeQuoteNarrative
|
||||
? this._customFields?.find((f) => f.id === 35)?.value?.toString() ||
|
||||
undefined
|
||||
: undefined;
|
||||
|
||||
console.log("[generateQuote] quoteNarrative:", quoteNarrative);
|
||||
|
||||
const companyLine = this.companyName ?? company?.name ?? "Customer Company";
|
||||
|
||||
// Only show attention if it differs from the customer name
|
||||
const attention =
|
||||
this.contactName && this.contactName !== customerName
|
||||
? this.contactName
|
||||
: undefined;
|
||||
|
||||
// Only show company if it's meaningfully different from the customer name
|
||||
// (catches "Patterson, Diane" vs "Diane Patterson" style duplicates)
|
||||
const normalise = (s: string) =>
|
||||
s
|
||||
.toLowerCase()
|
||||
.replace(/[,.\s]+/g, " ")
|
||||
.trim()
|
||||
.split(" ")
|
||||
.sort()
|
||||
.join(" ");
|
||||
const showCompany = normalise(companyLine) !== normalise(customerName);
|
||||
|
||||
return generateQuotePdf(
|
||||
{
|
||||
customer: {
|
||||
name: customerName,
|
||||
company: showCompany ? companyLine : undefined,
|
||||
attention,
|
||||
address:
|
||||
addressLines.length > 0 ? addressLines : ["Address unavailable"],
|
||||
},
|
||||
contact: {
|
||||
email: companyJson?.cw_Data?.primaryContact?.email ?? undefined,
|
||||
phone: companyJson?.cw_Data?.primaryContact?.phone ?? undefined,
|
||||
},
|
||||
salesRep: await this._resolveSalesRep(),
|
||||
quote: {
|
||||
quoteNumber: this.cwOpportunityId.toString(),
|
||||
date: new Date().toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
}),
|
||||
description: quoteDescription,
|
||||
},
|
||||
lineItems,
|
||||
quoteNarrative,
|
||||
tax: {
|
||||
rate: normalizedTaxRate,
|
||||
label: taxLabel,
|
||||
},
|
||||
isPreview: options.showPreview,
|
||||
showLineItemPricing: options.lineItemPricing,
|
||||
metadata: opts?.metadata,
|
||||
},
|
||||
{},
|
||||
options.logoPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit Quote
|
||||
*
|
||||
* Generates a non-preview quote PDF and stores it in the GeneratedQuotes
|
||||
* table with a full data snapshot for exact reproduction, regeneration
|
||||
* metadata, and creator attribution.
|
||||
*/
|
||||
public async commitQuote(
|
||||
opts: {
|
||||
lineItemPricing?: boolean;
|
||||
includeQuoteNarrative?: boolean;
|
||||
includeItemNarratives?: boolean;
|
||||
logoPath?: string;
|
||||
} = {},
|
||||
user: UserController,
|
||||
) {
|
||||
const quoteOptions = {
|
||||
lineItemPricing: opts?.lineItemPricing ?? true,
|
||||
includeQuoteNarrative: opts?.includeQuoteNarrative ?? true,
|
||||
includeItemNarratives: opts?.includeItemNarratives ?? true,
|
||||
logoPath: opts?.logoPath,
|
||||
};
|
||||
|
||||
// ── Fetch all data sources BEFORE generating ──────────────────────
|
||||
const products = await this.fetchProducts();
|
||||
const company = await this.fetchCompany();
|
||||
const companyJson = company?.toJson({
|
||||
includeAddress: true,
|
||||
includePrimaryContact: true,
|
||||
includeAllContacts: false,
|
||||
});
|
||||
const site = await this.fetchSite();
|
||||
const salesRep = await this._resolveSalesRep();
|
||||
await this._hydrateCustomFields();
|
||||
|
||||
const quoteNarrative = quoteOptions.includeQuoteNarrative
|
||||
? (this._customFields?.find((f) => f.id === 35)?.value?.toString() ??
|
||||
null)
|
||||
: null;
|
||||
|
||||
// ── Pre-generate IDs & timestamps for metadata ───────────────────
|
||||
const quoteId = crypto.randomUUID();
|
||||
const createdAt = new Date().toISOString();
|
||||
|
||||
// ── Generate the PDF ──────────────────────────────────────────────
|
||||
const quoteBuffer = await this.generateQuote({
|
||||
...quoteOptions,
|
||||
showPreview: false,
|
||||
metadata: {
|
||||
quoteId,
|
||||
createdById: user.id,
|
||||
createdByName: user.name ?? undefined,
|
||||
createdByEmail: user.email ?? undefined,
|
||||
createdAt,
|
||||
},
|
||||
});
|
||||
|
||||
const fileTimestamp = createdAt.replace(/[:.]/g, "-");
|
||||
const quoteFileName = `OPP-${this.cwOpportunityId}-${fileTimestamp}.pdf`;
|
||||
|
||||
// ── Build the full data snapshot ──────────────────────────────────
|
||||
const siteAddress = [
|
||||
site?.address?.line1,
|
||||
site?.address?.line2,
|
||||
[site?.address?.city, site?.address?.state, site?.address?.zip]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const companyAddress = [
|
||||
companyJson?.cw_Data?.address?.line1,
|
||||
companyJson?.cw_Data?.address?.line2,
|
||||
[
|
||||
companyJson?.cw_Data?.address?.city,
|
||||
companyJson?.cw_Data?.address?.state,
|
||||
companyJson?.cw_Data?.address?.zip,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" "),
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
const primaryContactFullName = [
|
||||
companyJson?.cw_Data?.primaryContact?.firstName,
|
||||
companyJson?.cw_Data?.primaryContact?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.trim();
|
||||
|
||||
const regenData = {
|
||||
// Generation options
|
||||
options: {
|
||||
lineItemPricing: quoteOptions.lineItemPricing,
|
||||
includeQuoteNarrative: quoteOptions.includeQuoteNarrative,
|
||||
includeItemNarratives: quoteOptions.includeItemNarratives,
|
||||
},
|
||||
|
||||
// Opportunity metadata
|
||||
opportunity: {
|
||||
id: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
name: this.name,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
contactName: this.contactName,
|
||||
companyName: this.companyName,
|
||||
},
|
||||
|
||||
// Customer / company / site snapshot
|
||||
customer: {
|
||||
preparedFor:
|
||||
this.contactName ||
|
||||
primaryContactFullName ||
|
||||
this.companyName ||
|
||||
"Customer",
|
||||
companyName: this.companyName ?? company?.name ?? null,
|
||||
primaryContact: companyJson?.cw_Data?.primaryContact
|
||||
? {
|
||||
firstName: companyJson.cw_Data.primaryContact.firstName ?? null,
|
||||
lastName: companyJson.cw_Data.primaryContact.lastName ?? null,
|
||||
email: companyJson.cw_Data.primaryContact.email ?? null,
|
||||
phone: companyJson.cw_Data.primaryContact.phone ?? null,
|
||||
}
|
||||
: null,
|
||||
siteAddress: siteAddress.length > 0 ? siteAddress : null,
|
||||
companyAddress: companyAddress.length > 0 ? companyAddress : null,
|
||||
},
|
||||
|
||||
// Sales rep snapshot
|
||||
salesRep: salesRep ?? null,
|
||||
|
||||
// Quote narrative
|
||||
quoteNarrative: quoteNarrative ?? null,
|
||||
|
||||
// Full product snapshot
|
||||
products: products.map((p) => ({
|
||||
cwForecastId: p.cwForecastId,
|
||||
forecastDescription: p.forecastDescription,
|
||||
productDescription: p.productDescription,
|
||||
customerDescription: p.customerDescription,
|
||||
productNarrative: p.productNarrative,
|
||||
productClass: p.productClass,
|
||||
forecastType: p.forecastType,
|
||||
catalogItem: p.catalogItemCwId
|
||||
? { id: p.catalogItemCwId, identifier: p.catalogItemIdentifier }
|
||||
: null,
|
||||
quantity: p.quantity,
|
||||
effectiveQuantity: p.effectiveQuantity,
|
||||
revenue: p.revenue,
|
||||
cost: p.cost,
|
||||
margin: p.margin,
|
||||
percentage: p.percentage,
|
||||
includeFlag: p.includeFlag,
|
||||
taxableFlag: p.taxableFlag,
|
||||
recurringFlag: p.recurringFlag,
|
||||
recurringRevenue: p.recurringRevenue,
|
||||
recurringCost: p.recurringCost,
|
||||
sequenceNumber: p.sequenceNumber,
|
||||
cancelledFlag: p.cancelledFlag,
|
||||
cancellationType: p.cancellationType,
|
||||
quantityCancelled: p.quantityCancelled,
|
||||
cancelledReason: p.cancelledReason,
|
||||
cancelledDate: p.cancelledDate,
|
||||
})),
|
||||
|
||||
// Timestamp of when this snapshot was taken
|
||||
snapshotTimestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const regenParams = {
|
||||
opportunityId: this.id,
|
||||
cwOpportunityId: this.cwOpportunityId,
|
||||
};
|
||||
|
||||
const hasher = new Bun.CryptoHasher("sha256");
|
||||
hasher.update(JSON.stringify({ regenData, regenParams }));
|
||||
const quoteRegenHash = hasher.digest("hex");
|
||||
|
||||
return generatedQuotes.create({
|
||||
id: quoteId,
|
||||
quoteRegenData: regenData,
|
||||
quoteRegenParams: regenParams,
|
||||
quoteRegenHash,
|
||||
quoteFile: quoteBuffer,
|
||||
quoteFileName,
|
||||
opportunityId: this.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ForecastProductController[] from raw CW data.
|
||||
*
|
||||
@@ -593,6 +1004,7 @@ export class OpportunityController {
|
||||
const procData = cancellationMap.get(item.id);
|
||||
if (procData) {
|
||||
ctrl.applyCancellationData(procData as any);
|
||||
ctrl.applyProcurementCustomFields(procData as any);
|
||||
}
|
||||
return ctrl;
|
||||
},
|
||||
@@ -1115,6 +1527,7 @@ export class OpportunityController {
|
||||
: null,
|
||||
customerPO: this.customerPO,
|
||||
totalSalesTax: this.totalSalesTax,
|
||||
probability: this.probability,
|
||||
location: this.locationCwId
|
||||
? { id: this.locationCwId, name: this.locationName }
|
||||
: null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { refresh } from "./api/auth";
|
||||
import app from "./api/server";
|
||||
import { setupSockets } from "./api/sockets";
|
||||
import {
|
||||
engine,
|
||||
PORT,
|
||||
@@ -68,6 +69,9 @@ Bun.serve({
|
||||
|
||||
console.log(`[startup] Server listening on port ${PORT}`);
|
||||
|
||||
setupSockets();
|
||||
console.log("[startup] Socket namespaces initialized");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Background initialisation — none of this blocks the server.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { prisma } from "../constants";
|
||||
import GenericError from "../Errors/GenericError";
|
||||
import { GeneratedQuoteController } from "../controllers/GeneratedQuoteController";
|
||||
|
||||
const generatedQuoteInclude = {
|
||||
opportunity: true,
|
||||
createdBy: {
|
||||
include: {
|
||||
roles: true,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const generatedQuotes = {
|
||||
async fetch(id: string): Promise<GeneratedQuoteController> {
|
||||
const quote = await prisma.generatedQuotes.findFirst({
|
||||
where: { id },
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
if (!quote) {
|
||||
throw new GenericError({
|
||||
message: "Generated quote not found",
|
||||
name: "GeneratedQuoteNotFound",
|
||||
cause: `No generated quote exists with ID '${id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return new GeneratedQuoteController(quote);
|
||||
},
|
||||
|
||||
async fetchByOpportunity(
|
||||
opportunityId: string,
|
||||
): Promise<GeneratedQuoteController[]> {
|
||||
const rows = await prisma.generatedQuotes.findMany({
|
||||
where: { opportunityId },
|
||||
include: generatedQuoteInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return rows.map((row) => new GeneratedQuoteController(row));
|
||||
},
|
||||
|
||||
async fetchByCreator(
|
||||
createdById: string,
|
||||
): Promise<GeneratedQuoteController[]> {
|
||||
const rows = await prisma.generatedQuotes.findMany({
|
||||
where: { createdById },
|
||||
include: generatedQuoteInclude,
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return rows.map((row) => new GeneratedQuoteController(row));
|
||||
},
|
||||
|
||||
async fetchByHash(
|
||||
quoteRegenHash: string,
|
||||
): Promise<GeneratedQuoteController | null> {
|
||||
const quote = await prisma.generatedQuotes.findUnique({
|
||||
where: { quoteRegenHash },
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
return quote ? new GeneratedQuoteController(quote) : null;
|
||||
},
|
||||
|
||||
async create(data: {
|
||||
id?: string;
|
||||
quoteRegenData: unknown;
|
||||
quoteRegenParams: unknown;
|
||||
quoteRegenHash: string;
|
||||
quoteFile: Buffer | Uint8Array;
|
||||
quoteFileName: string;
|
||||
opportunityId: string;
|
||||
createdById: string;
|
||||
}): Promise<GeneratedQuoteController> {
|
||||
const opportunity = await prisma.opportunity.findFirst({
|
||||
where: { id: data.opportunityId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!opportunity) {
|
||||
throw new GenericError({
|
||||
message: "Opportunity not found",
|
||||
name: "OpportunityNotFound",
|
||||
cause: `No opportunity exists with ID '${data.opportunityId}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const createdBy = await prisma.user.findFirst({
|
||||
where: { id: data.createdById },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!createdBy) {
|
||||
throw new GenericError({
|
||||
message: "User not found",
|
||||
name: "UserNotFound",
|
||||
cause: `No user exists with ID '${data.createdById}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const quote = await prisma.generatedQuotes.create({
|
||||
data: {
|
||||
...(data.id ? { id: data.id } : {}),
|
||||
quoteRegenData: data.quoteRegenData as any,
|
||||
quoteRegenParams: data.quoteRegenParams as any,
|
||||
quoteRegenHash: data.quoteRegenHash,
|
||||
quoteFile: Buffer.from(data.quoteFile),
|
||||
quoteFileName: data.quoteFileName,
|
||||
opportunityId: data.opportunityId,
|
||||
createdById: data.createdById,
|
||||
},
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
return new GeneratedQuoteController(quote);
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const existing = await prisma.generatedQuotes.findFirst({
|
||||
where: { id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new GenericError({
|
||||
message: "Generated quote not found",
|
||||
name: "GeneratedQuoteNotFound",
|
||||
cause: `No generated quote exists with ID '${id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.generatedQuotes.delete({
|
||||
where: { id },
|
||||
});
|
||||
},
|
||||
|
||||
async recordDownload(
|
||||
id: string,
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email: string;
|
||||
fetchAction: string;
|
||||
},
|
||||
): Promise<GeneratedQuoteController> {
|
||||
const existing = await prisma.generatedQuotes.findFirst({
|
||||
where: { id },
|
||||
select: { id: true, downloads: true },
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new GenericError({
|
||||
message: "Generated quote not found",
|
||||
name: "GeneratedQuoteNotFound",
|
||||
cause: `No generated quote exists with ID '${id}'`,
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const currentDownloads = Array.isArray(existing.downloads)
|
||||
? (existing.downloads as unknown[])
|
||||
: [];
|
||||
|
||||
const downloadRecord = {
|
||||
downloadedAt: new Date().toISOString(),
|
||||
fetchAction: user.fetchAction,
|
||||
userId: user.id,
|
||||
userName: user.name ?? null,
|
||||
userEmail: user.email,
|
||||
};
|
||||
|
||||
const updated = await prisma.generatedQuotes.update({
|
||||
where: { id },
|
||||
data: {
|
||||
downloads: [...currentDownloads, downloadRecord] as any,
|
||||
},
|
||||
include: generatedQuoteInclude,
|
||||
});
|
||||
|
||||
return new GeneratedQuoteController(updated);
|
||||
},
|
||||
};
|
||||
@@ -377,7 +377,7 @@ export const opportunities = {
|
||||
include: { company: true },
|
||||
skip,
|
||||
take: rpp,
|
||||
orderBy: { expectedCloseDate: "asc" },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
|
||||
@@ -354,7 +354,7 @@ export const opportunityCw = {
|
||||
opportunityId: number,
|
||||
): Promise<Record<string, unknown>[]> => {
|
||||
const response = await connectWiseApi.get(
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate`,
|
||||
`/procurement/products?conditions=${encodeURIComponent(`opportunity/id=${opportunityId}`)}&fields=id,forecastDetailId,cancelledFlag,quantityCancelled,cancelledReason,cancelledBy,cancelledDate,customFields`,
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -68,6 +68,7 @@ export interface CWOpportunity {
|
||||
closedDate: string;
|
||||
closedBy: CWMemberReference;
|
||||
totalSalesTax: number;
|
||||
probability: CWReference;
|
||||
shipToCompany: CWCompanyReference;
|
||||
shipToContact: CWContactReference;
|
||||
shipToSite: CWSiteReference;
|
||||
|
||||
@@ -14,6 +14,7 @@ export const processOpportunityResponse = (opportunity: CWOpportunity) => ({
|
||||
expectedCloseDate: opportunity.expectedCloseDate,
|
||||
closedDate: opportunity.closedDate,
|
||||
closedFlag: opportunity.closedFlag,
|
||||
probability: Number(opportunity.probability?.name) || 0,
|
||||
type: opportunity.type
|
||||
? { id: opportunity.type.id, name: opportunity.type.name }
|
||||
: null,
|
||||
|
||||
@@ -0,0 +1,782 @@
|
||||
import PdfPrinter from "pdfmake/src/Printer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
export interface QuoteLineItem {
|
||||
qty: number;
|
||||
description: string;
|
||||
unitPrice: number;
|
||||
narrative?: string;
|
||||
}
|
||||
|
||||
export interface CustomerInfo {
|
||||
name: string;
|
||||
company?: string;
|
||||
attention?: string;
|
||||
address: string[];
|
||||
}
|
||||
|
||||
export interface CustomerContact {
|
||||
email?: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface QuoteDetails {
|
||||
quoteNumber: string;
|
||||
date: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TaxConfig {
|
||||
rate: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SalesRepInfo {
|
||||
name: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface QuoteMetadata {
|
||||
quoteId?: string;
|
||||
createdById?: string;
|
||||
createdByName?: string;
|
||||
createdByEmail?: string;
|
||||
createdAt?: string;
|
||||
downloadedAt?: string;
|
||||
downloadedById?: string;
|
||||
downloadedByName?: string;
|
||||
downloadedByEmail?: string;
|
||||
}
|
||||
|
||||
export interface QuoteData {
|
||||
customer: CustomerInfo;
|
||||
contact: CustomerContact;
|
||||
quote: QuoteDetails;
|
||||
lineItems: QuoteLineItem[];
|
||||
tax: TaxConfig;
|
||||
salesRep?: SalesRepInfo;
|
||||
quoteNarrative?: string;
|
||||
isPreview?: boolean;
|
||||
showLineItemPricing?: boolean;
|
||||
metadata?: QuoteMetadata;
|
||||
}
|
||||
|
||||
export interface QuoteTheme {
|
||||
brandPrimary: string;
|
||||
brandDark: string;
|
||||
brandLight: string;
|
||||
accent: string;
|
||||
headerBg: string;
|
||||
footerBg: string;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME: QuoteTheme = {
|
||||
brandPrimary: "#8B5E0B",
|
||||
brandDark: "#5C3D07",
|
||||
brandLight: "#F5EDE0",
|
||||
accent: "#C67F17",
|
||||
headerBg: "#2D2317",
|
||||
footerBg: "#F5EDE0",
|
||||
};
|
||||
|
||||
const SLATE = "#3A3A3A";
|
||||
const SLATE_MID = "#636363";
|
||||
const SLATE_LIGHT = "#8E8E8E";
|
||||
const WHITE = "#FFFFFF";
|
||||
const ROW_ALT = "#FAF7F2";
|
||||
const DIVIDER = "#D4C5A9";
|
||||
|
||||
const PAGE_H = 792;
|
||||
const PAGE_W = 612;
|
||||
const MARGIN_L = 40;
|
||||
const MARGIN_R = 40;
|
||||
const MARGIN_TOP = 26;
|
||||
const MARGIN_BOTTOM = 65;
|
||||
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
|
||||
|
||||
const DEFAULT_DISCLAIMER =
|
||||
"Prices valid for 30 days from quote date. Taxes invoiced per jurisdiction regardless of presence on this quote.";
|
||||
|
||||
const COMPANY = {
|
||||
name: "Total Tech Solutions LLC",
|
||||
contactPerson: "Courtney Stevens",
|
||||
address: ["PO Box 331", "Murray, KY 42071"],
|
||||
phone: "(270) 761-8324",
|
||||
email: "courtney.stevens@totaltech.net",
|
||||
licenseInfo: "Licensed in Kentucky & Tennessee · TN License #2173",
|
||||
} as const;
|
||||
|
||||
const DEFAULT_LOGO_PATH = join(process.cwd(), "logo.png");
|
||||
|
||||
const fontDir = join(process.cwd(), "node_modules/pdfmake/build/fonts/Roboto");
|
||||
const fonts = {
|
||||
Roboto: {
|
||||
normal: join(fontDir, "Roboto-Regular.ttf"),
|
||||
bold: join(fontDir, "Roboto-Medium.ttf"),
|
||||
italics: join(fontDir, "Roboto-Italic.ttf"),
|
||||
bolditalics: join(fontDir, "Roboto-MediumItalic.ttf"),
|
||||
},
|
||||
};
|
||||
|
||||
const printer = new PdfPrinter(fonts as never);
|
||||
|
||||
const fmt = (n: number) =>
|
||||
"$" + n.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
||||
|
||||
const hr = (color = DIVIDER, weight = 0.75) => ({
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: CONTENT_W,
|
||||
y2: 0,
|
||||
lineWidth: weight,
|
||||
lineColor: color,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
function loadLogoDataUrl(logoPath: string): string | null {
|
||||
try {
|
||||
const raw = readFileSync(logoPath);
|
||||
const ext = logoPath.toLowerCase().endsWith(".png") ? "png" : "jpeg";
|
||||
return `data:image/${ext};base64,${raw.toString("base64")}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateQuote(
|
||||
data: QuoteData,
|
||||
theme: Partial<QuoteTheme> = {},
|
||||
logoPath = DEFAULT_LOGO_PATH,
|
||||
): Promise<Buffer> {
|
||||
const t: QuoteTheme = { ...DEFAULT_THEME, ...theme };
|
||||
const subTotal = data.lineItems.reduce(
|
||||
(sum, item) => sum + item.qty * item.unitPrice,
|
||||
0,
|
||||
);
|
||||
const taxAmount = subTotal * data.tax.rate;
|
||||
const total = subTotal + taxAmount;
|
||||
const logoDataUrl = loadLogoDataUrl(logoPath);
|
||||
|
||||
const showPricing = data.showLineItemPricing ?? false;
|
||||
|
||||
const tableHeader = [
|
||||
{ text: "Qty", style: "thCell", alignment: "center" },
|
||||
{ text: "Description", style: "thCell" },
|
||||
...(showPricing
|
||||
? [
|
||||
{ text: "Unit Price", style: "thCell", alignment: "right" },
|
||||
{ text: "Total", style: "thCell", alignment: "right" },
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const colCount = showPricing ? 4 : 2;
|
||||
|
||||
const tableRows: Record<string, unknown>[][] = [];
|
||||
for (const item of data.lineItems) {
|
||||
// Build the description cell — stack description + narrative so they
|
||||
// are a single cell and pdfmake never splits them across pages.
|
||||
const descriptionCell: Record<string, unknown> = item.narrative
|
||||
? {
|
||||
stack: [
|
||||
{ text: item.description, style: "tdCell" },
|
||||
{
|
||||
text: item.narrative,
|
||||
style: "narrative",
|
||||
margin: [0, 2, 8, 0],
|
||||
},
|
||||
],
|
||||
}
|
||||
: { text: item.description, style: "tdCell" };
|
||||
|
||||
tableRows.push([
|
||||
{ text: String(item.qty), style: "tdCell", alignment: "center" },
|
||||
descriptionCell,
|
||||
...(showPricing
|
||||
? [
|
||||
{
|
||||
text: fmt(item.unitPrice),
|
||||
style: "tdCell",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
},
|
||||
{
|
||||
text: fmt(item.qty * item.unitPrice),
|
||||
style: "tdCell",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]);
|
||||
}
|
||||
|
||||
const headerImage = logoDataUrl
|
||||
? { image: logoDataUrl, width: 200 }
|
||||
: {
|
||||
stack: [{ text: COMPANY.name, style: "companyName" }],
|
||||
width: 200,
|
||||
};
|
||||
|
||||
const docDefinition = {
|
||||
pageSize: "LETTER" as const,
|
||||
pageMargins: [MARGIN_L, MARGIN_TOP, MARGIN_R, MARGIN_BOTTOM] as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
],
|
||||
|
||||
info: {
|
||||
title: `Quote ${data.quote.quoteNumber}`,
|
||||
author: data.metadata?.createdByName ?? COMPANY.name,
|
||||
subject: data.quote.description,
|
||||
creator: COMPANY.name,
|
||||
producer: COMPANY.name,
|
||||
keywords: [
|
||||
data.metadata?.quoteId ? `quoteId:${data.metadata.quoteId}` : null,
|
||||
data.metadata?.createdById
|
||||
? `createdById:${data.metadata.createdById}`
|
||||
: null,
|
||||
data.metadata?.createdByEmail
|
||||
? `createdByEmail:${data.metadata.createdByEmail}`
|
||||
: null,
|
||||
data.metadata?.createdAt
|
||||
? `createdAt:${data.metadata.createdAt}`
|
||||
: null,
|
||||
data.metadata?.downloadedAt
|
||||
? `downloadedAt:${data.metadata.downloadedAt}`
|
||||
: null,
|
||||
data.metadata?.downloadedById
|
||||
? `downloadedById:${data.metadata.downloadedById}`
|
||||
: null,
|
||||
data.metadata?.downloadedByName
|
||||
? `downloadedByName:${data.metadata.downloadedByName}`
|
||||
: null,
|
||||
data.metadata?.downloadedByEmail
|
||||
? `downloadedByEmail:${data.metadata.downloadedByEmail}`
|
||||
: null,
|
||||
data.isPreview ? "preview:true" : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; "),
|
||||
},
|
||||
|
||||
defaultStyle: {
|
||||
font: "Roboto",
|
||||
fontSize: 9.5,
|
||||
color: SLATE,
|
||||
lineHeight: 1.3,
|
||||
},
|
||||
|
||||
styles: {
|
||||
companyName: { fontSize: 18, bold: true, color: t.brandDark },
|
||||
quoteLabel: { fontSize: 24, color: t.accent, bold: true, opacity: 0.12 },
|
||||
sectionTitle: {
|
||||
fontSize: 8.5,
|
||||
bold: true,
|
||||
color: t.brandPrimary,
|
||||
characterSpacing: 1.2,
|
||||
},
|
||||
sectionBody: { fontSize: 9, color: SLATE },
|
||||
sectionMuted: { fontSize: 8.5, color: SLATE_MID },
|
||||
infoLabel: {
|
||||
fontSize: 8,
|
||||
bold: true,
|
||||
color: SLATE_LIGHT,
|
||||
characterSpacing: 0.5,
|
||||
},
|
||||
infoValue: { fontSize: 10, bold: true, color: t.brandDark },
|
||||
contactLabel: { fontSize: 8, bold: true, color: SLATE_LIGHT },
|
||||
contactValue: { fontSize: 9, color: SLATE },
|
||||
thCell: {
|
||||
fontSize: 8.5,
|
||||
bold: true,
|
||||
color: WHITE,
|
||||
characterSpacing: 0.5,
|
||||
},
|
||||
tdCell: { fontSize: 9, color: SLATE },
|
||||
narrative: {
|
||||
fontSize: 8,
|
||||
color: SLATE_MID,
|
||||
italics: true,
|
||||
lineHeight: 1.2,
|
||||
},
|
||||
totalsLabel: { fontSize: 9, color: SLATE_MID },
|
||||
totalsValue: { fontSize: 9, color: SLATE, bold: true },
|
||||
totalFinalLabel: { fontSize: 11, bold: true, color: WHITE },
|
||||
totalFinalValue: { fontSize: 12, bold: true, color: t.brandDark },
|
||||
footerText: { fontSize: 7.5, color: SLATE_MID },
|
||||
footerBold: { fontSize: 7.5, color: t.brandPrimary, bold: true },
|
||||
disclaimer: { fontSize: 7, color: SLATE_LIGHT, italics: true },
|
||||
},
|
||||
|
||||
...(data.isPreview
|
||||
? {
|
||||
watermark: {
|
||||
text: "PREVIEW",
|
||||
color: t.brandDark,
|
||||
opacity: 0.15,
|
||||
bold: true,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
|
||||
background: () => ({
|
||||
canvas: [
|
||||
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 6, color: t.accent },
|
||||
{ type: "rect", x: 0, y: 6, w: 4, h: 786, color: t.brandLight },
|
||||
],
|
||||
}),
|
||||
|
||||
content: [
|
||||
{
|
||||
margin: [0, 4, 0, 0],
|
||||
columns: [
|
||||
headerImage,
|
||||
{
|
||||
stack: [
|
||||
{ text: COMPANY.name, style: "companyName", alignment: "right" },
|
||||
{
|
||||
text: "QUOTE",
|
||||
style: "quoteLabel",
|
||||
alignment: "right",
|
||||
margin: [0, -4, 0, 0],
|
||||
},
|
||||
],
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(t.accent, 1.5), margin: [0, 8, 0, 0] },
|
||||
|
||||
{
|
||||
margin: [0, 7, 0, 7],
|
||||
columns: [
|
||||
{
|
||||
width: "auto",
|
||||
stack: [
|
||||
{ text: "QUOTE NUMBER", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.quoteNumber,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "auto",
|
||||
margin: [30, 0, 0, 0],
|
||||
stack: [
|
||||
{ text: "DATE", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.date,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "*",
|
||||
margin: [30, 0, 0, 0],
|
||||
stack: [
|
||||
{ text: "DESCRIPTION", style: "infoLabel" },
|
||||
{
|
||||
text: data.quote.description,
|
||||
style: "infoValue",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(), margin: [0, 0, 0, 10] },
|
||||
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
width: 155,
|
||||
stack: [
|
||||
{ text: "FROM", style: "sectionTitle", margin: [0, 0, 0, 6] },
|
||||
{
|
||||
text: data.salesRep?.name ?? COMPANY.contactPerson,
|
||||
style: "sectionBody",
|
||||
bold: true,
|
||||
},
|
||||
{
|
||||
text: COMPANY.name,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
...COMPANY.address.map((line) => ({
|
||||
text: line,
|
||||
style: "sectionMuted",
|
||||
})),
|
||||
{
|
||||
text: COMPANY.phone,
|
||||
style: "sectionBody",
|
||||
margin: [0, 4, 0, 0],
|
||||
},
|
||||
{
|
||||
text: data.salesRep?.email ?? COMPANY.email,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 1, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
width: 175,
|
||||
margin: [25, 0, 0, 0],
|
||||
stack: [
|
||||
{
|
||||
text: "PREPARED FOR",
|
||||
style: "sectionTitle",
|
||||
margin: [0, 0, 0, 6],
|
||||
},
|
||||
{ text: data.customer.name, style: "sectionBody", bold: true },
|
||||
...(data.customer.company
|
||||
? [
|
||||
{
|
||||
text: data.customer.company,
|
||||
style: "sectionMuted",
|
||||
margin: [0, 2, 0, 0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.customer.attention
|
||||
? [{ text: data.customer.attention, style: "sectionMuted" }]
|
||||
: []),
|
||||
...data.customer.address.map((line) => ({
|
||||
text: line,
|
||||
style: "sectionMuted",
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
...(data.contact.email || data.contact.phone
|
||||
? [
|
||||
{
|
||||
width: "*" as const,
|
||||
margin: [20, 0, 0, 0] as [number, number, number, number],
|
||||
stack: [
|
||||
{
|
||||
text: "CONTACT",
|
||||
style: "sectionTitle",
|
||||
margin: [0, 0, 0, 6],
|
||||
},
|
||||
...(data.contact.email
|
||||
? [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
text: "Email",
|
||||
style: "contactLabel",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
text: data.contact.email,
|
||||
style: "contactValue",
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(data.contact.phone
|
||||
? [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
text: "Mobile",
|
||||
style: "contactLabel",
|
||||
width: 40,
|
||||
},
|
||||
{
|
||||
text: data.contact.phone,
|
||||
style: "contactValue",
|
||||
width: "*",
|
||||
},
|
||||
],
|
||||
margin: [0, 4, 0, 0],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
|
||||
{ ...hr(), margin: [0, 10, 0, 0] },
|
||||
|
||||
...(data.quoteNarrative
|
||||
? [
|
||||
{
|
||||
margin: [0, 8, 0, 6] as [number, number, number, number],
|
||||
table: {
|
||||
widths: [2, "*"],
|
||||
body: [
|
||||
[
|
||||
{
|
||||
text: "",
|
||||
fillColor: t.accent,
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
text: data.quoteNarrative,
|
||||
fontSize: 9,
|
||||
color: SLATE_MID,
|
||||
italics: true,
|
||||
lineHeight: 1.4,
|
||||
margin: [8, 6, 8, 6],
|
||||
fillColor: ROW_ALT,
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
hLineWidth: () => 0,
|
||||
vLineWidth: () => 0,
|
||||
paddingLeft: () => 0,
|
||||
paddingRight: () => 0,
|
||||
paddingTop: () => 0,
|
||||
paddingBottom: () => 0,
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
|
||||
{
|
||||
margin: [0, 10, 0, 0],
|
||||
table: {
|
||||
headerRows: 1,
|
||||
dontBreakRows: true,
|
||||
widths: showPricing ? [40, "*", 75, 75] : [40, "*"],
|
||||
body: [tableHeader, ...tableRows],
|
||||
},
|
||||
layout: {
|
||||
fillColor: (rowIndex: number) => {
|
||||
if (rowIndex === 0) return t.headerBg;
|
||||
return rowIndex % 2 === 0 ? ROW_ALT : null;
|
||||
},
|
||||
hLineWidth: (i: number, node: { table: { body: unknown[] } }) => {
|
||||
if (i === 0 || i === 1) return 0;
|
||||
if (i === node.table.body.length) return 1;
|
||||
return 0.5;
|
||||
},
|
||||
vLineWidth: () => 0,
|
||||
hLineColor: (i: number, node: { table: { body: unknown[] } }) =>
|
||||
i === node.table.body.length ? t.headerBg : "#E8E0D0",
|
||||
paddingLeft: (col: number) => (col === 0 ? 6 : 8),
|
||||
paddingRight: () => 8,
|
||||
paddingTop: () => 4,
|
||||
paddingBottom: () => 4,
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
unbreakable: true,
|
||||
stack: [
|
||||
{
|
||||
margin: [0, 6, 0, 0],
|
||||
columns: [
|
||||
{ width: "*", text: "" },
|
||||
{
|
||||
width: 250,
|
||||
table: {
|
||||
widths: ["*", 110],
|
||||
body: [
|
||||
[
|
||||
{
|
||||
text: "Subtotal",
|
||||
style: "totalsLabel",
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
text: fmt(subTotal),
|
||||
style: "totalsValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: data.tax.label,
|
||||
style: "totalsLabel",
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
{
|
||||
text: fmt(taxAmount),
|
||||
style: "totalsValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
margin: [0, 5, 0, 5],
|
||||
border: [false, false, false, true],
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
text: "TOTAL",
|
||||
style: "totalFinalLabel",
|
||||
fillColor: t.headerBg,
|
||||
margin: [10, 8, 6, 8],
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
{
|
||||
text: fmt(total),
|
||||
style: "totalFinalValue",
|
||||
alignment: "right",
|
||||
noWrap: true,
|
||||
fillColor: t.brandLight,
|
||||
margin: [6, 7, 8, 7],
|
||||
border: [false, false, false, false],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
layout: {
|
||||
hLineWidth: (i: number) => (i >= 1 && i <= 2 ? 0.5 : 0),
|
||||
vLineWidth: () => 0,
|
||||
hLineColor: () => "#E0D6C6",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
margin: [0, 40, 0, 0],
|
||||
columns: [
|
||||
{
|
||||
width: "50%",
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 220,
|
||||
y2: 0,
|
||||
lineWidth: 0.75,
|
||||
lineColor: "#999",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Authorized Signature",
|
||||
fontSize: 7,
|
||||
color: "#888",
|
||||
margin: [0, 3, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "50%",
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{
|
||||
type: "line",
|
||||
x1: 0,
|
||||
y1: 0,
|
||||
x2: 160,
|
||||
y2: 0,
|
||||
lineWidth: 0.75,
|
||||
lineColor: "#999",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
text: "Date",
|
||||
fontSize: 7,
|
||||
color: "#888",
|
||||
margin: [0, 3, 0, 0],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
footer: (currentPage: number, pageCount: number) => ({
|
||||
margin: [0, 0, 0, 0],
|
||||
stack: [
|
||||
{
|
||||
canvas: [
|
||||
{ type: "rect", x: 0, y: 0, w: PAGE_W, h: 44, color: t.footerBg },
|
||||
],
|
||||
},
|
||||
{
|
||||
margin: [MARGIN_L, -38, MARGIN_R, 0],
|
||||
columns: [
|
||||
{
|
||||
width: "*",
|
||||
stack: [
|
||||
{
|
||||
text: [
|
||||
{ text: COMPANY.name, style: "footerBold" },
|
||||
{
|
||||
text: ` · ${COMPANY.licenseInfo}`,
|
||||
style: "footerText",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
width: "auto",
|
||||
text: `Page ${currentPage} of ${pageCount}`,
|
||||
style: "footerText",
|
||||
alignment: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
margin: [MARGIN_L, 4, MARGIN_R, 0],
|
||||
text: DEFAULT_DISCLAIMER,
|
||||
style: "disclaimer",
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
const maybeDoc = printer.createPdfKitDocument(docDefinition as never) as any;
|
||||
const pdfDoc =
|
||||
maybeDoc && typeof maybeDoc.then === "function" ? await maybeDoc : maybeDoc;
|
||||
|
||||
if (!pdfDoc || typeof pdfDoc.on !== "function") {
|
||||
throw new Error("Failed to initialize PDF document stream");
|
||||
}
|
||||
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
try {
|
||||
const chunks: Buffer[] = [];
|
||||
pdfDoc.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
pdfDoc.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
pdfDoc.on("error", reject);
|
||||
if (typeof pdfDoc.end === "function") {
|
||||
pdfDoc.end();
|
||||
} else {
|
||||
reject(new Error("PDF document stream does not support end()"));
|
||||
}
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./generateQuote";
|
||||
export * from "./injectPdfMetadata";
|
||||
@@ -0,0 +1,48 @@
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
export interface DownloadMetadata {
|
||||
downloadedAt: string;
|
||||
downloadedById: string;
|
||||
downloadedByName?: string;
|
||||
downloadedByEmail?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects download-time metadata into an existing PDF's document properties.
|
||||
*
|
||||
* Appends download-specific key:value pairs to the PDF's Keywords field
|
||||
* (matching the semicolon-delimited format used at commit time) and updates
|
||||
* the ModificationDate.
|
||||
*
|
||||
* Returns the modified PDF as a `Uint8Array`.
|
||||
*/
|
||||
export async function injectPdfMetadata(
|
||||
pdfBytes: Buffer | Uint8Array,
|
||||
metadata: DownloadMetadata,
|
||||
): Promise<Uint8Array> {
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
|
||||
// Build new keyword entries in the same format used by generateQuote
|
||||
const newKeywordPairs = [
|
||||
`downloadedAt:${metadata.downloadedAt}`,
|
||||
`downloadedById:${metadata.downloadedById}`,
|
||||
metadata.downloadedByName
|
||||
? `downloadedByName:${metadata.downloadedByName}`
|
||||
: null,
|
||||
metadata.downloadedByEmail
|
||||
? `downloadedByEmail:${metadata.downloadedByEmail}`
|
||||
: null,
|
||||
].filter(Boolean) as string[];
|
||||
|
||||
// Append to existing keywords (preserve commit-time metadata)
|
||||
const existingKeywords = pdfDoc.getKeywords() ?? "";
|
||||
const separator = existingKeywords.length > 0 ? "; " : "";
|
||||
pdfDoc.setKeywords([
|
||||
existingKeywords + separator + newKeywordPairs.join("; "),
|
||||
]);
|
||||
|
||||
// Update modification date to download time
|
||||
pdfDoc.setModificationDate(new Date(metadata.downloadedAt));
|
||||
|
||||
return pdfDoc.save();
|
||||
}
|
||||
@@ -399,11 +399,12 @@ export const PERMISSION_NODES = {
|
||||
description:
|
||||
"Fetch a single opportunity and its sub-resources (products, notes, contacts)",
|
||||
usedIn: [
|
||||
"src/api/sales/[id]/fetch.ts",
|
||||
"src/api/sales/[id]/products.ts",
|
||||
"src/api/sales/[id]/notes.ts",
|
||||
"src/api/sales/[id]/fetchNote.ts",
|
||||
"src/api/sales/[id]/contacts.ts",
|
||||
"src/api/sales/opportunities/[id]/fetch.ts",
|
||||
"src/api/sales/opportunities/[id]/products/fetchAll.ts",
|
||||
"src/api/sales/opportunities/[id]/notes/fetchAll.ts",
|
||||
"src/api/sales/opportunities/[id]/notes/fetch.ts",
|
||||
"src/api/sales/opportunities/[id]/contacts.ts",
|
||||
"src/api/sockets/events/liveQuotePreview.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -411,33 +412,33 @@ export const PERMISSION_NODES = {
|
||||
description:
|
||||
"Fetch multiple opportunities, count, or opportunity types",
|
||||
usedIn: [
|
||||
"src/api/sales/fetchAll.ts",
|
||||
"src/api/sales/count.ts",
|
||||
"src/api/sales/opportunities/fetchAll.ts",
|
||||
"src/api/sales/opportunities/count.ts",
|
||||
"src/api/sales/fetchOpportunityTypes.ts",
|
||||
],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.refresh",
|
||||
description: "Refresh a single opportunity from ConnectWise",
|
||||
usedIn: ["src/api/sales/[id]/refresh.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/refresh.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.create",
|
||||
description: "Create a new note on an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/createNote.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/notes/create.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.update",
|
||||
description: "Update an existing note on an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/updateNote.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/notes/update.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.note.delete",
|
||||
description: "Delete a note from an opportunity",
|
||||
usedIn: ["src/api/sales/[id]/deleteNote.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/notes/delete.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
@@ -445,9 +446,9 @@ export const PERMISSION_NODES = {
|
||||
description:
|
||||
"Update products (forecast items) on an opportunity, including resequencing",
|
||||
usedIn: [
|
||||
"src/api/sales/[id]/resequenceProducts.ts",
|
||||
"src/api/sales/[id]/updateProduct.ts",
|
||||
"src/api/sales/[id]/cancelProduct.ts",
|
||||
"src/api/sales/opportunities/[id]/products/resequence.ts",
|
||||
"src/api/sales/opportunities/[id]/products/update.ts",
|
||||
"src/api/sales/opportunities/[id]/products/cancel.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
@@ -455,7 +456,7 @@ export const PERMISSION_NODES = {
|
||||
node: "sales.opportunity.product.add",
|
||||
description:
|
||||
"Add a new product (forecast item) to an opportunity. Individual fields are gated by sales.opportunity.product.field.<field> permissions.",
|
||||
usedIn: ["src/api/sales/[id]/addProduct.ts"],
|
||||
usedIn: ["src/api/sales/opportunities/[id]/products/add.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
fieldLevelPermissions: [
|
||||
"sales.opportunity.product.field.catalogItem",
|
||||
@@ -481,7 +482,9 @@ export const PERMISSION_NODES = {
|
||||
node: "sales.opportunity.product.add.specialOrder",
|
||||
description:
|
||||
'Add one or more "SPECIAL ORDER" products to an opportunity via the dedicated special-order route.',
|
||||
usedIn: ["src/api/sales/[id]/addSpecialOrderProduct.ts"],
|
||||
usedIn: [
|
||||
"src/api/sales/opportunities/[id]/products/addSpecialOrder.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
@@ -489,11 +492,45 @@ export const PERMISSION_NODES = {
|
||||
description:
|
||||
"Add labor products to an opportunity using the dedicated labor route with Field/Tech catalog selection and pricing inputs.",
|
||||
usedIn: [
|
||||
"src/api/sales/[id]/addLabor.ts",
|
||||
"src/api/sales/[id]/laborOptions.ts",
|
||||
"src/api/sales/opportunities/[id]/products/addLabor.ts",
|
||||
"src/api/sales/opportunities/[id]/products/laborOptions.ts",
|
||||
],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.fetch",
|
||||
description: "Fetch all committed quotes for an opportunity.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchAll.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.commit",
|
||||
description:
|
||||
"Generate and store a finalized quote PDF for an opportunity with regeneration metadata and creator attribution.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/commit.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.preview",
|
||||
description:
|
||||
"Generate a preview-stamped quote PDF for an opportunity without storing it.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/preview.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.download",
|
||||
description:
|
||||
"Download a committed quote PDF. Each download is recorded with timestamp and user info.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/download.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
{
|
||||
node: "sales.opportunity.quote.fetch_downloads",
|
||||
description:
|
||||
"Fetch download/print history for all quotes on an opportunity. Admin-level permission.",
|
||||
usedIn: ["src/api/sales/opportunities/[id]/quotes/fetchDownloads.ts"],
|
||||
dependencies: ["sales.opportunity.fetch"],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -954,6 +991,7 @@ export const PERMISSION_NODES = {
|
||||
"obj.opportunity.site",
|
||||
"obj.opportunity.customerPO",
|
||||
"obj.opportunity.totalSalesTax",
|
||||
"obj.opportunity.probability",
|
||||
"obj.opportunity.location",
|
||||
"obj.opportunity.department",
|
||||
"obj.opportunity.expectedCloseDate",
|
||||
|
||||
Reference in New Issue
Block a user