@open-mercato/core 0.6.7-develop.6589.1.f8748e07d4 → 0.6.7-develop.6592.1.7f69fa2454
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 +1 -1
- package/dist/modules/customers/api/deals/aggregate/route.js +4 -3
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/summary/route.js +4 -3
- package/dist/modules/customers/api/deals/summary/route.js.map +2 -2
- package/dist/modules/customers/lib/dealsOrganizationScope.js +21 -0
- package/dist/modules/customers/lib/dealsOrganizationScope.js.map +7 -0
- package/dist/modules/workflows/workflows.js +2 -2
- package/dist/modules/workflows/workflows.js.map +1 -1
- package/package.json +7 -7
- package/src/modules/customers/api/deals/aggregate/route.ts +4 -7
- package/src/modules/customers/api/deals/summary/route.ts +4 -7
- package/src/modules/customers/lib/dealsOrganizationScope.ts +40 -0
- package/src/modules/workflows/workflows.ts +2 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
|
+
import { resolveDealsOrganizationIds } from "../../../lib/dealsOrganizationScope.js";
|
|
6
7
|
import { parseBooleanFromUnknown } from "@open-mercato/shared/lib/boolean";
|
|
7
8
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
8
9
|
import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encryption/toggles";
|
|
@@ -90,7 +91,7 @@ function restrictToIds(where, values, ids) {
|
|
|
90
91
|
}
|
|
91
92
|
async function GET(req) {
|
|
92
93
|
const auth = await getAuthFromRequest(req);
|
|
93
|
-
if (!auth?.tenantId
|
|
94
|
+
if (!auth?.tenantId) {
|
|
94
95
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
95
96
|
}
|
|
96
97
|
const url = new URL(req.url);
|
|
@@ -114,10 +115,10 @@ async function GET(req) {
|
|
|
114
115
|
const em = container.resolve("em");
|
|
115
116
|
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
116
117
|
const effectiveTenantId = scope.tenantId ?? auth.tenantId;
|
|
117
|
-
|
|
118
|
-
if (!effectiveTenantId || orgFilterIds.length === 0) {
|
|
118
|
+
if (!effectiveTenantId) {
|
|
119
119
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
120
120
|
}
|
|
121
|
+
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId });
|
|
121
122
|
const baseCurrency = await em.getConnection().execute(
|
|
122
123
|
`SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,
|
|
123
124
|
[effectiveTenantId, orgFilterIds[0]]
|
|
@@ -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 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 { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\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.enum(['open', 'closed', 'win', 'loose'])).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 all = searchParams.getAll(key)\n if (!all.length) return null\n const flat = all.flatMap((v) => v.split(','))\n const trimmed = flat.map((s) => s.trim()).filter(Boolean)\n return trimmed.length ? trimmed : 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 || !auth.orgId) {\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 const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0\n ? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)\n : auth.orgId\n ? [auth.orgId]\n : []\n if (!effectiveTenantId || orgFilterIds.length === 0) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\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 baseCurrency = await em.getConnection().execute<Array<{ code: string }>>(\n `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,\n [effectiveTenantId, orgFilterIds[0]],\n )\n const baseCurrencyCode = baseCurrency[0]?.code ?? null\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 const placeholders = parsed.data.status.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...parsed.data.status)\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 where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,SAAS;AAGlB,SAAS,0BAA0B;AACnC,SAAS,8BAA8B;AACvC,SAAS,0CAA0C;
|
|
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 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 { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\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.enum(['open', 'closed', 'win', 'loose'])).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 all = searchParams.getAll(key)\n if (!all.length) return null\n const flat = all.flatMap((v) => v.split(','))\n const trimmed = flat.map((s) => s.trim()).filter(Boolean)\n return trimmed.length ? trimmed : 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 baseCurrency = await em.getConnection().execute<Array<{ code: string }>>(\n `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,\n [effectiveTenantId, orgFilterIds[0]],\n )\n const baseCurrencyCode = baseCurrency[0]?.code ?? null\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 const placeholders = parsed.data.status.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...parsed.data.status)\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 where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\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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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -4,6 +4,7 @@ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
|
|
|
4
4
|
import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
|
|
6
6
|
import { fetchStuckDealIds } from "../../../lib/stuckDeals.js";
|
|
7
|
+
import { resolveDealsOrganizationIds } from "../../../lib/dealsOrganizationScope.js";
|
|
7
8
|
import {
|
|
8
9
|
computeDelta,
|
|
9
10
|
convertSumsToBase,
|
|
@@ -106,17 +107,17 @@ function sumsByCurrency(entries) {
|
|
|
106
107
|
}
|
|
107
108
|
async function GET(req) {
|
|
108
109
|
const auth = await getAuthFromRequest(req);
|
|
109
|
-
if (!auth?.tenantId
|
|
110
|
+
if (!auth?.tenantId) {
|
|
110
111
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
111
112
|
}
|
|
112
113
|
const container = await createRequestContainer();
|
|
113
114
|
const em = container.resolve("em");
|
|
114
115
|
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
|
|
115
116
|
const effectiveTenantId = scope.tenantId ?? auth.tenantId;
|
|
116
|
-
|
|
117
|
-
if (!effectiveTenantId || orgFilterIds.length === 0) {
|
|
117
|
+
if (!effectiveTenantId) {
|
|
118
118
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
|
119
119
|
}
|
|
120
|
+
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId });
|
|
120
121
|
const today = /* @__PURE__ */ new Date();
|
|
121
122
|
const currentQuarter = getQuarterWindow(today);
|
|
122
123
|
const previousQuarter = getPreviousQuarterWindow(today);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/customers/api/deals/summary/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 type { ExchangeRateService, RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport {\n computeDelta,\n convertSumsToBase,\n getPreviousQuarterWindow,\n getQuarterWindow,\n getTrailingMonths,\n type CurrencySum,\n type Delta,\n} from '../../../lib/dealsMetrics'\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 OPEN_STATUSES = ['open', 'in_progress'] as const\nconst TRAILING_MONTHS = 6\nconst TOP_OWNERS = 5\n\nconst deltaSchema = z.object({\n value: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n})\n\nconst stageBreakdownSchema = z.object({\n stage: z.string().nullable(),\n count: z.number(),\n value: z.number(),\n})\n\nconst ownerCountSchema = z.object({\n id: z.string(),\n count: z.number(),\n})\n\nconst winRatePointSchema = z.object({\n period: z.string(),\n rate: z.number(),\n})\n\nconst summaryResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n pipelineValue: z.object({\n value: z.number(),\n delta: deltaSchema,\n stages: z.array(stageBreakdownSchema),\n }),\n activeDeals: z.object({\n value: z.number(),\n delta: deltaSchema,\n ownersCount: z.number(),\n needAttention: z.number(),\n owners: z.array(ownerCountSchema),\n ownersOverflow: z.number(),\n }),\n wonThisQuarter: z.object({\n value: z.number(),\n delta: deltaSchema,\n dealsClosed: z.number(),\n avgDeal: z.number(),\n }),\n winRate: z.object({\n value: z.number(),\n deltaPp: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n previousValue: z.number(),\n series: z.array(winRatePointSchema),\n }),\n})\n\nexport type DealsSummaryResponse = z.infer<typeof summaryResponseSchema>\n\nconst summaryErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals KPI summary',\n methods: {\n GET: {\n summary: 'Pipeline KPI metrics with period-over-period deltas for the deals list',\n description:\n 'Returns the four list-level KPI cards (pipeline value, active deals, won this quarter, win rate) with quarter-over-quarter deltas, per-stage open-pipeline breakdown, top owners, and a 6-month win-rate series. Values are converted to the tenant base currency where rates are available; partial conversions are disclosed via convertedAll/missingRateCurrencies.',\n responses: [\n { status: 200, description: 'Deals KPI summary payload', schema: summaryResponseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: summaryErrorSchema },\n ],\n },\n },\n}\n\ntype OpenPipelineRow = {\n stage: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n owner_user_id: string | null\n}\n\ntype WindowSumRow = {\n currency: string | null\n current_total: string | number | null\n current_count: string | number\n previous_total: string | number | null\n previous_count: string | number\n}\n\ntype WinLossRow = {\n current_won: string | number\n current_lost: string | number\n previous_won: string | number\n previous_lost: string | number\n}\n\ntype WinRateMonthRow = {\n period: string\n won: string | number\n lost: string | number\n}\n\ntype OwnerCountRow = {\n owner_user_id: string | null\n count: string | number\n}\n\nfunction toNumber(value: string | number | null | undefined): number {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\nfunction winRate(won: number, lost: number): number {\n const denom = won + lost\n if (denom <= 0) return 0\n return Math.round((100 * won) / denom)\n}\n\nfunction sumsByCurrency(entries: Array<{ currency: string | null; total: number }>): CurrencySum[] {\n const byCurrency = new Map<string, number>()\n for (const entry of entries) {\n const currency = (entry.currency ?? '').toString().trim().toUpperCase()\n if (!currency) continue\n byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)\n }\n return Array.from(byCurrency.entries()).map(([currency, total]) => ({ currency, total }))\n}\n\nexport async function GET(req: Request) {\n const auth = await getAuthFromRequest(req)\n if (!auth?.tenantId || !auth.orgId) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n const effectiveTenantId = scope.tenantId ?? auth.tenantId\n const orgFilterIds = Array.isArray(scope.filterIds) && scope.filterIds.length > 0\n ? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)\n : auth.orgId\n ? [auth.orgId]\n : []\n if (!effectiveTenantId || orgFilterIds.length === 0) {\n return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })\n }\n\n const today = new Date()\n const currentQuarter = getQuarterWindow(today)\n const previousQuarter = getPreviousQuarterWindow(today)\n const trailingMonths = getTrailingMonths(today, TRAILING_MONTHS)\n const seriesStart = trailingMonths[0]?.start ?? currentQuarter.start\n\n const connection = em.getConnection()\n\n const baseCurrency: Array<{ code: string }> = await connection.execute<Array<{ code: string }>>(\n `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,\n [effectiveTenantId, orgFilterIds[0]],\n )\n const baseCurrencyCode = baseCurrency[0]?.code ?? null\n\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const scopeWhere = `tenant_id = ? AND organization_id IN (${orgPlaceholders}) AND deleted_at IS NULL`\n const scopeValues: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n const openPlaceholders = OPEN_STATUSES.map(() => '?').join(',')\n\n // 1) Open pipeline: per (stage, currency) sums + open-deal owner per row, so we can\n // derive pipeline value (per stage + converted total) and the open owner set in one pass.\n const openRows: OpenPipelineRow[] = await connection.execute<OpenPipelineRow[]>(\n `SELECT\n pipeline_stage AS stage,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n owner_user_id\n FROM customer_deals\n WHERE ${scopeWhere} AND status IN (${openPlaceholders})\n GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,\n [...scopeValues, ...OPEN_STATUSES],\n )\n\n // 2) Open-deal value created in the current vs previous quarter (pipeline inflow delta).\n const inflowRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND status IN (${openPlaceholders})\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n ...scopeValues, ...OPEN_STATUSES,\n ],\n )\n\n // 3) Won value per currency for the current vs previous quarter (updated_at in window).\n const wonRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n // 4) Win/lost counts for the current vs previous quarter (win rate + delta-pp).\n const winLossRows: WinLossRow[] = await connection.execute<WinLossRow[]>(\n `SELECT\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,\n COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,\n COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost\n FROM customer_deals\n WHERE ${scopeWhere}`,\n [\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n // 5) Win-rate series over the trailing months (won/lost grouped by updated_at month).\n const seriesRows: WinRateMonthRow[] = await connection.execute<WinRateMonthRow[]>(\n `SELECT\n to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,\n COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,\n COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost\n FROM customer_deals\n WHERE ${scopeWhere} AND updated_at >= ?\n GROUP BY 1`,\n [...scopeValues, seriesStart.toISOString()],\n )\n\n // Overdue open deals (id set) + stuck deals (id set) \u2192 union count for \"need attention\".\n const overdueRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND status = 'open' AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,\n [...scopeValues],\n )\n // `fetchStuckDealIds` is single-org; run it for every org in scope so multi-org callers don't\n // undercount stuck deals (the aggregates above already span every org in `orgFilterIds`).\n const stuckIdLists = await Promise.all(\n orgFilterIds.map((orgId) =>\n fetchStuckDealIds(em as unknown as PgEntityManager, orgId, effectiveTenantId)),\n )\n const stuckIdSet = new Set<string>()\n for (const list of stuckIdLists) for (const id of list) stuckIdSet.add(id)\n\n // The stuck-deal query does not filter status, so a stuck id can be a won/lost/closed deal.\n // \"Need attention\" is an active-deal metric \u2014 intersect with the open (OPEN_STATUSES) set so\n // terminal deals never inflate the count.\n let openStuckIds: string[] = []\n if (stuckIdSet.size > 0) {\n const stuckIdValues = Array.from(stuckIdSet)\n const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')\n const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND status IN (${openPlaceholders}) AND id IN (${stuckPlaceholders})`,\n [...scopeValues, ...OPEN_STATUSES, ...stuckIdValues],\n )\n openStuckIds = openStuckRows.map((row) => row.id)\n }\n\n const attentionIds = new Set<string>()\n for (const row of overdueRows) attentionIds.add(row.id)\n for (const id of openStuckIds) attentionIds.add(id)\n\n // Reduce open rows: per-stage sums, distinct owners, owner counts, and a flat\n // per-currency list for the converted pipeline total.\n const stageMap = new Map<string, { stage: string | null; count: number; byCurrency: CurrencySum[] }>()\n const openOwnerCounts = new Map<string, number>()\n const openSums: Array<{ currency: string | null; total: number }> = []\n for (const row of openRows) {\n const stageKey = row.stage ?? '__null__'\n const total = toNumber(row.total)\n const count = toNumber(row.count)\n const currency = (row.currency ?? '').toString().trim().toUpperCase()\n if (!stageMap.has(stageKey)) {\n stageMap.set(stageKey, { stage: row.stage ?? null, count: 0, byCurrency: [] })\n }\n const stageAgg = stageMap.get(stageKey)!\n stageAgg.count += count\n if (currency) stageAgg.byCurrency.push({ currency, total })\n openSums.push({ currency, total })\n if (row.owner_user_id) {\n openOwnerCounts.set(row.owner_user_id, (openOwnerCounts.get(row.owner_user_id) ?? 0) + count)\n }\n }\n\n // Collect every distinct non-base currency across all metrics and fetch rates ONCE.\n const distinctCurrencies = new Set<string>()\n const collect = (entries: Array<{ currency: string | null }>) => {\n for (const entry of entries) {\n const currency = (entry.currency ?? '').toString().trim().toUpperCase()\n if (currency && currency !== baseCurrencyCode) distinctCurrencies.add(currency)\n }\n }\n collect(openSums)\n collect(inflowRows)\n collect(wonRows)\n\n let rates = new Map<string, RateResult>()\n if (baseCurrencyCode && distinctCurrencies.size > 0) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n if (exchange) {\n const pairs = Array.from(distinctCurrencies).map((code) => ({\n fromCurrencyCode: code,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n rates = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n } catch (err) {\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.summary', err })\n }\n }\n }\n\n const missingRateCurrencies = new Set<string>()\n const trackMissing = (missing: string[]) => {\n for (const code of missing) missingRateCurrencies.add(code)\n }\n let convertedAll = true\n\n // Degraded path: when there is no base currency, fall back to the dominant currency's\n // raw sum so the cards still show a number (mirrors the aggregate route's disclosure).\n const dominantCurrencyTotal = (entries: Array<{ currency: string | null; total: number }>): number => {\n const byCurrency = new Map<string, number>()\n for (const entry of entries) {\n const currency = (entry.currency ?? '').toString().trim().toUpperCase()\n if (!currency) continue\n byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)\n }\n let best = 0\n for (const total of byCurrency.values()) {\n if (Math.abs(total) > Math.abs(best)) best = total\n }\n return Math.round(best)\n }\n\n const convert = (entries: Array<{ currency: string | null; total: number }>): number => {\n if (!baseCurrencyCode) {\n convertedAll = false\n trackMissing(sumsByCurrency(entries).map((entry) => entry.currency))\n return dominantCurrencyTotal(entries)\n }\n const result = convertSumsToBase(sumsByCurrency(entries), baseCurrencyCode, rates)\n if (!result.convertedAll) convertedAll = false\n trackMissing(result.missingRateCurrencies)\n return result.total\n }\n\n // Pipeline value (open deals, converted) + per-stage converted breakdown.\n const pipelineValueTotal = convert(openSums)\n const stages = Array.from(stageMap.values()).map((stageAgg) => ({\n stage: stageAgg.stage,\n count: stageAgg.count,\n value: convert(stageAgg.byCurrency),\n }))\n\n // Pipeline inflow delta (open value created this vs previous quarter).\n const inflowCurrent = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))\n const inflowPrevious = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))\n const pipelineDelta: Delta = computeDelta(inflowCurrent, inflowPrevious)\n\n // Active deals: count of open deals, owners, need-attention, top owners.\n const activeDealsCount = openRows.reduce((sum, row) => sum + toNumber(row.count), 0)\n const ownersCount = openOwnerCounts.size\n const inflowCurrentCount = inflowRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)\n const inflowPreviousCount = inflowRows.reduce((sum, row) => sum + toNumber(row.previous_count), 0)\n const activeDelta: Delta = computeDelta(inflowCurrentCount, inflowPreviousCount)\n const sortedOwners = Array.from(openOwnerCounts.entries())\n .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))\n const owners = sortedOwners.slice(0, TOP_OWNERS).map(([id, count]) => ({ id, count }))\n const ownersOverflow = Math.max(0, ownersCount - owners.length)\n\n // Won this quarter.\n const wonCurrent = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))\n const wonPrevious = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))\n const dealsClosed = wonRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)\n const wonDelta: Delta = computeDelta(wonCurrent, wonPrevious)\n const avgDeal = dealsClosed > 0 ? Math.round(wonCurrent / dealsClosed) : 0\n\n // Win rate (current + previous quarter) and pp delta.\n const winLoss = winLossRows[0]\n const currentWon = toNumber(winLoss?.current_won)\n const currentLost = toNumber(winLoss?.current_lost)\n const previousWon = toNumber(winLoss?.previous_won)\n const previousLost = toNumber(winLoss?.previous_lost)\n const winRateValue = winRate(currentWon, currentLost)\n const winRatePrevious = winRate(previousWon, previousLost)\n const deltaPp = winRateValue - winRatePrevious\n const winRateDirection = deltaPp > 0 ? 'up' : deltaPp < 0 ? 'down' : 'unchanged'\n\n // Win-rate series over trailing months (fill missing months with 0).\n const seriesByPeriod = new Map<string, { won: number; lost: number }>()\n for (const row of seriesRows) {\n seriesByPeriod.set(row.period, { won: toNumber(row.won), lost: toNumber(row.lost) })\n }\n const series = trailingMonths.map((month) => {\n const point = seriesByPeriod.get(month.label)\n const won = point?.won ?? 0\n const lost = point?.lost ?? 0\n const denom = won + lost\n return { period: month.label, rate: denom > 0 ? won / denom : 0 }\n })\n\n const response: DealsSummaryResponse = {\n baseCurrencyCode,\n convertedAll,\n missingRateCurrencies: Array.from(missingRateCurrencies),\n pipelineValue: {\n value: pipelineValueTotal,\n delta: pipelineDelta,\n stages,\n },\n activeDeals: {\n value: activeDealsCount,\n delta: activeDelta,\n ownersCount,\n needAttention: attentionIds.size,\n owners,\n ownersOverflow,\n },\n wonThisQuarter: {\n value: wonCurrent,\n delta: wonDelta,\n dealsClosed,\n avgDeal,\n },\n winRate: {\n value: winRateValue,\n deltaPp,\n direction: winRateDirection,\n previousValue: winRatePrevious,\n series,\n },\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
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 type { ExchangeRateService, RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport {\n computeDelta,\n convertSumsToBase,\n getPreviousQuarterWindow,\n getQuarterWindow,\n getTrailingMonths,\n type CurrencySum,\n type Delta,\n} from '../../../lib/dealsMetrics'\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 OPEN_STATUSES = ['open', 'in_progress'] as const\nconst TRAILING_MONTHS = 6\nconst TOP_OWNERS = 5\n\nconst deltaSchema = z.object({\n value: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n})\n\nconst stageBreakdownSchema = z.object({\n stage: z.string().nullable(),\n count: z.number(),\n value: z.number(),\n})\n\nconst ownerCountSchema = z.object({\n id: z.string(),\n count: z.number(),\n})\n\nconst winRatePointSchema = z.object({\n period: z.string(),\n rate: z.number(),\n})\n\nconst summaryResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n pipelineValue: z.object({\n value: z.number(),\n delta: deltaSchema,\n stages: z.array(stageBreakdownSchema),\n }),\n activeDeals: z.object({\n value: z.number(),\n delta: deltaSchema,\n ownersCount: z.number(),\n needAttention: z.number(),\n owners: z.array(ownerCountSchema),\n ownersOverflow: z.number(),\n }),\n wonThisQuarter: z.object({\n value: z.number(),\n delta: deltaSchema,\n dealsClosed: z.number(),\n avgDeal: z.number(),\n }),\n winRate: z.object({\n value: z.number(),\n deltaPp: z.number(),\n direction: z.enum(['up', 'down', 'unchanged']),\n previousValue: z.number(),\n series: z.array(winRatePointSchema),\n }),\n})\n\nexport type DealsSummaryResponse = z.infer<typeof summaryResponseSchema>\n\nconst summaryErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals KPI summary',\n methods: {\n GET: {\n summary: 'Pipeline KPI metrics with period-over-period deltas for the deals list',\n description:\n 'Returns the four list-level KPI cards (pipeline value, active deals, won this quarter, win rate) with quarter-over-quarter deltas, per-stage open-pipeline breakdown, top owners, and a 6-month win-rate series. Values are converted to the tenant base currency where rates are available; partial conversions are disclosed via convertedAll/missingRateCurrencies.',\n responses: [\n { status: 200, description: 'Deals KPI summary payload', schema: summaryResponseSchema },\n ],\n errors: [\n { status: 401, description: 'Unauthorized', schema: summaryErrorSchema },\n ],\n },\n },\n}\n\ntype OpenPipelineRow = {\n stage: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n owner_user_id: string | null\n}\n\ntype WindowSumRow = {\n currency: string | null\n current_total: string | number | null\n current_count: string | number\n previous_total: string | number | null\n previous_count: string | number\n}\n\ntype WinLossRow = {\n current_won: string | number\n current_lost: string | number\n previous_won: string | number\n previous_lost: string | number\n}\n\ntype WinRateMonthRow = {\n period: string\n won: string | number\n lost: string | number\n}\n\ntype OwnerCountRow = {\n owner_user_id: string | null\n count: string | number\n}\n\nfunction toNumber(value: string | number | null | undefined): number {\n const parsed = Number(value ?? 0)\n return Number.isFinite(parsed) ? parsed : 0\n}\n\nfunction winRate(won: number, lost: number): number {\n const denom = won + lost\n if (denom <= 0) return 0\n return Math.round((100 * won) / denom)\n}\n\nfunction sumsByCurrency(entries: Array<{ currency: string | null; total: number }>): CurrencySum[] {\n const byCurrency = new Map<string, number>()\n for (const entry of entries) {\n const currency = (entry.currency ?? '').toString().trim().toUpperCase()\n if (!currency) continue\n byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)\n }\n return Array.from(byCurrency.entries()).map(([currency, total]) => ({ currency, total }))\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 container = await createRequestContainer()\n const em = container.resolve<CoreEntityManager>('em')\n\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 const today = new Date()\n const currentQuarter = getQuarterWindow(today)\n const previousQuarter = getPreviousQuarterWindow(today)\n const trailingMonths = getTrailingMonths(today, TRAILING_MONTHS)\n const seriesStart = trailingMonths[0]?.start ?? currentQuarter.start\n\n const connection = em.getConnection()\n\n const baseCurrency: Array<{ code: string }> = await connection.execute<Array<{ code: string }>>(\n `SELECT code FROM currencies WHERE tenant_id = ? AND organization_id = ? AND is_base = true AND deleted_at IS NULL LIMIT 1`,\n [effectiveTenantId, orgFilterIds[0]],\n )\n const baseCurrencyCode = baseCurrency[0]?.code ?? null\n\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const scopeWhere = `tenant_id = ? AND organization_id IN (${orgPlaceholders}) AND deleted_at IS NULL`\n const scopeValues: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n const openPlaceholders = OPEN_STATUSES.map(() => '?').join(',')\n\n // 1) Open pipeline: per (stage, currency) sums + open-deal owner per row, so we can\n // derive pipeline value (per stage + converted total) and the open owner set in one pass.\n const openRows: OpenPipelineRow[] = await connection.execute<OpenPipelineRow[]>(\n `SELECT\n pipeline_stage AS stage,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n owner_user_id\n FROM customer_deals\n WHERE ${scopeWhere} AND status IN (${openPlaceholders})\n GROUP BY pipeline_stage, UPPER(COALESCE(value_currency, '')), owner_user_id`,\n [...scopeValues, ...OPEN_STATUSES],\n )\n\n // 2) Open-deal value created in the current vs previous quarter (pipeline inflow delta).\n const inflowRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE created_at >= ? AND created_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE created_at >= ? AND created_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND status IN (${openPlaceholders})\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n ...scopeValues, ...OPEN_STATUSES,\n ],\n )\n\n // 3) Won value per currency for the current vs previous quarter (updated_at in window).\n const wonRows: WindowSumRow[] = await connection.execute<WindowSumRow[]>(\n `SELECT\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS current_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS current_count,\n COALESCE(SUM(value_amount) FILTER (WHERE updated_at >= ? AND updated_at < ?), 0) AS previous_total,\n COUNT(*) FILTER (WHERE updated_at >= ? AND updated_at < ?) AS previous_count\n FROM customer_deals\n WHERE ${scopeWhere} AND (status = 'win' OR closure_outcome = 'won')\n GROUP BY UPPER(COALESCE(value_currency, ''))`,\n [\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n // 4) Win/lost counts for the current vs previous quarter (win rate + delta-pp).\n const winLossRows: WinLossRow[] = await connection.execute<WinLossRow[]>(\n `SELECT\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS current_won,\n COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS current_lost,\n COUNT(*) FILTER (WHERE (status = 'win' OR closure_outcome = 'won') AND updated_at >= ? AND updated_at < ?) AS previous_won,\n COUNT(*) FILTER (WHERE (status = 'loose' OR closure_outcome = 'lost') AND updated_at >= ? AND updated_at < ?) AS previous_lost\n FROM customer_deals\n WHERE ${scopeWhere}`,\n [\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n currentQuarter.start.toISOString(), currentQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n previousQuarter.start.toISOString(), previousQuarter.end.toISOString(),\n ...scopeValues,\n ],\n )\n\n // 5) Win-rate series over the trailing months (won/lost grouped by updated_at month).\n const seriesRows: WinRateMonthRow[] = await connection.execute<WinRateMonthRow[]>(\n `SELECT\n to_char(date_trunc('month', updated_at AT TIME ZONE 'UTC'), 'YYYY-MM') AS period,\n COUNT(*) FILTER (WHERE status = 'win' OR closure_outcome = 'won') AS won,\n COUNT(*) FILTER (WHERE status = 'loose' OR closure_outcome = 'lost') AS lost\n FROM customer_deals\n WHERE ${scopeWhere} AND updated_at >= ?\n GROUP BY 1`,\n [...scopeValues, seriesStart.toISOString()],\n )\n\n // Overdue open deals (id set) + stuck deals (id set) \u2192 union count for \"need attention\".\n const overdueRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND status = 'open' AND expected_close_at IS NOT NULL AND expected_close_at < CURRENT_DATE`,\n [...scopeValues],\n )\n // `fetchStuckDealIds` is single-org; run it for every org in scope so multi-org callers don't\n // undercount stuck deals (the aggregates above already span every org in `orgFilterIds`).\n const stuckIdLists = await Promise.all(\n orgFilterIds.map((orgId) =>\n fetchStuckDealIds(em as unknown as PgEntityManager, orgId, effectiveTenantId)),\n )\n const stuckIdSet = new Set<string>()\n for (const list of stuckIdLists) for (const id of list) stuckIdSet.add(id)\n\n // The stuck-deal query does not filter status, so a stuck id can be a won/lost/closed deal.\n // \"Need attention\" is an active-deal metric \u2014 intersect with the open (OPEN_STATUSES) set so\n // terminal deals never inflate the count.\n let openStuckIds: string[] = []\n if (stuckIdSet.size > 0) {\n const stuckIdValues = Array.from(stuckIdSet)\n const stuckPlaceholders = stuckIdValues.map(() => '?').join(',')\n const openStuckRows: Array<{ id: string }> = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE ${scopeWhere} AND status IN (${openPlaceholders}) AND id IN (${stuckPlaceholders})`,\n [...scopeValues, ...OPEN_STATUSES, ...stuckIdValues],\n )\n openStuckIds = openStuckRows.map((row) => row.id)\n }\n\n const attentionIds = new Set<string>()\n for (const row of overdueRows) attentionIds.add(row.id)\n for (const id of openStuckIds) attentionIds.add(id)\n\n // Reduce open rows: per-stage sums, distinct owners, owner counts, and a flat\n // per-currency list for the converted pipeline total.\n const stageMap = new Map<string, { stage: string | null; count: number; byCurrency: CurrencySum[] }>()\n const openOwnerCounts = new Map<string, number>()\n const openSums: Array<{ currency: string | null; total: number }> = []\n for (const row of openRows) {\n const stageKey = row.stage ?? '__null__'\n const total = toNumber(row.total)\n const count = toNumber(row.count)\n const currency = (row.currency ?? '').toString().trim().toUpperCase()\n if (!stageMap.has(stageKey)) {\n stageMap.set(stageKey, { stage: row.stage ?? null, count: 0, byCurrency: [] })\n }\n const stageAgg = stageMap.get(stageKey)!\n stageAgg.count += count\n if (currency) stageAgg.byCurrency.push({ currency, total })\n openSums.push({ currency, total })\n if (row.owner_user_id) {\n openOwnerCounts.set(row.owner_user_id, (openOwnerCounts.get(row.owner_user_id) ?? 0) + count)\n }\n }\n\n // Collect every distinct non-base currency across all metrics and fetch rates ONCE.\n const distinctCurrencies = new Set<string>()\n const collect = (entries: Array<{ currency: string | null }>) => {\n for (const entry of entries) {\n const currency = (entry.currency ?? '').toString().trim().toUpperCase()\n if (currency && currency !== baseCurrencyCode) distinctCurrencies.add(currency)\n }\n }\n collect(openSums)\n collect(inflowRows)\n collect(wonRows)\n\n let rates = new Map<string, RateResult>()\n if (baseCurrencyCode && distinctCurrencies.size > 0) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n if (exchange) {\n const pairs = Array.from(distinctCurrencies).map((code) => ({\n fromCurrencyCode: code,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n rates = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n } catch (err) {\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.summary', err })\n }\n }\n }\n\n const missingRateCurrencies = new Set<string>()\n const trackMissing = (missing: string[]) => {\n for (const code of missing) missingRateCurrencies.add(code)\n }\n let convertedAll = true\n\n // Degraded path: when there is no base currency, fall back to the dominant currency's\n // raw sum so the cards still show a number (mirrors the aggregate route's disclosure).\n const dominantCurrencyTotal = (entries: Array<{ currency: string | null; total: number }>): number => {\n const byCurrency = new Map<string, number>()\n for (const entry of entries) {\n const currency = (entry.currency ?? '').toString().trim().toUpperCase()\n if (!currency) continue\n byCurrency.set(currency, (byCurrency.get(currency) ?? 0) + entry.total)\n }\n let best = 0\n for (const total of byCurrency.values()) {\n if (Math.abs(total) > Math.abs(best)) best = total\n }\n return Math.round(best)\n }\n\n const convert = (entries: Array<{ currency: string | null; total: number }>): number => {\n if (!baseCurrencyCode) {\n convertedAll = false\n trackMissing(sumsByCurrency(entries).map((entry) => entry.currency))\n return dominantCurrencyTotal(entries)\n }\n const result = convertSumsToBase(sumsByCurrency(entries), baseCurrencyCode, rates)\n if (!result.convertedAll) convertedAll = false\n trackMissing(result.missingRateCurrencies)\n return result.total\n }\n\n // Pipeline value (open deals, converted) + per-stage converted breakdown.\n const pipelineValueTotal = convert(openSums)\n const stages = Array.from(stageMap.values()).map((stageAgg) => ({\n stage: stageAgg.stage,\n count: stageAgg.count,\n value: convert(stageAgg.byCurrency),\n }))\n\n // Pipeline inflow delta (open value created this vs previous quarter).\n const inflowCurrent = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))\n const inflowPrevious = convert(inflowRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))\n const pipelineDelta: Delta = computeDelta(inflowCurrent, inflowPrevious)\n\n // Active deals: count of open deals, owners, need-attention, top owners.\n const activeDealsCount = openRows.reduce((sum, row) => sum + toNumber(row.count), 0)\n const ownersCount = openOwnerCounts.size\n const inflowCurrentCount = inflowRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)\n const inflowPreviousCount = inflowRows.reduce((sum, row) => sum + toNumber(row.previous_count), 0)\n const activeDelta: Delta = computeDelta(inflowCurrentCount, inflowPreviousCount)\n const sortedOwners = Array.from(openOwnerCounts.entries())\n .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))\n const owners = sortedOwners.slice(0, TOP_OWNERS).map(([id, count]) => ({ id, count }))\n const ownersOverflow = Math.max(0, ownersCount - owners.length)\n\n // Won this quarter.\n const wonCurrent = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.current_total) })))\n const wonPrevious = convert(wonRows.map((row) => ({ currency: row.currency, total: toNumber(row.previous_total) })))\n const dealsClosed = wonRows.reduce((sum, row) => sum + toNumber(row.current_count), 0)\n const wonDelta: Delta = computeDelta(wonCurrent, wonPrevious)\n const avgDeal = dealsClosed > 0 ? Math.round(wonCurrent / dealsClosed) : 0\n\n // Win rate (current + previous quarter) and pp delta.\n const winLoss = winLossRows[0]\n const currentWon = toNumber(winLoss?.current_won)\n const currentLost = toNumber(winLoss?.current_lost)\n const previousWon = toNumber(winLoss?.previous_won)\n const previousLost = toNumber(winLoss?.previous_lost)\n const winRateValue = winRate(currentWon, currentLost)\n const winRatePrevious = winRate(previousWon, previousLost)\n const deltaPp = winRateValue - winRatePrevious\n const winRateDirection = deltaPp > 0 ? 'up' : deltaPp < 0 ? 'down' : 'unchanged'\n\n // Win-rate series over trailing months (fill missing months with 0).\n const seriesByPeriod = new Map<string, { won: number; lost: number }>()\n for (const row of seriesRows) {\n seriesByPeriod.set(row.period, { won: toNumber(row.won), lost: toNumber(row.lost) })\n }\n const series = trailingMonths.map((month) => {\n const point = seriesByPeriod.get(month.label)\n const won = point?.won ?? 0\n const lost = point?.lost ?? 0\n const denom = won + lost\n return { period: month.label, rate: denom > 0 ? won / denom : 0 }\n })\n\n const response: DealsSummaryResponse = {\n baseCurrencyCode,\n convertedAll,\n missingRateCurrencies: Array.from(missingRateCurrencies),\n pipelineValue: {\n value: pipelineValueTotal,\n delta: pipelineDelta,\n stages,\n },\n activeDeals: {\n value: activeDealsCount,\n delta: activeDelta,\n ownersCount,\n needAttention: attentionIds.size,\n owners,\n ownersOverflow,\n },\n wonThisQuarter: {\n value: wonCurrent,\n delta: wonDelta,\n dealsClosed,\n avgDeal,\n },\n winRate: {\n value: winRateValue,\n deltaPp,\n direction: winRateDirection,\n previousValue: winRatePrevious,\n series,\n },\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const NO_ORGANIZATION_SENTINEL = "00000000-0000-0000-0000-000000000000";
|
|
2
|
+
async function resolveDealsOrganizationIds(params) {
|
|
3
|
+
const { em, scope, auth, tenantId } = params;
|
|
4
|
+
if (Array.isArray(scope.filterIds) && scope.filterIds.length > 0) {
|
|
5
|
+
return scope.filterIds.filter((id) => typeof id === "string" && id.length > 0);
|
|
6
|
+
}
|
|
7
|
+
if (scope.filterIds === null) {
|
|
8
|
+
const rows = await em.getConnection().execute(
|
|
9
|
+
`SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL`,
|
|
10
|
+
[tenantId]
|
|
11
|
+
);
|
|
12
|
+
const ids = rows.map((row) => String(row.id)).filter((id) => id.length > 0);
|
|
13
|
+
if (ids.length > 0) return ids;
|
|
14
|
+
}
|
|
15
|
+
return auth.orgId ? [auth.orgId] : [NO_ORGANIZATION_SENTINEL];
|
|
16
|
+
}
|
|
17
|
+
export {
|
|
18
|
+
NO_ORGANIZATION_SENTINEL,
|
|
19
|
+
resolveDealsOrganizationIds
|
|
20
|
+
};
|
|
21
|
+
//# sourceMappingURL=dealsOrganizationScope.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../src/modules/customers/lib/dealsOrganizationScope.ts"],
|
|
4
|
+
"sourcesContent": ["import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'\n\n// Matches no organization row, so a caller with nothing in scope reads an empty result set\n// instead of being told their session failed. A 401 here is not merely wrong but harmful:\n// `apiFetch` treats it as an expired session and bounces through /api/auth/session/refresh,\n// which succeeds and returns to the same page \u2014 an endless reload.\nexport const NO_ORGANIZATION_SENTINEL = '00000000-0000-0000-0000-000000000000'\n\n/**\n * Resolves the organization ids a deals read should filter by.\n *\n * `scope.filterIds === null` is the resolver's \"all organizations\" signal \u2014 it is only\n * returned once RBAC has cleared the caller for every org in the tenant, so it must widen\n * the read rather than narrow it. `auth.orgId` is null in exactly that case (the super-admin\n * org cookie override clears it), which is why it cannot be used as a fallback there.\n *\n * Always returns at least one id, so callers can rely on `[0]` for the single-organization\n * lookups (base currency, exchange-rate scope) these routes still perform.\n */\nexport async function resolveDealsOrganizationIds(params: {\n em: CoreEntityManager\n scope: Pick<OrganizationScope, 'filterIds'>\n auth: { orgId?: string | null }\n tenantId: string\n}): Promise<string[]> {\n const { em, scope, auth, tenantId } = params\n if (Array.isArray(scope.filterIds) && scope.filterIds.length > 0) {\n return scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)\n }\n if (scope.filterIds === null) {\n const rows = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL`,\n [tenantId],\n )\n const ids = rows.map((row: { id: string }) => String(row.id)).filter((id: string) => id.length > 0)\n if (ids.length > 0) return ids\n }\n return auth.orgId ? [auth.orgId] : [NO_ORGANIZATION_SENTINEL]\n}\n"],
|
|
5
|
+
"mappings": "AAOO,MAAM,2BAA2B;AAaxC,eAAsB,4BAA4B,QAK5B;AACpB,QAAM,EAAE,IAAI,OAAO,MAAM,SAAS,IAAI;AACtC,MAAI,MAAM,QAAQ,MAAM,SAAS,KAAK,MAAM,UAAU,SAAS,GAAG;AAChE,WAAO,MAAM,UAAU,OAAO,CAAC,OAAO,OAAO,OAAO,YAAY,GAAG,SAAS,CAAC;AAAA,EAC/E;AACA,MAAI,MAAM,cAAc,MAAM;AAC5B,UAAM,OAAO,MAAM,GAAG,cAAc,EAAE;AAAA,MACpC;AAAA,MACA,CAAC,QAAQ;AAAA,IACX;AACA,UAAM,MAAM,KAAK,IAAI,CAAC,QAAwB,OAAO,IAAI,EAAE,CAAC,EAAE,OAAO,CAAC,OAAe,GAAG,SAAS,CAAC;AAClG,QAAI,IAAI,SAAS,EAAG,QAAO;AAAA,EAC7B;AACA,SAAO,KAAK,QAAQ,CAAC,KAAK,KAAK,IAAI,CAAC,wBAAwB;AAC9D;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -47,7 +47,7 @@ const simpleApproval = defineWorkflow({
|
|
|
47
47
|
activityName: "Emit Approval Requested Event",
|
|
48
48
|
activityType: "EMIT_EVENT",
|
|
49
49
|
config: {
|
|
50
|
-
|
|
50
|
+
eventName: "approval.requested",
|
|
51
51
|
payload: { requestId: "{{context.request.id}}", workflowInstanceId: "{{workflow.instanceId}}" }
|
|
52
52
|
},
|
|
53
53
|
async: true
|
|
@@ -86,7 +86,7 @@ const simpleApproval = defineWorkflow({
|
|
|
86
86
|
activityName: "Emit Approval Completed Event",
|
|
87
87
|
activityType: "EMIT_EVENT",
|
|
88
88
|
config: {
|
|
89
|
-
|
|
89
|
+
eventName: "approval.completed",
|
|
90
90
|
payload: {
|
|
91
91
|
requestId: "{{context.request.id}}",
|
|
92
92
|
approved: "{{task.result.approved}}",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/modules/workflows/workflows.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * Workflows Module \u2014 Code-Based Workflow Definitions\n *\n * Demo workflows shipped with the workflows module.\n * These are auto-discovered by the generator and loaded into the in-memory registry.\n */\n\nimport { defineWorkflow, createWorkflowsModuleConfig } from '@open-mercato/shared/modules/workflows'\n\n// ============================================================================\n// Simple Approval Workflow\n// ============================================================================\n\nconst simpleApproval = defineWorkflow({\n workflowId: 'workflows.simple-approval',\n workflowName: 'Simple Approval Workflow',\n description: 'Basic three-step approval workflow: Start \u2192 Approval \u2192 End',\n metadata: { category: 'Testing', tags: ['test', 'approval', 'simple'], icon: 'check-circle' },\n steps: [\n { stepId: 'start', stepName: 'Start', stepType: 'START', description: 'Initialize workflow' },\n {\n stepId: 'approval',\n stepName: 'Pending Approval',\n stepType: 'USER_TASK',\n description: 'Approve or reject request',\n userTaskConfig: { assignedTo: 'approver', slaDuration: 'PT1H' },\n },\n { stepId: 'end', stepName: 'Complete', stepType: 'END', description: 'Workflow complete' },\n ] as const,\n transitions: [\n {\n transitionId: 'start_to_approval',\n transitionName: 'Submit for Approval',\n fromStepId: 'start',\n toStepId: 'approval',\n trigger: 'auto',\n priority: 100,\n activities: [\n {\n activityId: 'notify_approver',\n activityName: 'Notify Approver',\n activityType: 'SEND_EMAIL',\n config: {\n to: '{{context.approver.email}}',\n subject: 'Approval Required: {{context.request.title}}',\n template: 'approval_request',\n data: {\n requestId: '{{context.request.id}}',\n title: '{{context.request.title}}',\n requester: '{{context.requester.name}}',\n },\n },\n timeout: 'PT10S',\n async: true,\n retryPolicy: { maxAttempts: 3, initialIntervalMs: 2000, backoffCoefficient: 2, maxIntervalMs: 20000 },\n },\n {\n activityId: 'emit_approval_requested',\n activityName: 'Emit Approval Requested Event',\n activityType: 'EMIT_EVENT',\n config: {\n eventType: 'approval.requested',\n payload: { requestId: '{{context.request.id}}', workflowInstanceId: '{{workflow.instanceId}}' },\n },\n async: true,\n },\n ],\n },\n {\n transitionId: 'approval_to_end',\n transitionName: 'Approval Decision',\n fromStepId: 'approval',\n toStepId: 'end',\n trigger: 'manual',\n priority: 100,\n activities: [\n {\n activityId: 'notify_requester',\n activityName: 'Notify Requester of Decision',\n activityType: 'SEND_EMAIL',\n config: {\n to: '{{context.requester.email}}',\n subject: 'Approval Decision: {{context.request.title}}',\n template: 'approval_decision',\n data: {\n requestId: '{{context.request.id}}',\n title: '{{context.request.title}}',\n decision: '{{task.result.approved}}',\n comments: '{{task.result.comments}}',\n approver: '{{task.completedBy}}',\n },\n },\n timeout: 'PT10S',\n async: true,\n },\n {\n activityId: 'emit_approval_completed',\n activityName: 'Emit Approval Completed Event',\n activityType: 'EMIT_EVENT',\n config: {\n eventType: 'approval.completed',\n payload: {\n requestId: '{{context.request.id}}',\n approved: '{{task.result.approved}}',\n approvedBy: '{{task.completedBy}}',\n timestamp: '{{now}}',\n },\n },\n async: true,\n },\n ],\n },\n ],\n})\n\n// ============================================================================\n// Checkout Demo Workflow\n// ============================================================================\n\nconst checkoutDemo = defineWorkflow({\n workflowId: 'workflows.checkout-demo',\n workflowName: 'Checkout with Payment Webhook',\n description: 'Realistic checkout workflow with signal-based payment confirmation',\n metadata: { category: 'E-commerce', tags: ['checkout', 'demo', 'payments', 'signals'], icon: 'ShoppingCart' },\n steps: [\n {\n stepId: 'start',\n stepName: 'Start',\n stepType: 'START',\n description: 'Checkout process initiated',\n preConditions: [{\n ruleId: 'workflow_checkout_cart_not_empty',\n required: true,\n validationMessage: {\n en: 'Your shopping cart is empty. Please add items before starting checkout.',\n pl: 'Tw\u00F3j koszyk jest pusty. Dodaj produkty przed rozpocz\u0119ciem zakupu.',\n },\n }],\n },\n { stepId: 'cart_validation', stepName: 'Cart Validation', stepType: 'AUTOMATED', description: 'Validate cart has items and inventory is available', timeout: 'PT10S' },\n {\n stepId: 'customer_info',\n stepName: 'Customer Information',\n stepType: 'USER_TASK',\n description: 'Collect customer shipping and contact information',\n userTaskConfig: {\n formSchema: {\n type: 'object',\n properties: {\n fullName: { type: 'string', title: 'Full Name', description: \"Customer's full legal name\" },\n email: { type: 'string', format: 'email', title: 'Email Address', description: 'Email for order confirmation' },\n phone: { type: 'string', title: 'Phone Number', description: 'Contact number for delivery' },\n comment: { type: 'string', title: 'Order Comment', maxLength: 500, description: 'Special instructions or notes' },\n },\n required: ['fullName', 'email'],\n },\n slaDuration: 'PT24H',\n },\n },\n { stepId: 'payment_initiation', stepName: 'Initiate Payment', stepType: 'AUTOMATED', description: 'Send payment request to payment provider', timeout: 'PT10S' },\n {\n stepId: 'wait_payment_confirmation',\n stepName: 'Wait for Payment Confirmation',\n stepType: 'WAIT_FOR_SIGNAL',\n description: 'Waiting for payment provider webhook confirmation',\n signalConfig: { signalName: 'payment_confirmed', timeout: 'PT5M' },\n },\n { stepId: 'order_confirmation', stepName: 'Order Confirmation', stepType: 'AUTOMATED', description: 'Create order record and send confirmation', timeout: 'PT15S' },\n { stepId: 'end', stepName: 'Complete', stepType: 'END', description: 'Checkout completed successfully' },\n ] as const,\n transitions: [\n {\n transitionId: 'start_to_cart',\n transitionName: 'Initialize Checkout',\n fromStepId: 'start',\n toStepId: 'cart_validation',\n trigger: 'auto',\n priority: 100,\n activities: [{\n activityId: 'log_checkout_start',\n activityName: 'Log Checkout Start',\n activityType: 'EMIT_EVENT',\n config: {\n eventName: 'checkout.started',\n payload: { customerId: '{{context.customerId}}', cartId: '{{context.cartId}}', currency: '{{context.currency}}', timestamp: '{{now}}' },\n },\n async: false,\n }],\n },\n {\n transitionId: 'cart_to_customer_info',\n transitionName: 'Collect Customer Information',\n fromStepId: 'cart_validation',\n toStepId: 'customer_info',\n trigger: 'auto',\n priority: 100,\n preConditions: [{ ruleId: 'workflow_checkout_cart_not_empty', required: true }],\n },\n {\n transitionId: 'customer_info_to_payment',\n transitionName: 'Initiate Payment',\n fromStepId: 'customer_info',\n toStepId: 'payment_initiation',\n trigger: 'auto',\n priority: 100,\n },\n {\n transitionId: 'payment_to_wait_confirmation',\n transitionName: 'Wait for Confirmation',\n fromStepId: 'payment_initiation',\n toStepId: 'wait_payment_confirmation',\n trigger: 'auto',\n priority: 100,\n },\n {\n transitionId: 'confirmation_to_order',\n transitionName: 'Payment Confirmed',\n fromStepId: 'wait_payment_confirmation',\n toStepId: 'order_confirmation',\n trigger: 'auto',\n priority: 100,\n },\n {\n transitionId: 'confirmation_to_end',\n transitionName: 'Finalize Order',\n fromStepId: 'order_confirmation',\n toStepId: 'end',\n trigger: 'auto',\n priority: 100,\n activities: [\n {\n activityId: 'create_order',\n activityName: 'Create Order Record',\n activityType: 'CALL_API',\n config: {\n endpoint: '/api/sales/orders',\n method: 'POST',\n body: {\n customerEntityId: '{{context.customer.id}}',\n currencyCode: '{{context.cart.currency}}',\n placedAt: '{{now}}',\n lines: '{{context.cart.orderLines}}',\n adjustments: '{{context.cart.orderAdjustments}}',\n grandTotalGrossAmount: '{{context.cart.total}}',\n },\n validateTenantMatch: true,\n },\n timeout: 'PT10S',\n async: false,\n retryPolicy: { maxAttempts: 3, initialIntervalMs: 2000, backoffCoefficient: 2, maxIntervalMs: 10000 },\n },\n {\n activityId: 'send_confirmation_email',\n activityName: 'Send Order Confirmation Email',\n activityType: 'SEND_EMAIL',\n config: {\n to: '{{context.customer.email}}',\n subject: 'Order Confirmation',\n template: 'order_confirmation',\n data: { customerName: '{{context.customer.name}}', total: '{{context.cart.total}}' },\n },\n timeout: 'PT15S',\n async: true,\n retryPolicy: { maxAttempts: 3, initialIntervalMs: 2000, backoffCoefficient: 2, maxIntervalMs: 20000 },\n },\n ],\n },\n ],\n})\n\n// ============================================================================\n// Module Export\n// ============================================================================\n\nexport const workflowsConfig = createWorkflowsModuleConfig({\n moduleId: 'workflows',\n workflows: [simpleApproval, checkoutDemo],\n})\n\nexport default workflowsConfig\n"],
|
|
4
|
+
"sourcesContent": ["/**\n * Workflows Module \u2014 Code-Based Workflow Definitions\n *\n * Demo workflows shipped with the workflows module.\n * These are auto-discovered by the generator and loaded into the in-memory registry.\n */\n\nimport { defineWorkflow, createWorkflowsModuleConfig } from '@open-mercato/shared/modules/workflows'\n\n// ============================================================================\n// Simple Approval Workflow\n// ============================================================================\n\nconst simpleApproval = defineWorkflow({\n workflowId: 'workflows.simple-approval',\n workflowName: 'Simple Approval Workflow',\n description: 'Basic three-step approval workflow: Start \u2192 Approval \u2192 End',\n metadata: { category: 'Testing', tags: ['test', 'approval', 'simple'], icon: 'check-circle' },\n steps: [\n { stepId: 'start', stepName: 'Start', stepType: 'START', description: 'Initialize workflow' },\n {\n stepId: 'approval',\n stepName: 'Pending Approval',\n stepType: 'USER_TASK',\n description: 'Approve or reject request',\n userTaskConfig: { assignedTo: 'approver', slaDuration: 'PT1H' },\n },\n { stepId: 'end', stepName: 'Complete', stepType: 'END', description: 'Workflow complete' },\n ] as const,\n transitions: [\n {\n transitionId: 'start_to_approval',\n transitionName: 'Submit for Approval',\n fromStepId: 'start',\n toStepId: 'approval',\n trigger: 'auto',\n priority: 100,\n activities: [\n {\n activityId: 'notify_approver',\n activityName: 'Notify Approver',\n activityType: 'SEND_EMAIL',\n config: {\n to: '{{context.approver.email}}',\n subject: 'Approval Required: {{context.request.title}}',\n template: 'approval_request',\n data: {\n requestId: '{{context.request.id}}',\n title: '{{context.request.title}}',\n requester: '{{context.requester.name}}',\n },\n },\n timeout: 'PT10S',\n async: true,\n retryPolicy: { maxAttempts: 3, initialIntervalMs: 2000, backoffCoefficient: 2, maxIntervalMs: 20000 },\n },\n {\n activityId: 'emit_approval_requested',\n activityName: 'Emit Approval Requested Event',\n activityType: 'EMIT_EVENT',\n config: {\n eventName: 'approval.requested',\n payload: { requestId: '{{context.request.id}}', workflowInstanceId: '{{workflow.instanceId}}' },\n },\n async: true,\n },\n ],\n },\n {\n transitionId: 'approval_to_end',\n transitionName: 'Approval Decision',\n fromStepId: 'approval',\n toStepId: 'end',\n trigger: 'manual',\n priority: 100,\n activities: [\n {\n activityId: 'notify_requester',\n activityName: 'Notify Requester of Decision',\n activityType: 'SEND_EMAIL',\n config: {\n to: '{{context.requester.email}}',\n subject: 'Approval Decision: {{context.request.title}}',\n template: 'approval_decision',\n data: {\n requestId: '{{context.request.id}}',\n title: '{{context.request.title}}',\n decision: '{{task.result.approved}}',\n comments: '{{task.result.comments}}',\n approver: '{{task.completedBy}}',\n },\n },\n timeout: 'PT10S',\n async: true,\n },\n {\n activityId: 'emit_approval_completed',\n activityName: 'Emit Approval Completed Event',\n activityType: 'EMIT_EVENT',\n config: {\n eventName: 'approval.completed',\n payload: {\n requestId: '{{context.request.id}}',\n approved: '{{task.result.approved}}',\n approvedBy: '{{task.completedBy}}',\n timestamp: '{{now}}',\n },\n },\n async: true,\n },\n ],\n },\n ],\n})\n\n// ============================================================================\n// Checkout Demo Workflow\n// ============================================================================\n\nconst checkoutDemo = defineWorkflow({\n workflowId: 'workflows.checkout-demo',\n workflowName: 'Checkout with Payment Webhook',\n description: 'Realistic checkout workflow with signal-based payment confirmation',\n metadata: { category: 'E-commerce', tags: ['checkout', 'demo', 'payments', 'signals'], icon: 'ShoppingCart' },\n steps: [\n {\n stepId: 'start',\n stepName: 'Start',\n stepType: 'START',\n description: 'Checkout process initiated',\n preConditions: [{\n ruleId: 'workflow_checkout_cart_not_empty',\n required: true,\n validationMessage: {\n en: 'Your shopping cart is empty. Please add items before starting checkout.',\n pl: 'Tw\u00F3j koszyk jest pusty. Dodaj produkty przed rozpocz\u0119ciem zakupu.',\n },\n }],\n },\n { stepId: 'cart_validation', stepName: 'Cart Validation', stepType: 'AUTOMATED', description: 'Validate cart has items and inventory is available', timeout: 'PT10S' },\n {\n stepId: 'customer_info',\n stepName: 'Customer Information',\n stepType: 'USER_TASK',\n description: 'Collect customer shipping and contact information',\n userTaskConfig: {\n formSchema: {\n type: 'object',\n properties: {\n fullName: { type: 'string', title: 'Full Name', description: \"Customer's full legal name\" },\n email: { type: 'string', format: 'email', title: 'Email Address', description: 'Email for order confirmation' },\n phone: { type: 'string', title: 'Phone Number', description: 'Contact number for delivery' },\n comment: { type: 'string', title: 'Order Comment', maxLength: 500, description: 'Special instructions or notes' },\n },\n required: ['fullName', 'email'],\n },\n slaDuration: 'PT24H',\n },\n },\n { stepId: 'payment_initiation', stepName: 'Initiate Payment', stepType: 'AUTOMATED', description: 'Send payment request to payment provider', timeout: 'PT10S' },\n {\n stepId: 'wait_payment_confirmation',\n stepName: 'Wait for Payment Confirmation',\n stepType: 'WAIT_FOR_SIGNAL',\n description: 'Waiting for payment provider webhook confirmation',\n signalConfig: { signalName: 'payment_confirmed', timeout: 'PT5M' },\n },\n { stepId: 'order_confirmation', stepName: 'Order Confirmation', stepType: 'AUTOMATED', description: 'Create order record and send confirmation', timeout: 'PT15S' },\n { stepId: 'end', stepName: 'Complete', stepType: 'END', description: 'Checkout completed successfully' },\n ] as const,\n transitions: [\n {\n transitionId: 'start_to_cart',\n transitionName: 'Initialize Checkout',\n fromStepId: 'start',\n toStepId: 'cart_validation',\n trigger: 'auto',\n priority: 100,\n activities: [{\n activityId: 'log_checkout_start',\n activityName: 'Log Checkout Start',\n activityType: 'EMIT_EVENT',\n config: {\n eventName: 'checkout.started',\n payload: { customerId: '{{context.customerId}}', cartId: '{{context.cartId}}', currency: '{{context.currency}}', timestamp: '{{now}}' },\n },\n async: false,\n }],\n },\n {\n transitionId: 'cart_to_customer_info',\n transitionName: 'Collect Customer Information',\n fromStepId: 'cart_validation',\n toStepId: 'customer_info',\n trigger: 'auto',\n priority: 100,\n preConditions: [{ ruleId: 'workflow_checkout_cart_not_empty', required: true }],\n },\n {\n transitionId: 'customer_info_to_payment',\n transitionName: 'Initiate Payment',\n fromStepId: 'customer_info',\n toStepId: 'payment_initiation',\n trigger: 'auto',\n priority: 100,\n },\n {\n transitionId: 'payment_to_wait_confirmation',\n transitionName: 'Wait for Confirmation',\n fromStepId: 'payment_initiation',\n toStepId: 'wait_payment_confirmation',\n trigger: 'auto',\n priority: 100,\n },\n {\n transitionId: 'confirmation_to_order',\n transitionName: 'Payment Confirmed',\n fromStepId: 'wait_payment_confirmation',\n toStepId: 'order_confirmation',\n trigger: 'auto',\n priority: 100,\n },\n {\n transitionId: 'confirmation_to_end',\n transitionName: 'Finalize Order',\n fromStepId: 'order_confirmation',\n toStepId: 'end',\n trigger: 'auto',\n priority: 100,\n activities: [\n {\n activityId: 'create_order',\n activityName: 'Create Order Record',\n activityType: 'CALL_API',\n config: {\n endpoint: '/api/sales/orders',\n method: 'POST',\n body: {\n customerEntityId: '{{context.customer.id}}',\n currencyCode: '{{context.cart.currency}}',\n placedAt: '{{now}}',\n lines: '{{context.cart.orderLines}}',\n adjustments: '{{context.cart.orderAdjustments}}',\n grandTotalGrossAmount: '{{context.cart.total}}',\n },\n validateTenantMatch: true,\n },\n timeout: 'PT10S',\n async: false,\n retryPolicy: { maxAttempts: 3, initialIntervalMs: 2000, backoffCoefficient: 2, maxIntervalMs: 10000 },\n },\n {\n activityId: 'send_confirmation_email',\n activityName: 'Send Order Confirmation Email',\n activityType: 'SEND_EMAIL',\n config: {\n to: '{{context.customer.email}}',\n subject: 'Order Confirmation',\n template: 'order_confirmation',\n data: { customerName: '{{context.customer.name}}', total: '{{context.cart.total}}' },\n },\n timeout: 'PT15S',\n async: true,\n retryPolicy: { maxAttempts: 3, initialIntervalMs: 2000, backoffCoefficient: 2, maxIntervalMs: 20000 },\n },\n ],\n },\n ],\n})\n\n// ============================================================================\n// Module Export\n// ============================================================================\n\nexport const workflowsConfig = createWorkflowsModuleConfig({\n moduleId: 'workflows',\n workflows: [simpleApproval, checkoutDemo],\n})\n\nexport default workflowsConfig\n"],
|
|
5
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.6.7-develop.
|
|
3
|
+
"version": "0.6.7-develop.6592.1.7f69fa2454",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -253,16 +253,16 @@
|
|
|
253
253
|
"zod": "^4.4.3"
|
|
254
254
|
},
|
|
255
255
|
"peerDependencies": {
|
|
256
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
257
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
258
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
256
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6592.1.7f69fa2454",
|
|
257
|
+
"@open-mercato/shared": "0.6.7-develop.6592.1.7f69fa2454",
|
|
258
|
+
"@open-mercato/ui": "0.6.7-develop.6592.1.7f69fa2454",
|
|
259
259
|
"react": "^19.0.0",
|
|
260
260
|
"react-dom": "^19.0.0"
|
|
261
261
|
},
|
|
262
262
|
"devDependencies": {
|
|
263
|
-
"@open-mercato/ai-assistant": "0.6.7-develop.
|
|
264
|
-
"@open-mercato/shared": "0.6.7-develop.
|
|
265
|
-
"@open-mercato/ui": "0.6.7-develop.
|
|
263
|
+
"@open-mercato/ai-assistant": "0.6.7-develop.6592.1.7f69fa2454",
|
|
264
|
+
"@open-mercato/shared": "0.6.7-develop.6592.1.7f69fa2454",
|
|
265
|
+
"@open-mercato/ui": "0.6.7-develop.6592.1.7f69fa2454",
|
|
266
266
|
"@testing-library/dom": "^10.4.1",
|
|
267
267
|
"@testing-library/jest-dom": "^6.9.1",
|
|
268
268
|
"@testing-library/react": "^16.3.1",
|
|
@@ -5,6 +5,7 @@ import type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'
|
|
|
5
5
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
6
6
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
7
7
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
8
|
+
import { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'
|
|
8
9
|
import type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
|
|
9
10
|
import { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'
|
|
10
11
|
import { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'
|
|
@@ -139,7 +140,7 @@ function restrictToIds(where: string[], values: Array<string | number | null>, i
|
|
|
139
140
|
|
|
140
141
|
export async function GET(req: Request) {
|
|
141
142
|
const auth = await getAuthFromRequest(req)
|
|
142
|
-
if (!auth?.tenantId
|
|
143
|
+
if (!auth?.tenantId) {
|
|
143
144
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
144
145
|
}
|
|
145
146
|
|
|
@@ -170,14 +171,10 @@ export async function GET(req: Request) {
|
|
|
170
171
|
// when rbacService cannot be resolved (e.g. in unit tests with a minimal container).
|
|
171
172
|
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
172
173
|
const effectiveTenantId = scope.tenantId ?? auth.tenantId
|
|
173
|
-
|
|
174
|
-
? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
175
|
-
: auth.orgId
|
|
176
|
-
? [auth.orgId]
|
|
177
|
-
: []
|
|
178
|
-
if (!effectiveTenantId || orgFilterIds.length === 0) {
|
|
174
|
+
if (!effectiveTenantId) {
|
|
179
175
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
180
176
|
}
|
|
177
|
+
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })
|
|
181
178
|
|
|
182
179
|
// Raw SQL is used here intentionally — the route only projects non-encrypted columns
|
|
183
180
|
// (`pipeline_stage_id`, `value_amount`, `value_currency`, `status`, plus filters). It
|
|
@@ -8,6 +8,7 @@ import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/d
|
|
|
8
8
|
import type { ExchangeRateService, RateResult } from '@open-mercato/core/modules/currencies/services/exchangeRateService'
|
|
9
9
|
import type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
|
|
10
10
|
import { fetchStuckDealIds } from '../../../lib/stuckDeals'
|
|
11
|
+
import { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'
|
|
11
12
|
import {
|
|
12
13
|
computeDelta,
|
|
13
14
|
convertSumsToBase,
|
|
@@ -163,7 +164,7 @@ function sumsByCurrency(entries: Array<{ currency: string | null; total: number
|
|
|
163
164
|
|
|
164
165
|
export async function GET(req: Request) {
|
|
165
166
|
const auth = await getAuthFromRequest(req)
|
|
166
|
-
if (!auth?.tenantId
|
|
167
|
+
if (!auth?.tenantId) {
|
|
167
168
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
168
169
|
}
|
|
169
170
|
|
|
@@ -172,14 +173,10 @@ export async function GET(req: Request) {
|
|
|
172
173
|
|
|
173
174
|
const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })
|
|
174
175
|
const effectiveTenantId = scope.tenantId ?? auth.tenantId
|
|
175
|
-
|
|
176
|
-
? scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
177
|
-
: auth.orgId
|
|
178
|
-
? [auth.orgId]
|
|
179
|
-
: []
|
|
180
|
-
if (!effectiveTenantId || orgFilterIds.length === 0) {
|
|
176
|
+
if (!effectiveTenantId) {
|
|
181
177
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
|
182
178
|
}
|
|
179
|
+
const orgFilterIds = await resolveDealsOrganizationIds({ em, scope, auth, tenantId: effectiveTenantId })
|
|
183
180
|
|
|
184
181
|
const today = new Date()
|
|
185
182
|
const currentQuarter = getQuarterWindow(today)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { EntityManager as CoreEntityManager } from '@mikro-orm/core'
|
|
2
|
+
import type { OrganizationScope } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
3
|
+
|
|
4
|
+
// Matches no organization row, so a caller with nothing in scope reads an empty result set
|
|
5
|
+
// instead of being told their session failed. A 401 here is not merely wrong but harmful:
|
|
6
|
+
// `apiFetch` treats it as an expired session and bounces through /api/auth/session/refresh,
|
|
7
|
+
// which succeeds and returns to the same page — an endless reload.
|
|
8
|
+
export const NO_ORGANIZATION_SENTINEL = '00000000-0000-0000-0000-000000000000'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Resolves the organization ids a deals read should filter by.
|
|
12
|
+
*
|
|
13
|
+
* `scope.filterIds === null` is the resolver's "all organizations" signal — it is only
|
|
14
|
+
* returned once RBAC has cleared the caller for every org in the tenant, so it must widen
|
|
15
|
+
* the read rather than narrow it. `auth.orgId` is null in exactly that case (the super-admin
|
|
16
|
+
* org cookie override clears it), which is why it cannot be used as a fallback there.
|
|
17
|
+
*
|
|
18
|
+
* Always returns at least one id, so callers can rely on `[0]` for the single-organization
|
|
19
|
+
* lookups (base currency, exchange-rate scope) these routes still perform.
|
|
20
|
+
*/
|
|
21
|
+
export async function resolveDealsOrganizationIds(params: {
|
|
22
|
+
em: CoreEntityManager
|
|
23
|
+
scope: Pick<OrganizationScope, 'filterIds'>
|
|
24
|
+
auth: { orgId?: string | null }
|
|
25
|
+
tenantId: string
|
|
26
|
+
}): Promise<string[]> {
|
|
27
|
+
const { em, scope, auth, tenantId } = params
|
|
28
|
+
if (Array.isArray(scope.filterIds) && scope.filterIds.length > 0) {
|
|
29
|
+
return scope.filterIds.filter((id) => typeof id === 'string' && id.length > 0)
|
|
30
|
+
}
|
|
31
|
+
if (scope.filterIds === null) {
|
|
32
|
+
const rows = await em.getConnection().execute<Array<{ id: string }>>(
|
|
33
|
+
`SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL`,
|
|
34
|
+
[tenantId],
|
|
35
|
+
)
|
|
36
|
+
const ids = rows.map((row: { id: string }) => String(row.id)).filter((id: string) => id.length > 0)
|
|
37
|
+
if (ids.length > 0) return ids
|
|
38
|
+
}
|
|
39
|
+
return auth.orgId ? [auth.orgId] : [NO_ORGANIZATION_SENTINEL]
|
|
40
|
+
}
|
|
@@ -59,7 +59,7 @@ const simpleApproval = defineWorkflow({
|
|
|
59
59
|
activityName: 'Emit Approval Requested Event',
|
|
60
60
|
activityType: 'EMIT_EVENT',
|
|
61
61
|
config: {
|
|
62
|
-
|
|
62
|
+
eventName: 'approval.requested',
|
|
63
63
|
payload: { requestId: '{{context.request.id}}', workflowInstanceId: '{{workflow.instanceId}}' },
|
|
64
64
|
},
|
|
65
65
|
async: true,
|
|
@@ -98,7 +98,7 @@ const simpleApproval = defineWorkflow({
|
|
|
98
98
|
activityName: 'Emit Approval Completed Event',
|
|
99
99
|
activityType: 'EMIT_EVENT',
|
|
100
100
|
config: {
|
|
101
|
-
|
|
101
|
+
eventName: 'approval.completed',
|
|
102
102
|
payload: {
|
|
103
103
|
requestId: '{{context.request.id}}',
|
|
104
104
|
approved: '{{task.result.approved}}',
|