@open-mercato/core 0.7.1-develop.7122.1.421cefe668 → 0.7.1-develop.7123.1.25365e718d
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import { NextResponse } from "next/server";
|
|
2
3
|
import { z } from "zod";
|
|
4
|
+
import { runWithCacheTenant } from "@open-mercato/cache";
|
|
3
5
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
6
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
7
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
@@ -7,6 +9,12 @@ import { resolveDealsOrganizationIds } from "../../../lib/dealsOrganizationScope
|
|
|
7
9
|
import { resolveOptionalBaseCurrencyCode } from "../../../lib/optionalBaseCurrency.js";
|
|
8
10
|
import { parseBooleanFromUnknown } from "@open-mercato/shared/lib/boolean";
|
|
9
11
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
12
|
+
import {
|
|
13
|
+
buildCollectionTags,
|
|
14
|
+
debugCrudCache,
|
|
15
|
+
isCrudCacheEnabled,
|
|
16
|
+
resolveCrudCache
|
|
17
|
+
} from "@open-mercato/shared/lib/crud/cache";
|
|
10
18
|
import { readQueryParamList } from "@open-mercato/shared/lib/crud/query-params";
|
|
11
19
|
import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
12
20
|
import { fetchStuckDealIds } from "../../../lib/stuckDeals.js";
|
|
@@ -15,6 +23,8 @@ import { findMatchingEntityIdsBySearchTokensAcrossSources } from "../../utils.js
|
|
|
15
23
|
import { E } from "../../../../../generated/entities.ids.generated.js";
|
|
16
24
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
17
25
|
const logger = createLogger("customers");
|
|
26
|
+
const DEALS_AGGREGATE_CACHE_RESOURCE = "customers.deal";
|
|
27
|
+
const DEALS_AGGREGATE_CACHE_TTL_MS = 3e4;
|
|
18
28
|
const metadata = {
|
|
19
29
|
GET: { requireAuth: true, requireFeatures: ["customers.deals.view"] }
|
|
20
30
|
};
|
|
@@ -36,6 +46,29 @@ const querySchema = z.object({
|
|
|
36
46
|
expectedCloseAtFrom: z.string().optional(),
|
|
37
47
|
expectedCloseAtTo: z.string().optional()
|
|
38
48
|
});
|
|
49
|
+
function normalizeStringSet(values) {
|
|
50
|
+
return Array.from(new Set((values ?? []).map((value) => value.trim()))).sort((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
51
|
+
}
|
|
52
|
+
function buildDealsAggregateCacheKey(params) {
|
|
53
|
+
const signature = {
|
|
54
|
+
tenantId: params.tenantId,
|
|
55
|
+
currencyScopeOrganizationId: params.currencyScopeOrganizationId,
|
|
56
|
+
organizationIds: normalizeStringSet(params.organizationIds),
|
|
57
|
+
pipelineId: params.query.pipelineId ?? null,
|
|
58
|
+
status: normalizeStringSet(params.query.status),
|
|
59
|
+
ownerUserId: normalizeStringSet(params.query.ownerUserId),
|
|
60
|
+
personId: normalizeStringSet(params.query.personId),
|
|
61
|
+
companyId: normalizeStringSet(params.query.companyId),
|
|
62
|
+
expectedCloseAtFrom: params.query.expectedCloseAtFrom ?? null,
|
|
63
|
+
expectedCloseAtTo: params.query.expectedCloseAtTo ?? null,
|
|
64
|
+
isOverdue: params.query.isOverdue === true
|
|
65
|
+
};
|
|
66
|
+
const digest = createHash("sha256").update(JSON.stringify(signature)).digest("hex");
|
|
67
|
+
return `customers:deal:aggregate:v1:${digest}`;
|
|
68
|
+
}
|
|
69
|
+
function isDealsAggregateCacheEligible(searchParams) {
|
|
70
|
+
return !searchParams.has("search") && !searchParams.has("isStuck");
|
|
71
|
+
}
|
|
39
72
|
const stageBreakdownByCurrencySchema = z.object({
|
|
40
73
|
currency: z.string(),
|
|
41
74
|
total: z.number(),
|
|
@@ -119,6 +152,37 @@ async function GET(req) {
|
|
|
119
152
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
120
153
|
}
|
|
121
154
|
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId });
|
|
155
|
+
const aggregateCache = isDealsAggregateCacheEligible(params) && isCrudCacheEnabled() ? resolveCrudCache(container) : null;
|
|
156
|
+
const aggregateCacheKey = aggregateCache ? buildDealsAggregateCacheKey({
|
|
157
|
+
tenantId: effectiveTenantId,
|
|
158
|
+
currencyScopeOrganizationId: orgFilterIds[0] ?? null,
|
|
159
|
+
organizationIds: orgFilterIds,
|
|
160
|
+
query: parsed.data
|
|
161
|
+
}) : null;
|
|
162
|
+
if (aggregateCache && aggregateCacheKey) {
|
|
163
|
+
try {
|
|
164
|
+
const cached = await runWithCacheTenant(
|
|
165
|
+
effectiveTenantId,
|
|
166
|
+
() => aggregateCache.get(aggregateCacheKey)
|
|
167
|
+
);
|
|
168
|
+
const cachedResponse = aggregateResponseSchema.safeParse(cached);
|
|
169
|
+
if (cachedResponse.success) {
|
|
170
|
+
return NextResponse.json(cachedResponse.data);
|
|
171
|
+
}
|
|
172
|
+
if (cached !== null && cached !== void 0) {
|
|
173
|
+
debugCrudCache("get-invalid", {
|
|
174
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
175
|
+
key: aggregateCacheKey
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
debugCrudCache("get", {
|
|
180
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
181
|
+
key: aggregateCacheKey,
|
|
182
|
+
error: err instanceof Error ? err.message : String(err)
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
122
186
|
const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(
|
|
123
187
|
container,
|
|
124
188
|
effectiveTenantId,
|
|
@@ -331,6 +395,27 @@ async function GET(req) {
|
|
|
331
395
|
baseCurrencyCode,
|
|
332
396
|
perStage: Array.from(stageMap.values())
|
|
333
397
|
};
|
|
398
|
+
if (aggregateCache && aggregateCacheKey) {
|
|
399
|
+
try {
|
|
400
|
+
await runWithCacheTenant(
|
|
401
|
+
effectiveTenantId,
|
|
402
|
+
() => aggregateCache.set(aggregateCacheKey, response, {
|
|
403
|
+
ttl: DEALS_AGGREGATE_CACHE_TTL_MS,
|
|
404
|
+
tags: buildCollectionTags(
|
|
405
|
+
DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
406
|
+
effectiveTenantId,
|
|
407
|
+
normalizeStringSet(orgFilterIds)
|
|
408
|
+
)
|
|
409
|
+
})
|
|
410
|
+
);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
debugCrudCache("store", {
|
|
413
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
414
|
+
key: aggregateCacheKey,
|
|
415
|
+
error: err instanceof Error ? err.message : String(err)
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
}
|
|
334
419
|
return NextResponse.json(response);
|
|
335
420
|
}
|
|
336
421
|
export {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/customers/api/deals/aggregate/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { readQueryParamList } from '@open-mercato/shared/lib/crud/query-params'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { expandDealStatusAliases } from '../../../lib/dealStatus'\nimport { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n}\n\nconst querySchema = z.object({\n pipelineId: z.string().uuid().optional(),\n search: z.string().optional(),\n status: z.array(z.string().max(50)).max(20).optional(),\n ownerUserId: z.array(z.string().uuid()).optional(),\n personId: z.array(z.string().uuid()).optional(),\n companyId: z.array(z.string().uuid()).optional(),\n isStuck: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n isOverdue: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n})\n\ntype StageBreakdownByCurrency = {\n currency: string\n total: number\n count: number\n}\n\ntype StageAggregate = {\n stageId: string\n count: number\n openCount: number\n totalInBaseCurrency: number\n byCurrency: StageBreakdownByCurrency[]\n /**\n * `true` when every currency present in `byCurrency` was either the base currency or\n * had a usable exchange rate at request time. `false` when at least one currency in\n * `byCurrency` could not be resolved to base \u2014 the lane header should surface a\n * \"partial\" indicator in that case so the converted total isn't read as authoritative.\n */\n convertedAll: boolean\n /**\n * Currencies present in `byCurrency` that had NO exchange rate to the base currency.\n * Empty when conversion was complete or there is no base currency configured. The\n * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.\n */\n missingRateCurrencies: string[]\n}\n\ntype AggregateResponse = {\n baseCurrencyCode: string | null\n perStage: StageAggregate[]\n}\n\nconst stageBreakdownByCurrencySchema = z.object({\n currency: z.string(),\n total: z.number(),\n count: z.number(),\n})\n\nconst stageAggregateSchema = z.object({\n stageId: z.string(),\n count: z.number(),\n openCount: z.number(),\n totalInBaseCurrency: z.number(),\n byCurrency: z.array(stageBreakdownByCurrencySchema),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n})\n\nconst aggregateResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n perStage: z.array(stageAggregateSchema),\n})\n\nconst aggregateErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals aggregate per pipeline stage',\n methods: {\n GET: {\n summary: 'Per-stage counts and currency totals for kanban lane headers',\n description:\n 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },\n { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },\n ],\n },\n },\n}\n\nfunction readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {\n const values = readQueryParamList(searchParams, key)\n return values.length ? values : null\n}\n\nfunction restrictToIds(where: string[], values: Array<string | number | null>, ids: string[]) {\n if (ids.length === 0) {\n where.push('id = ?')\n values.push('00000000-0000-0000-0000-000000000000')\n return\n }\n const placeholders = ids.map(() => '?').join(',')\n where.push(`id IN (${placeholders})`)\n values.push(...ids)\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const params = url.searchParams\n const parsed = querySchema.safeParse({\n pipelineId: params.get('pipelineId') ?? undefined,\n search: params.get('search') ?? undefined,\n status: readArrayParam(params, 'status') ?? undefined,\n ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,\n personId: readArrayParam(params, 'personId') ?? undefined,\n companyId: readArrayParam(params, 'companyId') ?? undefined,\n isStuck: params.get('isStuck') ?? undefined,\n isOverdue: params.get('isOverdue') ?? undefined,\n expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,\n expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\n // Resolve organization scope from the request so multi-org operators can aggregate\n // deals for any org their RBAC scope permits (matching the deal detail route at\n // [id]/route.ts:360). Falls back to `auth.orgId` when no scope cookie is set or\n // when rbacService cannot be resolved (e.g. in unit tests with a minimal container).\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const effectiveTenantId = scope.tenantId ?? auth.tenantId\n if (!effectiveTenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })\n\n // Raw SQL is used here intentionally \u2014 the route only projects non-encrypted columns\n // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It\n // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an\n // aggregate that never reads `title`/`description`. The search path still relies on\n // the token index above to find matching deals when encrypted columns are involved.\n const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(\n container,\n effectiveTenantId,\n orgFilterIds[0],\n )\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n // Non-empty input always expands to a non-empty set, so this always narrows.\n const expandedStatuses = expandDealStatusAliases(parsed.data.status)\n const placeholders = expandedStatuses.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...expandedStatuses)\n }\n if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {\n const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')\n where.push(`owner_user_id IN (${placeholders})`)\n values.push(...parsed.data.ownerUserId)\n }\n if (parsed.data.expectedCloseAtFrom) {\n where.push('expected_close_at >= ?')\n values.push(parsed.data.expectedCloseAtFrom)\n }\n if (parsed.data.expectedCloseAtTo) {\n where.push('expected_close_at <= ?')\n values.push(parsed.data.expectedCloseAtTo)\n }\n if (parsed.data.isOverdue) {\n // Mirror the list route's precedence: the caller-supplied status filter wins, and\n // status='open' is injected only when none was provided.\n const hasCallerStatus = !!parsed.data.status?.length\n if (hasCallerStatus) {\n where.push('expected_close_at < CURRENT_DATE')\n } else {\n where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\n }\n }\n if (parsed.data.isStuck) {\n // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the\n // cards rendered inside each lane agree on the same definition of \"stuck\". Empty result\n // \u2192 narrow to a sentinel UUID so the WHERE clause collapses to zero rows.\n const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)\n restrictToIds(where, values, stuckIds)\n }\n if (parsed.data.personId && parsed.data.personId.length) {\n const placeholders = parsed.data.personId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.personId)\n }\n if (parsed.data.companyId && parsed.data.companyId.length) {\n const placeholders = parsed.data.companyId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.companyId)\n }\n\n const whereSql = where.join(' AND ')\n\n // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.\n const rows = await em.getConnection().execute<\n Array<{\n stage_id: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n open_count: string | number\n }>\n >(\n `SELECT\n pipeline_stage_id AS stage_id,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n COUNT(*) FILTER (WHERE status = 'open') AS open_count\n FROM customer_deals\n WHERE ${whereSql}\n GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,\n values,\n )\n\n // Reduce to per-stage aggregates\n const stageMap = new Map<string, StageAggregate>()\n for (const row of rows) {\n const stageId = row.stage_id ?? '__unassigned'\n const total = Number(row.total ?? 0)\n const count = Number(row.count ?? 0)\n const openCount = Number(row.open_count ?? 0)\n const currency = (row.currency ?? '').toString().trim()\n\n if (!stageMap.has(stageId)) {\n stageMap.set(stageId, {\n stageId,\n count: 0,\n openCount: 0,\n totalInBaseCurrency: 0,\n byCurrency: [],\n convertedAll: true,\n missingRateCurrencies: [],\n })\n }\n const agg = stageMap.get(stageId)!\n agg.count += count\n agg.openCount += openCount\n // Include any row that has a real currency code, even when its summed amount is\n // zero \u2014 `byCurrency` should agree with `count` so the per-currency breakdown\n // doesn't silently drop deals whose amount happens to be zero. Rows with no\n // currency code at all still get folded into the stage totals via `count`.\n if (currency.length > 0) {\n agg.byCurrency.push({ currency, total, count })\n }\n }\n\n // Convert to base currency where possible\n if (baseCurrencyCode) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n const today = new Date()\n const distinctCurrencies = new Set<string>()\n for (const agg of stageMap.values()) {\n for (const row of agg.byCurrency) {\n if (row.currency && row.currency !== baseCurrencyCode) {\n distinctCurrencies.add(row.currency)\n }\n }\n }\n\n const rateCache = new Map<string, number>()\n if (exchange && distinctCurrencies.size > 0) {\n const pairs = Array.from(distinctCurrencies).map((c) => ({\n fromCurrencyCode: c,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n const results = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n for (const [key, rateResult] of results) {\n if (rateResult.rates.length > 0) {\n // Pick the first matching rate (sources are equivalent for display purposes)\n const rate = Number(rateResult.rates[0].rate)\n if (Number.isFinite(rate) && rate > 0) {\n rateCache.set(key, rate)\n }\n }\n }\n } catch (err) {\n // Swallow \u2014 partial totals are still useful and we'll fall back to currency-native\n // sums. Logging at warn level so operators can correlate missing-rate disclosures in\n // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.aggregate', err })\n }\n }\n\n for (const agg of stageMap.values()) {\n let totalBase = 0\n let convertedAll = true\n const missingRateCurrencies: string[] = []\n for (const row of agg.byCurrency) {\n if (!row.currency) continue\n if (row.currency === baseCurrencyCode) {\n totalBase += row.total\n continue\n }\n const key = `${row.currency}/${baseCurrencyCode}`\n const rate = rateCache.get(key)\n if (rate !== undefined) {\n totalBase += row.total * rate\n } else {\n convertedAll = false\n if (!missingRateCurrencies.includes(row.currency)) {\n missingRateCurrencies.push(row.currency)\n }\n }\n }\n // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.\n // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete\n // conversion from a partial one and disclose which currencies were excluded.\n agg.totalInBaseCurrency = Math.round(totalBase)\n agg.convertedAll = convertedAll\n agg.missingRateCurrencies = missingRateCurrencies\n }\n } else {\n // No base currency configured for the tenant \u2014 surface this by marking every stage as\n // \"not converted\" with all currencies flagged as missing rates. This way the client UI\n // can fall back to a per-currency breakdown without trying to render a board total.\n for (const agg of stageMap.values()) {\n const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)\n if (present.length > 0) {\n agg.convertedAll = false\n agg.missingRateCurrencies = Array.from(new Set(present))\n }\n }\n }\n\n // Sort byCurrency rows by total descending for stable display\n for (const agg of stageMap.values()) {\n agg.byCurrency.sort((a, b) => b.total - a.total)\n }\n\n const response: AggregateResponse = {\n baseCurrencyCode,\n perStage: Array.from(stageMap.values()),\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { createHash } from 'node:crypto'\nimport { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant } from '@open-mercato/cache'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport {\n buildCollectionTags,\n debugCrudCache,\n isCrudCacheEnabled,\n resolveCrudCache,\n} from '@open-mercato/shared/lib/crud/cache'\nimport { readQueryParamList } from '@open-mercato/shared/lib/crud/query-params'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { expandDealStatusAliases } from '../../../lib/dealStatus'\nimport { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\nconst DEALS_AGGREGATE_CACHE_RESOURCE = 'customers.deal'\nconst DEALS_AGGREGATE_CACHE_TTL_MS = 30_000\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n}\n\nconst querySchema = z.object({\n pipelineId: z.string().uuid().optional(),\n search: z.string().optional(),\n status: z.array(z.string().max(50)).max(20).optional(),\n ownerUserId: z.array(z.string().uuid()).optional(),\n personId: z.array(z.string().uuid()).optional(),\n companyId: z.array(z.string().uuid()).optional(),\n isStuck: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n isOverdue: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n})\n\ntype AggregateQuery = z.infer<typeof querySchema>\n\nfunction normalizeStringSet(values: string[] | undefined): string[] {\n return Array.from(new Set((values ?? []).map((value) => value.trim())))\n .sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))\n}\n\nfunction buildDealsAggregateCacheKey(params: {\n tenantId: string\n currencyScopeOrganizationId: string | null\n organizationIds: string[]\n query: AggregateQuery\n}): string {\n const signature = {\n tenantId: params.tenantId,\n currencyScopeOrganizationId: params.currencyScopeOrganizationId,\n organizationIds: normalizeStringSet(params.organizationIds),\n pipelineId: params.query.pipelineId ?? null,\n status: normalizeStringSet(params.query.status),\n ownerUserId: normalizeStringSet(params.query.ownerUserId),\n personId: normalizeStringSet(params.query.personId),\n companyId: normalizeStringSet(params.query.companyId),\n expectedCloseAtFrom: params.query.expectedCloseAtFrom ?? null,\n expectedCloseAtTo: params.query.expectedCloseAtTo ?? null,\n isOverdue: params.query.isOverdue === true,\n }\n const digest = createHash('sha256').update(JSON.stringify(signature)).digest('hex')\n return `customers:deal:aggregate:v1:${digest}`\n}\n\nfunction isDealsAggregateCacheEligible(searchParams: URLSearchParams): boolean {\n return !searchParams.has('search') && !searchParams.has('isStuck')\n}\n\ntype StageBreakdownByCurrency = {\n currency: string\n total: number\n count: number\n}\n\ntype StageAggregate = {\n stageId: string\n count: number\n openCount: number\n totalInBaseCurrency: number\n byCurrency: StageBreakdownByCurrency[]\n /**\n * `true` when every currency present in `byCurrency` was either the base currency or\n * had a usable exchange rate at request time. `false` when at least one currency in\n * `byCurrency` could not be resolved to base \u2014 the lane header should surface a\n * \"partial\" indicator in that case so the converted total isn't read as authoritative.\n */\n convertedAll: boolean\n /**\n * Currencies present in `byCurrency` that had NO exchange rate to the base currency.\n * Empty when conversion was complete or there is no base currency configured. The\n * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.\n */\n missingRateCurrencies: string[]\n}\n\ntype AggregateResponse = {\n baseCurrencyCode: string | null\n perStage: StageAggregate[]\n}\n\nconst stageBreakdownByCurrencySchema = z.object({\n currency: z.string(),\n total: z.number(),\n count: z.number(),\n})\n\nconst stageAggregateSchema = z.object({\n stageId: z.string(),\n count: z.number(),\n openCount: z.number(),\n totalInBaseCurrency: z.number(),\n byCurrency: z.array(stageBreakdownByCurrencySchema),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n})\n\nconst aggregateResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n perStage: z.array(stageAggregateSchema),\n})\n\nconst aggregateErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals aggregate per pipeline stage',\n methods: {\n GET: {\n summary: 'Per-stage counts and currency totals for kanban lane headers',\n description:\n 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },\n { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },\n ],\n },\n },\n}\n\nfunction readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {\n const values = readQueryParamList(searchParams, key)\n return values.length ? values : null\n}\n\nfunction restrictToIds(where: string[], values: Array<string | number | null>, ids: string[]) {\n if (ids.length === 0) {\n where.push('id = ?')\n values.push('00000000-0000-0000-0000-000000000000')\n return\n }\n const placeholders = ids.map(() => '?').join(',')\n where.push(`id IN (${placeholders})`)\n values.push(...ids)\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const url = new URL(req.url)\n const params = url.searchParams\n const parsed = querySchema.safeParse({\n pipelineId: params.get('pipelineId') ?? undefined,\n search: params.get('search') ?? undefined,\n status: readArrayParam(params, 'status') ?? undefined,\n ownerUserId: readArrayParam(params, 'ownerUserId') ?? undefined,\n personId: readArrayParam(params, 'personId') ?? undefined,\n companyId: readArrayParam(params, 'companyId') ?? undefined,\n isStuck: params.get('isStuck') ?? undefined,\n isOverdue: params.get('isOverdue') ?? undefined,\n expectedCloseAtFrom: params.get('expectedCloseAtFrom') ?? undefined,\n expectedCloseAtTo: params.get('expectedCloseAtTo') ?? undefined,\n })\n if (!parsed.success) {\n return NextResponse.json({ error: 'Invalid query parameters' }, { status: 400 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\n // Resolve organization scope from the request so multi-org operators can aggregate\n // deals for any org their RBAC scope permits (matching the deal detail route at\n // [id]/route.ts:360). Falls back to `auth.orgId` when no scope cookie is set or\n // when rbacService cannot be resolved (e.g. in unit tests with a minimal container).\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const effectiveTenantId = scope.tenantId ?? auth.tenantId\n if (!effectiveTenantId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })\n const aggregateCache = isDealsAggregateCacheEligible(params) && isCrudCacheEnabled()\n ? resolveCrudCache(container)\n : null\n const aggregateCacheKey = aggregateCache\n ? buildDealsAggregateCacheKey({\n tenantId: effectiveTenantId,\n currencyScopeOrganizationId: orgFilterIds[0] ?? null,\n organizationIds: orgFilterIds,\n query: parsed.data,\n })\n : null\n\n if (aggregateCache && aggregateCacheKey) {\n try {\n const cached = await runWithCacheTenant(\n effectiveTenantId,\n () => aggregateCache.get(aggregateCacheKey),\n )\n const cachedResponse = aggregateResponseSchema.safeParse(cached)\n if (cachedResponse.success) {\n return NextResponse.json(cachedResponse.data)\n }\n if (cached !== null && cached !== undefined) {\n debugCrudCache('get-invalid', {\n resource: DEALS_AGGREGATE_CACHE_RESOURCE,\n key: aggregateCacheKey,\n })\n }\n } catch (err) {\n debugCrudCache('get', {\n resource: DEALS_AGGREGATE_CACHE_RESOURCE,\n key: aggregateCacheKey,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n }\n\n // Raw SQL is used here intentionally \u2014 the route only projects non-encrypted columns\n // (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It\n // avoids the per-row decryption cost that would be paid by `findWithDecryption` for an\n // aggregate that never reads `title`/`description`. The search path still relies on\n // the token index above to find matching deals when encrypted columns are involved.\n const baseCurrencyCode = await resolveOptionalBaseCurrencyCode(\n container,\n effectiveTenantId,\n orgFilterIds[0],\n )\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n // Non-empty input always expands to a non-empty set, so this always narrows.\n const expandedStatuses = expandDealStatusAliases(parsed.data.status)\n const placeholders = expandedStatuses.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...expandedStatuses)\n }\n if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {\n const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')\n where.push(`owner_user_id IN (${placeholders})`)\n values.push(...parsed.data.ownerUserId)\n }\n if (parsed.data.expectedCloseAtFrom) {\n where.push('expected_close_at >= ?')\n values.push(parsed.data.expectedCloseAtFrom)\n }\n if (parsed.data.expectedCloseAtTo) {\n where.push('expected_close_at <= ?')\n values.push(parsed.data.expectedCloseAtTo)\n }\n if (parsed.data.isOverdue) {\n // Mirror the list route's precedence: the caller-supplied status filter wins, and\n // status='open' is injected only when none was provided.\n const hasCallerStatus = !!parsed.data.status?.length\n if (hasCallerStatus) {\n where.push('expected_close_at < CURRENT_DATE')\n } else {\n where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\n }\n }\n if (parsed.data.isStuck) {\n // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the\n // cards rendered inside each lane agree on the same definition of \"stuck\". Empty result\n // \u2192 narrow to a sentinel UUID so the WHERE clause collapses to zero rows.\n const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)\n restrictToIds(where, values, stuckIds)\n }\n if (parsed.data.personId && parsed.data.personId.length) {\n const placeholders = parsed.data.personId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.personId)\n }\n if (parsed.data.companyId && parsed.data.companyId.length) {\n const placeholders = parsed.data.companyId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.companyId)\n }\n\n const whereSql = where.join(' AND ')\n\n // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.\n const rows = await em.getConnection().execute<\n Array<{\n stage_id: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n open_count: string | number\n }>\n >(\n `SELECT\n pipeline_stage_id AS stage_id,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n COUNT(*) FILTER (WHERE status = 'open') AS open_count\n FROM customer_deals\n WHERE ${whereSql}\n GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,\n values,\n )\n\n // Reduce to per-stage aggregates\n const stageMap = new Map<string, StageAggregate>()\n for (const row of rows) {\n const stageId = row.stage_id ?? '__unassigned'\n const total = Number(row.total ?? 0)\n const count = Number(row.count ?? 0)\n const openCount = Number(row.open_count ?? 0)\n const currency = (row.currency ?? '').toString().trim()\n\n if (!stageMap.has(stageId)) {\n stageMap.set(stageId, {\n stageId,\n count: 0,\n openCount: 0,\n totalInBaseCurrency: 0,\n byCurrency: [],\n convertedAll: true,\n missingRateCurrencies: [],\n })\n }\n const agg = stageMap.get(stageId)!\n agg.count += count\n agg.openCount += openCount\n // Include any row that has a real currency code, even when its summed amount is\n // zero \u2014 `byCurrency` should agree with `count` so the per-currency breakdown\n // doesn't silently drop deals whose amount happens to be zero. Rows with no\n // currency code at all still get folded into the stage totals via `count`.\n if (currency.length > 0) {\n agg.byCurrency.push({ currency, total, count })\n }\n }\n\n // Convert to base currency where possible\n if (baseCurrencyCode) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n const today = new Date()\n const distinctCurrencies = new Set<string>()\n for (const agg of stageMap.values()) {\n for (const row of agg.byCurrency) {\n if (row.currency && row.currency !== baseCurrencyCode) {\n distinctCurrencies.add(row.currency)\n }\n }\n }\n\n const rateCache = new Map<string, number>()\n if (exchange && distinctCurrencies.size > 0) {\n const pairs = Array.from(distinctCurrencies).map((c) => ({\n fromCurrencyCode: c,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n const results = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n for (const [key, rateResult] of results) {\n if (rateResult.rates.length > 0) {\n // Pick the first matching rate (sources are equivalent for display purposes)\n const rate = Number(rateResult.rates[0].rate)\n if (Number.isFinite(rate) && rate > 0) {\n rateCache.set(key, rate)\n }\n }\n }\n } catch (err) {\n // Swallow \u2014 partial totals are still useful and we'll fall back to currency-native\n // sums. Logging at warn level so operators can correlate missing-rate disclosures in\n // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.aggregate', err })\n }\n }\n\n for (const agg of stageMap.values()) {\n let totalBase = 0\n let convertedAll = true\n const missingRateCurrencies: string[] = []\n for (const row of agg.byCurrency) {\n if (!row.currency) continue\n if (row.currency === baseCurrencyCode) {\n totalBase += row.total\n continue\n }\n const key = `${row.currency}/${baseCurrencyCode}`\n const rate = rateCache.get(key)\n if (rate !== undefined) {\n totalBase += row.total * rate\n } else {\n convertedAll = false\n if (!missingRateCurrencies.includes(row.currency)) {\n missingRateCurrencies.push(row.currency)\n }\n }\n }\n // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.\n // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete\n // conversion from a partial one and disclose which currencies were excluded.\n agg.totalInBaseCurrency = Math.round(totalBase)\n agg.convertedAll = convertedAll\n agg.missingRateCurrencies = missingRateCurrencies\n }\n } else {\n // No base currency configured for the tenant \u2014 surface this by marking every stage as\n // \"not converted\" with all currencies flagged as missing rates. This way the client UI\n // can fall back to a per-currency breakdown without trying to render a board total.\n for (const agg of stageMap.values()) {\n const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)\n if (present.length > 0) {\n agg.convertedAll = false\n agg.missingRateCurrencies = Array.from(new Set(present))\n }\n }\n }\n\n // Sort byCurrency rows by total descending for stable display\n for (const agg of stageMap.values()) {\n agg.byCurrency.sort((a, b) => b.total - a.total)\n }\n\n const response: AggregateResponse = {\n baseCurrencyCode,\n perStage: Array.from(stageMap.values()),\n }\n\n if (aggregateCache && aggregateCacheKey) {\n try {\n await runWithCacheTenant(\n effectiveTenantId,\n () => aggregateCache.set(aggregateCacheKey, response, {\n ttl: DEALS_AGGREGATE_CACHE_TTL_MS,\n tags: buildCollectionTags(\n DEALS_AGGREGATE_CACHE_RESOURCE,\n effectiveTenantId,\n normalizeStringSet(orgFilterIds),\n ),\n }),\n )\n } catch (err) {\n debugCrudCache('store', {\n resource: DEALS_AGGREGATE_CACHE_RESOURCE,\n key: aggregateCacheKey,\n error: err instanceof Error ? err.message : String(err),\n })\n }\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.7.1-develop.
|
|
3
|
+
"version": "0.7.1-develop.7123.1.25365e718d",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -252,16 +252,16 @@
|
|
|
252
252
|
"zod": "^4.4.3"
|
|
253
253
|
},
|
|
254
254
|
"peerDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
256
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
257
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7123.1.25365e718d",
|
|
256
|
+
"@open-mercato/shared": "0.7.1-develop.7123.1.25365e718d",
|
|
257
|
+
"@open-mercato/ui": "0.7.1-develop.7123.1.25365e718d",
|
|
258
258
|
"react": "^19.0.0",
|
|
259
259
|
"react-dom": "^19.0.0"
|
|
260
260
|
},
|
|
261
261
|
"devDependencies": {
|
|
262
|
-
"@open-mercato/ai-assistant": "0.7.1-develop.
|
|
263
|
-
"@open-mercato/shared": "0.7.1-develop.
|
|
264
|
-
"@open-mercato/ui": "0.7.1-develop.
|
|
262
|
+
"@open-mercato/ai-assistant": "0.7.1-develop.7123.1.25365e718d",
|
|
263
|
+
"@open-mercato/shared": "0.7.1-develop.7123.1.25365e718d",
|
|
264
|
+
"@open-mercato/ui": "0.7.1-develop.7123.1.25365e718d",
|
|
265
265
|
"@testing-library/dom": "^10.4.1",
|
|
266
266
|
"@testing-library/jest-dom": "^7.0.0",
|
|
267
267
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
1
2
|
import { NextResponse } from 'next/server'
|
|
2
3
|
import { z } from 'zod'
|
|
3
4
|
import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
|
|
4
5
|
import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
|
|
6
|
+
import { runWithCacheTenant } from '@open-mercato/cache'
|
|
5
7
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
8
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
9
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
@@ -11,6 +13,12 @@ import type { ExchangeRateService } from '@open-mercato/core/modules/currencies/
|
|
|
11
13
|
import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
|
|
12
14
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
13
15
|
import type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'
|
|
16
|
+
import {
|
|
17
|
+
buildCollectionTags,
|
|
18
|
+
debugCrudCache,
|
|
19
|
+
isCrudCacheEnabled,
|
|
20
|
+
resolveCrudCache,
|
|
21
|
+
} from '@open-mercato/shared/lib/crud/cache'
|
|
14
22
|
import { readQueryParamList } from '@open-mercato/shared/lib/crud/query-params'
|
|
15
23
|
import { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'
|
|
16
24
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
@@ -21,6 +29,8 @@ import { E } from '#generated/entities.ids.generated'
|
|
|
21
29
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
22
30
|
|
|
23
31
|
const logger = createLogger('customers')
|
|
32
|
+
const DEALS_AGGREGATE_CACHE_RESOURCE = 'customers.deal'
|
|
33
|
+
const DEALS_AGGREGATE_CACHE_TTL_MS = 30_000
|
|
24
34
|
|
|
25
35
|
export const metadata = {
|
|
26
36
|
GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },
|
|
@@ -45,6 +55,40 @@ const querySchema = z.object({
|
|
|
45
55
|
expectedCloseAtTo: z.string().optional(),
|
|
46
56
|
})
|
|
47
57
|
|
|
58
|
+
type AggregateQuery = z.infer<typeof querySchema>
|
|
59
|
+
|
|
60
|
+
function normalizeStringSet(values: string[] | undefined): string[] {
|
|
61
|
+
return Array.from(new Set((values ?? []).map((value) => value.trim())))
|
|
62
|
+
.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function buildDealsAggregateCacheKey(params: {
|
|
66
|
+
tenantId: string
|
|
67
|
+
currencyScopeOrganizationId: string | null
|
|
68
|
+
organizationIds: string[]
|
|
69
|
+
query: AggregateQuery
|
|
70
|
+
}): string {
|
|
71
|
+
const signature = {
|
|
72
|
+
tenantId: params.tenantId,
|
|
73
|
+
currencyScopeOrganizationId: params.currencyScopeOrganizationId,
|
|
74
|
+
organizationIds: normalizeStringSet(params.organizationIds),
|
|
75
|
+
pipelineId: params.query.pipelineId ?? null,
|
|
76
|
+
status: normalizeStringSet(params.query.status),
|
|
77
|
+
ownerUserId: normalizeStringSet(params.query.ownerUserId),
|
|
78
|
+
personId: normalizeStringSet(params.query.personId),
|
|
79
|
+
companyId: normalizeStringSet(params.query.companyId),
|
|
80
|
+
expectedCloseAtFrom: params.query.expectedCloseAtFrom ?? null,
|
|
81
|
+
expectedCloseAtTo: params.query.expectedCloseAtTo ?? null,
|
|
82
|
+
isOverdue: params.query.isOverdue === true,
|
|
83
|
+
}
|
|
84
|
+
const digest = createHash('sha256').update(JSON.stringify(signature)).digest('hex')
|
|
85
|
+
return `customers:deal:aggregate:v1:${digest}`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function isDealsAggregateCacheEligible(searchParams: URLSearchParams): boolean {
|
|
89
|
+
return !searchParams.has('search') && !searchParams.has('isStuck')
|
|
90
|
+
}
|
|
91
|
+
|
|
48
92
|
type StageBreakdownByCurrency = {
|
|
49
93
|
currency: string
|
|
50
94
|
total: number
|
|
@@ -175,6 +219,42 @@ export async function GET(req: Request) {
|
|
|
175
219
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
176
220
|
}
|
|
177
221
|
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })
|
|
222
|
+
const aggregateCache = isDealsAggregateCacheEligible(params) && isCrudCacheEnabled()
|
|
223
|
+
? resolveCrudCache(container)
|
|
224
|
+
: null
|
|
225
|
+
const aggregateCacheKey = aggregateCache
|
|
226
|
+
? buildDealsAggregateCacheKey({
|
|
227
|
+
tenantId: effectiveTenantId,
|
|
228
|
+
currencyScopeOrganizationId: orgFilterIds[0] ?? null,
|
|
229
|
+
organizationIds: orgFilterIds,
|
|
230
|
+
query: parsed.data,
|
|
231
|
+
})
|
|
232
|
+
: null
|
|
233
|
+
|
|
234
|
+
if (aggregateCache && aggregateCacheKey) {
|
|
235
|
+
try {
|
|
236
|
+
const cached = await runWithCacheTenant(
|
|
237
|
+
effectiveTenantId,
|
|
238
|
+
() => aggregateCache.get(aggregateCacheKey),
|
|
239
|
+
)
|
|
240
|
+
const cachedResponse = aggregateResponseSchema.safeParse(cached)
|
|
241
|
+
if (cachedResponse.success) {
|
|
242
|
+
return NextResponse.json(cachedResponse.data)
|
|
243
|
+
}
|
|
244
|
+
if (cached !== null && cached !== undefined) {
|
|
245
|
+
debugCrudCache('get-invalid', {
|
|
246
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
247
|
+
key: aggregateCacheKey,
|
|
248
|
+
})
|
|
249
|
+
}
|
|
250
|
+
} catch (err) {
|
|
251
|
+
debugCrudCache('get', {
|
|
252
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
253
|
+
key: aggregateCacheKey,
|
|
254
|
+
error: err instanceof Error ? err.message : String(err),
|
|
255
|
+
})
|
|
256
|
+
}
|
|
257
|
+
}
|
|
178
258
|
|
|
179
259
|
// Raw SQL is used here intentionally — the route only projects non-encrypted columns
|
|
180
260
|
// (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It
|
|
@@ -443,5 +523,27 @@ export async function GET(req: Request) {
|
|
|
443
523
|
perStage: Array.from(stageMap.values()),
|
|
444
524
|
}
|
|
445
525
|
|
|
526
|
+
if (aggregateCache && aggregateCacheKey) {
|
|
527
|
+
try {
|
|
528
|
+
await runWithCacheTenant(
|
|
529
|
+
effectiveTenantId,
|
|
530
|
+
() => aggregateCache.set(aggregateCacheKey, response, {
|
|
531
|
+
ttl: DEALS_AGGREGATE_CACHE_TTL_MS,
|
|
532
|
+
tags: buildCollectionTags(
|
|
533
|
+
DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
534
|
+
effectiveTenantId,
|
|
535
|
+
normalizeStringSet(orgFilterIds),
|
|
536
|
+
),
|
|
537
|
+
}),
|
|
538
|
+
)
|
|
539
|
+
} catch (err) {
|
|
540
|
+
debugCrudCache('store', {
|
|
541
|
+
resource: DEALS_AGGREGATE_CACHE_RESOURCE,
|
|
542
|
+
key: aggregateCacheKey,
|
|
543
|
+
error: err instanceof Error ? err.message : String(err),
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
446
548
|
return NextResponse.json(response)
|
|
447
549
|
}
|