184 lines
6.1 KiB
TypeScript
184 lines
6.1 KiB
TypeScript
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")) {
|
|
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]));
|
|
|
|
// 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"] }),
|
|
);
|