@open-mercato/core 0.6.8-develop.7072.1.19c2a8bbe0 → 0.6.8-develop.7077.1.94f1126c13
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/auth/api/login.js +4 -1
- package/dist/modules/auth/api/login.js.map +2 -2
- package/dist/modules/auth/api/session/refresh.js +25 -1
- package/dist/modules/auth/api/session/refresh.js.map +2 -2
- package/dist/modules/customers/ai-tools/deals-pack.js +48 -5
- package/dist/modules/customers/ai-tools/deals-pack.js.map +2 -2
- package/dist/modules/customers/api/deals/aggregate/route.js +11 -4
- package/dist/modules/customers/api/deals/aggregate/route.js.map +2 -2
- package/dist/modules/customers/api/deals/route.js +38 -1
- package/dist/modules/customers/api/deals/route.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js +4 -13
- package/dist/modules/customers/backend/customers/deals/pipeline/components/Lane.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js +85 -14
- package/dist/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.js.map +2 -2
- package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js +18 -0
- package/dist/modules/customers/backend/customers/deals/pipeline/components/toneClasses.js.map +7 -0
- package/dist/modules/customers/commands/deals.js +21 -34
- package/dist/modules/customers/commands/deals.js.map +2 -2
- package/dist/modules/customers/lib/closureStage.js +45 -0
- package/dist/modules/customers/lib/closureStage.js.map +7 -0
- package/dist/modules/customers/lib/dealStatus.js +30 -0
- package/dist/modules/customers/lib/dealStatus.js.map +2 -2
- package/dist/modules/query_index/lib/search-entity-policy.js +14 -0
- package/dist/modules/query_index/lib/search-entity-policy.js.map +7 -0
- package/package.json +7 -7
- package/src/modules/auth/api/login.ts +9 -1
- package/src/modules/auth/api/session/refresh.ts +32 -1
- package/src/modules/customers/ai-tools/deals-pack.ts +62 -0
- package/src/modules/customers/api/deals/aggregate/route.ts +14 -4
- package/src/modules/customers/api/deals/route.ts +50 -1
- package/src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx +5 -14
- package/src/modules/customers/backend/customers/deals/pipeline/components/StatusFilterPopover.tsx +109 -27
- package/src/modules/customers/backend/customers/deals/pipeline/components/toneClasses.ts +21 -0
- package/src/modules/customers/commands/deals.ts +34 -59
- package/src/modules/customers/i18n/de.json +3 -0
- package/src/modules/customers/i18n/en.json +3 -0
- package/src/modules/customers/i18n/es.json +3 -0
- package/src/modules/customers/i18n/ko.json +3 -0
- package/src/modules/customers/i18n/pl.json +3 -0
- package/src/modules/customers/lib/closureStage.ts +76 -0
- package/src/modules/customers/lib/dealStatus.ts +42 -0
- package/src/modules/query_index/lib/search-entity-policy.ts +46 -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 { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { 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 baseCurrencyCode = await resolveOptionalBaseCurrencyCode(\n container,\n effectiveTenantId,\n orgFilterIds[0],\n )\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n 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;AACnD,SAAS,mCAAmC;AAC5C,SAAS,uCAAuC;AAEhD,SAAS,+BAA+B;AACxC,SAAS,yBAAyB;AAElC,SAAS,qCAAqC;AAE9C,SAAS,yBAAyB;AAClC,SAAS,wDAAwD;AACjE,SAAS,SAAS;AAClB,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,WAAW;AAEhC,MAAM,WAAW;AAAA,EACtB,KAAK,EAAE,aAAa,MAAM,iBAAiB,CAAC,sBAAsB,EAAE;AACtE;AAEA,MAAM,cAAc,EAAE,OAAO;AAAA,EAC3B,YAAY,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS;AAAA,EACvC,QAAQ,EAAE,OAAO,EAAE,SAAS;AAAA,EAC5B,QAAQ,EAAE,MAAM,EAAE,
|
|
4
|
+
"sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { EntityManager as CoreEntityManager } from '@mikro-orm/core'\nimport type { EntityManager as PgEntityManager } from '@mikro-orm/postgresql'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { resolveDealsOrganizationIds } from '../../../lib/dealsOrganizationScope'\nimport { resolveOptionalBaseCurrencyCode } from '../../../lib/optionalBaseCurrency'\nimport type { ExchangeRateService } from '@open-mercato/core/modules/currencies/services/exchangeRateService'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport type { CrudCtx } from '@open-mercato/shared/lib/crud/factory'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { fetchStuckDealIds } from '../../../lib/stuckDeals'\nimport { expandDealStatusAliases } from '../../../lib/dealStatus'\nimport { findMatchingEntityIdsBySearchTokensAcrossSources } from '../../utils'\nimport { E } from '#generated/entities.ids.generated'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n}\n\nconst querySchema = z.object({\n pipelineId: z.string().uuid().optional(),\n search: z.string().optional(),\n status: z.array(z.string().max(50)).max(20).optional(),\n ownerUserId: z.array(z.string().uuid()).optional(),\n personId: z.array(z.string().uuid()).optional(),\n companyId: z.array(z.string().uuid()).optional(),\n isStuck: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n isOverdue: z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n }, z.boolean()).optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n})\n\ntype StageBreakdownByCurrency = {\n currency: string\n total: number\n count: number\n}\n\ntype StageAggregate = {\n stageId: string\n count: number\n openCount: number\n totalInBaseCurrency: number\n byCurrency: StageBreakdownByCurrency[]\n /**\n * `true` when every currency present in `byCurrency` was either the base currency or\n * had a usable exchange rate at request time. `false` when at least one currency in\n * `byCurrency` could not be resolved to base \u2014 the lane header should surface a\n * \"partial\" indicator in that case so the converted total isn't read as authoritative.\n */\n convertedAll: boolean\n /**\n * Currencies present in `byCurrency` that had NO exchange rate to the base currency.\n * Empty when conversion was complete or there is no base currency configured. The\n * client uses this to disclose which slice of the value is excluded from `totalInBaseCurrency`.\n */\n missingRateCurrencies: string[]\n}\n\ntype AggregateResponse = {\n baseCurrencyCode: string | null\n perStage: StageAggregate[]\n}\n\nconst stageBreakdownByCurrencySchema = z.object({\n currency: z.string(),\n total: z.number(),\n count: z.number(),\n})\n\nconst stageAggregateSchema = z.object({\n stageId: z.string(),\n count: z.number(),\n openCount: z.number(),\n totalInBaseCurrency: z.number(),\n byCurrency: z.array(stageBreakdownByCurrencySchema),\n convertedAll: z.boolean(),\n missingRateCurrencies: z.array(z.string()),\n})\n\nconst aggregateResponseSchema = z.object({\n baseCurrencyCode: z.string().nullable(),\n perStage: z.array(stageAggregateSchema),\n})\n\nconst aggregateErrorSchema = z.object({\n error: z.string(),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Customers',\n summary: 'Deals aggregate per pipeline stage',\n methods: {\n GET: {\n summary: 'Per-stage counts and currency totals for kanban lane headers',\n description:\n 'Returns per-stage counts and totals for deals, with values converted to the tenant base currency where rates are available. Used to power kanban lane headers without loading every deal.',\n query: querySchema,\n responses: [\n { status: 200, description: 'Per-stage aggregate payload', schema: aggregateResponseSchema },\n ],\n errors: [\n { status: 400, description: 'Invalid query parameters', schema: aggregateErrorSchema },\n { status: 401, description: 'Unauthorized', schema: aggregateErrorSchema },\n ],\n },\n },\n}\n\nfunction readArrayParam(searchParams: URLSearchParams, key: string): string[] | null {\n const 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 baseCurrencyCode = await resolveOptionalBaseCurrencyCode(\n container,\n effectiveTenantId,\n orgFilterIds[0],\n )\n\n // Build WHERE clause shared between count + sum queries\n const orgPlaceholders = orgFilterIds.map(() => '?').join(',')\n const where: string[] = [\n 'tenant_id = ?',\n `organization_id IN (${orgPlaceholders})`,\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number | null> = [effectiveTenantId, ...orgFilterIds]\n\n if (parsed.data.pipelineId) {\n where.push('pipeline_id = ?')\n values.push(parsed.data.pipelineId)\n }\n const search = parsed.data.search?.trim()\n if (search) {\n const searchCtx: CrudCtx = {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: orgFilterIds[0],\n organizationIds: orgFilterIds,\n request: req,\n }\n const matchingIds = await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx: searchCtx,\n query: search,\n sources: [\n {\n entityType: E.customers.customer_deal,\n fields: [\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'source',\n 'value_amount',\n 'value_currency',\n 'cf:competitive_risk',\n 'cf:implementation_complexity',\n ],\n },\n ],\n })\n if (matchingIds !== null && matchingIds.length > 0) {\n restrictToIds(where, values, matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are declared in\n // `encryption.ts` as encrypted columns. A raw `ILIKE` over the ciphertext would\n // silently match nothing on encrypted tenants and produce misleading lane totals,\n // so we collapse the result to zero rows instead \u2014 consistent with the list\n // endpoint's behavior when the token index has nothing to offer.\n restrictToIds(where, values, [])\n } else {\n const searchPattern = `%${escapeLikePattern(search)}%`\n where.push(\"(title ILIKE ? ESCAPE '\\\\' OR description ILIKE ? ESCAPE '\\\\')\")\n values.push(searchPattern, searchPattern)\n }\n }\n if (parsed.data.status && parsed.data.status.length) {\n // Non-empty input always expands to a non-empty set, so this always narrows.\n const expandedStatuses = expandDealStatusAliases(parsed.data.status)\n const placeholders = expandedStatuses.map(() => '?').join(',')\n where.push(`status IN (${placeholders})`)\n values.push(...expandedStatuses)\n }\n if (parsed.data.ownerUserId && parsed.data.ownerUserId.length) {\n const placeholders = parsed.data.ownerUserId.map(() => '?').join(',')\n where.push(`owner_user_id IN (${placeholders})`)\n values.push(...parsed.data.ownerUserId)\n }\n if (parsed.data.expectedCloseAtFrom) {\n where.push('expected_close_at >= ?')\n values.push(parsed.data.expectedCloseAtFrom)\n }\n if (parsed.data.expectedCloseAtTo) {\n where.push('expected_close_at <= ?')\n values.push(parsed.data.expectedCloseAtTo)\n }\n if (parsed.data.isOverdue) {\n // Mirror the list route's precedence: the caller-supplied status filter wins, and\n // status='open' is injected only when none was provided.\n const hasCallerStatus = !!parsed.data.status?.length\n if (hasCallerStatus) {\n where.push('expected_close_at < CURRENT_DATE')\n } else {\n where.push(\"expected_close_at < CURRENT_DATE AND status = 'open'\")\n }\n }\n if (parsed.data.isStuck) {\n // Reuse the list endpoint's stuck-deal lookup so kanban headers, lane counts, and the\n // cards rendered inside each lane agree on the same definition of \"stuck\". Empty result\n // \u2192 narrow to a sentinel UUID so the WHERE clause collapses to zero rows.\n const stuckIds = await fetchStuckDealIds(em as unknown as PgEntityManager, orgFilterIds[0], effectiveTenantId)\n restrictToIds(where, values, stuckIds)\n }\n if (parsed.data.personId && parsed.data.personId.length) {\n const placeholders = parsed.data.personId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.personId)\n }\n if (parsed.data.companyId && parsed.data.companyId.length) {\n const placeholders = parsed.data.companyId.map(() => '?').join(',')\n where.push(`EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`)\n values.push(...parsed.data.companyId)\n }\n\n const whereSql = where.join(' AND ')\n\n // Aggregate by (stage, currency). Treat null stage as the literal `__unassigned`.\n const rows = await em.getConnection().execute<\n Array<{\n stage_id: string | null\n currency: string | null\n total: string | number | null\n count: string | number\n open_count: string | number\n }>\n >(\n `SELECT\n pipeline_stage_id AS stage_id,\n UPPER(COALESCE(value_currency, '')) AS currency,\n COALESCE(SUM(value_amount), 0) AS total,\n COUNT(*) AS count,\n COUNT(*) FILTER (WHERE status = 'open') AS open_count\n FROM customer_deals\n WHERE ${whereSql}\n GROUP BY pipeline_stage_id, UPPER(COALESCE(value_currency, ''))`,\n values,\n )\n\n // Reduce to per-stage aggregates\n const stageMap = new Map<string, StageAggregate>()\n for (const row of rows) {\n const stageId = row.stage_id ?? '__unassigned'\n const total = Number(row.total ?? 0)\n const count = Number(row.count ?? 0)\n const openCount = Number(row.open_count ?? 0)\n const currency = (row.currency ?? '').toString().trim()\n\n if (!stageMap.has(stageId)) {\n stageMap.set(stageId, {\n stageId,\n count: 0,\n openCount: 0,\n totalInBaseCurrency: 0,\n byCurrency: [],\n convertedAll: true,\n missingRateCurrencies: [],\n })\n }\n const agg = stageMap.get(stageId)!\n agg.count += count\n agg.openCount += openCount\n // Include any row that has a real currency code, even when its summed amount is\n // zero \u2014 `byCurrency` should agree with `count` so the per-currency breakdown\n // doesn't silently drop deals whose amount happens to be zero. Rows with no\n // currency code at all still get folded into the stage totals via `count`.\n if (currency.length > 0) {\n agg.byCurrency.push({ currency, total, count })\n }\n }\n\n // Convert to base currency where possible\n if (baseCurrencyCode) {\n const exchange = container.resolve('exchangeRateService') as ExchangeRateService | undefined\n const today = new Date()\n const distinctCurrencies = new Set<string>()\n for (const agg of stageMap.values()) {\n for (const row of agg.byCurrency) {\n if (row.currency && row.currency !== baseCurrencyCode) {\n distinctCurrencies.add(row.currency)\n }\n }\n }\n\n const rateCache = new Map<string, number>()\n if (exchange && distinctCurrencies.size > 0) {\n const pairs = Array.from(distinctCurrencies).map((c) => ({\n fromCurrencyCode: c,\n toCurrencyCode: baseCurrencyCode,\n }))\n try {\n const results = await exchange.getRates({\n pairs,\n date: today,\n scope: { tenantId: effectiveTenantId, organizationId: orgFilterIds[0] },\n options: { maxDaysBack: 60, autoFetch: false },\n })\n for (const [key, rateResult] of results) {\n if (rateResult.rates.length > 0) {\n // Pick the first matching rate (sources are equivalent for display purposes)\n const rate = Number(rateResult.rates[0].rate)\n if (Number.isFinite(rate) && rate > 0) {\n rateCache.set(key, rate)\n }\n }\n }\n } catch (err) {\n // Swallow \u2014 partial totals are still useful and we'll fall back to currency-native\n // sums. Logging at warn level so operators can correlate missing-rate disclosures in\n // the UI (`convertedAll: false` / `missingRateCurrencies`) with the underlying error.\n logger.warn('exchange-rate lookup failed; falling back to per-currency totals', { component: 'deals.aggregate', err })\n }\n }\n\n for (const agg of stageMap.values()) {\n let totalBase = 0\n let convertedAll = true\n const missingRateCurrencies: string[] = []\n for (const row of agg.byCurrency) {\n if (!row.currency) continue\n if (row.currency === baseCurrencyCode) {\n totalBase += row.total\n continue\n }\n const key = `${row.currency}/${baseCurrencyCode}`\n const rate = rateCache.get(key)\n if (rate !== undefined) {\n totalBase += row.total * rate\n } else {\n convertedAll = false\n if (!missingRateCurrencies.includes(row.currency)) {\n missingRateCurrencies.push(row.currency)\n }\n }\n }\n // Even when some rates are missing, totalInBaseCurrency reflects the converted slice.\n // `convertedAll`/`missingRateCurrencies` let the client distinguish a complete\n // conversion from a partial one and disclose which currencies were excluded.\n agg.totalInBaseCurrency = Math.round(totalBase)\n agg.convertedAll = convertedAll\n agg.missingRateCurrencies = missingRateCurrencies\n }\n } else {\n // No base currency configured for the tenant \u2014 surface this by marking every stage as\n // \"not converted\" with all currencies flagged as missing rates. This way the client UI\n // can fall back to a per-currency breakdown without trying to render a board total.\n for (const agg of stageMap.values()) {\n const present = agg.byCurrency.map((row) => row.currency).filter((c): c is string => !!c)\n if (present.length > 0) {\n agg.convertedAll = false\n agg.missingRateCurrencies = Array.from(new Set(present))\n }\n }\n }\n\n // Sort byCurrency rows by total descending for stable display\n for (const agg of stageMap.values()) {\n agg.byCurrency.sort((a, b) => b.total - a.total)\n }\n\n const response: AggregateResponse = {\n baseCurrencyCode,\n perStage: Array.from(stageMap.values()),\n }\n\n return NextResponse.json(response)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -22,11 +22,22 @@ import { isTenantDataEncryptionEnabled } from "@open-mercato/shared/lib/encrypti
|
|
|
22
22
|
import { escapeLikePattern } from "@open-mercato/shared/lib/db/escapeLikePattern";
|
|
23
23
|
import { consumeAdvancedFilterState, mergeAdvancedFilterTree } from "@open-mercato/shared/lib/crud/advanced-filter-integration";
|
|
24
24
|
import { fetchStuckDealIds } from "../../lib/stuckDeals.js";
|
|
25
|
+
import { expandDealStatusAliases } from "../../lib/dealStatus.js";
|
|
25
26
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
26
27
|
const logger = createLogger("customers");
|
|
27
28
|
const rawBodySchema = z.object({}).passthrough();
|
|
28
29
|
const stringOrStringArray = z.union([z.string(), z.array(z.string())]);
|
|
29
30
|
const OPEN_DEAL_STATUSES = ["open", "in_progress"];
|
|
31
|
+
const STATUS_TREE_OPERATORS = /* @__PURE__ */ new Set([
|
|
32
|
+
"is",
|
|
33
|
+
"equals",
|
|
34
|
+
"is_not",
|
|
35
|
+
"not_equals",
|
|
36
|
+
"is_any_of",
|
|
37
|
+
"has_any_of",
|
|
38
|
+
"is_none_of",
|
|
39
|
+
"has_none_of"
|
|
40
|
+
]);
|
|
30
41
|
const booleanQueryParam = z.preprocess((value) => {
|
|
31
42
|
const parsed = parseBooleanFromUnknown(value);
|
|
32
43
|
return parsed === null ? value : parsed;
|
|
@@ -191,6 +202,31 @@ function normalizeUuidList(values) {
|
|
|
191
202
|
}
|
|
192
203
|
async function buildDealListFilters(query, ctx) {
|
|
193
204
|
const advancedFilterTree = consumeAdvancedFilterState(query);
|
|
205
|
+
if (advancedFilterTree) {
|
|
206
|
+
const walk = (node) => {
|
|
207
|
+
if (node.type === "rule" && node.field === "status" && STATUS_TREE_OPERATORS.has(node.operator)) {
|
|
208
|
+
const listOperators = /* @__PURE__ */ new Set(["is_any_of", "has_any_of", "is_none_of", "has_none_of"]);
|
|
209
|
+
const rawValues = Array.isArray(node.value) ? node.value.filter((v) => typeof v === "string") : typeof node.value === "string" ? [node.value] : [];
|
|
210
|
+
if (rawValues.length === 0) return;
|
|
211
|
+
const expanded = expandDealStatusAliases(rawValues);
|
|
212
|
+
if (listOperators.has(node.operator)) {
|
|
213
|
+
node.value = expanded;
|
|
214
|
+
} else if (expanded.length === 1) {
|
|
215
|
+
node.value = expanded[0];
|
|
216
|
+
} else {
|
|
217
|
+
node.value = expanded;
|
|
218
|
+
if (node.operator === "is" || node.operator === "equals") {
|
|
219
|
+
node.operator = "is_any_of";
|
|
220
|
+
} else if (node.operator === "is_not" || node.operator === "not_equals") {
|
|
221
|
+
node.operator = "is_none_of";
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} else if (node.type === "group") {
|
|
225
|
+
for (const child of node.children) walk(child);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
walk(advancedFilterTree.root);
|
|
229
|
+
}
|
|
194
230
|
const filters = {};
|
|
195
231
|
let restrictedIds = null;
|
|
196
232
|
const intersectIds = (ids) => {
|
|
@@ -235,7 +271,8 @@ async function buildDealListFilters(query, ctx) {
|
|
|
235
271
|
];
|
|
236
272
|
}
|
|
237
273
|
}
|
|
238
|
-
const
|
|
274
|
+
const rawStatusList = query.status ? normalizeStringList(query.status) : [];
|
|
275
|
+
const statusList = expandDealStatusAliases(rawStatusList);
|
|
239
276
|
if (statusList.length > 0) {
|
|
240
277
|
filters.status = statusList.length === 1 ? { $eq: statusList[0] } : { $in: statusList };
|
|
241
278
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../src/modules/customers/api/deals/route.ts"],
|
|
4
|
-
"sourcesContent": ["import { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink } from '../../data/entities'\nimport { dealCreateSchema, dealUpdateSchema } from '../../data/validators'\nimport { E } from '#generated/entities.ids.generated'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport {\n applyEntityIdRestriction,\n findMatchingEntityIdsWithQueryEngine,\n findMatchingEntityIdsBySearchTokensAcrossSources,\n parseScopedCommandInput,\n} from '../utils'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n createCustomersCrudOpenApi,\n createPagedListResponseSchema,\n defaultOkResponseSchema,\n} from '../openapi'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'\nimport { fetchStuckDealIds } from '../../lib/stuckDeals'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst stringOrStringArray = z.union([z.string(), z.array(z.string())])\nconst OPEN_DEAL_STATUSES = ['open', 'in_progress'] as const\nconst booleanQueryParam = z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n}, z.boolean()).optional()\n\nexport const dealListQuerySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n id: z.string().uuid().optional(),\n search: z.string().optional(),\n status: stringOrStringArray.optional(),\n pipelineStage: z.string().optional(),\n pipelineId: stringOrStringArray.optional(),\n pipelineStageId: z.union([z.string().uuid(), z.literal('__unassigned')]).optional(),\n ownerUserId: stringOrStringArray.optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n isStuck: booleanQueryParam,\n isOverdue: booleanQueryParam,\n needsAttention: booleanQueryParam,\n valueCurrency: stringOrStringArray.optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n /**\n * @deprecated Use `personId` instead. The `personEntityId` alias is kept for backward\n * compatibility with older clients and will be removed in a future minor release.\n */\n personEntityId: z.string().uuid().optional().describe('Deprecated; use personId'),\n /**\n * @deprecated Use `companyId` instead. The `companyEntityId` alias is kept for backward\n * compatibility with older clients and will be removed in a future minor release.\n */\n companyEntityId: z.string().uuid().optional().describe('Deprecated; use companyId'),\n personId: stringOrStringArray.optional(),\n companyId: stringOrStringArray.optional(),\n })\n .passthrough()\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n POST: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nexport type DealListQuery = z.infer<typeof dealListQuerySchema>\n\nfunction parseUuid(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed.length) return null\n const result = z.string().uuid().safeParse(trimmed)\n return result.success ? trimmed : null\n}\n\nfunction normalizeStringList(value: unknown): string[] {\n const set = new Set<string>()\n const visit = (entry: unknown) => {\n if (entry == null) return\n if (Array.isArray(entry)) {\n entry.forEach(visit)\n return\n }\n if (typeof entry !== 'string') return\n entry\n .split(',')\n .map((token) => token.trim())\n .filter((token) => token.length > 0)\n .forEach((token) => set.add(token))\n }\n visit(value)\n return Array.from(set)\n}\n\nfunction parseDateInput(value: unknown): Date | null {\n if (!(typeof value === 'string') || value.trim().length === 0) return null\n const date = new Date(value)\n return Number.isNaN(date.getTime()) ? null : date\n}\n\n/**\n * Pre-pagination ID narrowing for person/company filters.\n *\n * Mirrors the SQL the aggregate route uses (`EXISTS ... IN (...)`) so the list endpoint\n * and the lane-header aggregate return a consistent set of deals for the same filters.\n * Semantics: OR within a category (any selected person/company matches), AND across\n * categories (deal must match at least one person AND at least one company when both\n * filter lists are provided).\n *\n * Returns the matched deal IDs (UUIDs). An empty array means \"no deals match\" \u2014 callers\n * must intersect with `restrictedIds` (which will collapse the list to zero).\n */\nasync function fetchDealIdsMatchingAssociations(\n em: EntityManager,\n organizationId: string,\n tenantId: string,\n personIds: string[],\n companyIds: string[],\n): Promise<string[]> {\n if (!personIds.length && !companyIds.length) return []\n const where: string[] = [\n 'organization_id = ?',\n 'tenant_id = ?',\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number> = [organizationId, tenantId]\n if (personIds.length) {\n const placeholders = personIds.map(() => '?').join(',')\n where.push(\n `EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`,\n )\n values.push(...personIds)\n }\n if (companyIds.length) {\n const placeholders = companyIds.map(() => '?').join(',')\n where.push(\n `EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`,\n )\n values.push(...companyIds)\n }\n const rows = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals WHERE ${where.join(' AND ')}`,\n values,\n )\n return rows.map((row) => row.id)\n}\n\nasync function fetchNeedAttentionDealIds(\n em: EntityManager,\n organizationId: string,\n tenantId: string,\n): Promise<string[]> {\n const connection = em.getConnection()\n const overdueRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n AND status = 'open'\n AND expected_close_at IS NOT NULL\n AND expected_close_at < CURRENT_DATE`,\n [organizationId, tenantId],\n )\n\n const attentionIds = new Set(overdueRows.map((row) => row.id))\n const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)\n if (stuckIds.length > 0) {\n const idPlaceholders = stuckIds.map(() => '?').join(',')\n const statusPlaceholders = OPEN_DEAL_STATUSES.map(() => '?').join(',')\n const openStuckRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n AND status IN (${statusPlaceholders})\n AND id IN (${idPlaceholders})`,\n [organizationId, tenantId, ...OPEN_DEAL_STATUSES, ...stuckIds],\n )\n for (const row of openStuckRows) attentionIds.add(row.id)\n }\n\n return Array.from(attentionIds)\n}\n\nfunction normalizeCurrencyList(value: unknown): string[] {\n const set = new Set<string>()\n const visit = (entry: unknown) => {\n if (entry == null) return\n if (Array.isArray(entry)) {\n entry.forEach(visit)\n return\n }\n if (typeof entry !== 'string') return\n entry\n .split(',')\n .map((token) => token.trim().toUpperCase())\n .filter((token) => /^[A-Z]{3}$/.test(token))\n .forEach((token) => set.add(token))\n }\n visit(value)\n return Array.from(set)\n}\n\nfunction normalizeUuidList(values: Array<unknown>): string[] {\n const set = new Set<string>()\n values.forEach((candidate) => {\n if (Array.isArray(candidate)) {\n candidate.forEach((entry) => {\n const parsed = parseUuid(entry)\n if (parsed) set.add(parsed)\n })\n return\n }\n if (typeof candidate === 'string' && candidate.includes(',')) {\n candidate\n .split(',')\n .map((entry) => entry.trim())\n .forEach((entry) => {\n const parsed = parseUuid(entry)\n if (parsed) set.add(parsed)\n })\n return\n }\n const parsed = parseUuid(candidate)\n if (parsed) set.add(parsed)\n })\n return Array.from(set)\n}\n\nexport async function buildDealListFilters(query: DealListQuery, ctx?: import('@open-mercato/shared/lib/crud/factory').CrudCtx) {\n const advancedFilterTree = consumeAdvancedFilterState(query)\n const filters: Record<string, unknown> = {}\n let restrictedIds: string[] | null = null\n\n const intersectIds = (ids: string[]) => {\n if (restrictedIds === null) {\n restrictedIds = ids\n return\n }\n const lookup = new Set(ids)\n restrictedIds = restrictedIds.filter((id) => lookup.has(id))\n }\n\n if (query.id) filters.id = { $eq: query.id }\n\n if (query.search) {\n const matchingIds = ctx\n ? await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx,\n query: 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 : null\n if (matchingIds !== null && matchingIds.length > 0) {\n intersectIds(matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are encrypted at rest\n // (see `encryption.ts`). The `$ilike` fallback below would silently match\n // nothing on the ciphertext and produce empty pages without disclosing why \u2014\n // collapse the result to \"no matches\" instead so the kanban + list views\n // agree, and lane aggregates stay consistent with the list endpoint.\n intersectIds([])\n } else {\n const searchPattern = `%${escapeLikePattern(query.search)}%`\n filters.$or = [\n { title: { $ilike: searchPattern } },\n { description: { $ilike: searchPattern } },\n ]\n }\n }\n\n const statusList = query.status ? normalizeStringList(query.status) : []\n if (statusList.length > 0) {\n filters.status = statusList.length === 1 ? { $eq: statusList[0] } : { $in: statusList }\n }\n\n if (query.pipelineStage) {\n filters.pipeline_stage = { $eq: query.pipelineStage }\n }\n\n const pipelineIds = query.pipelineId ? normalizeUuidList([query.pipelineId]) : []\n if (pipelineIds.length > 0) {\n filters.pipeline_id = pipelineIds.length === 1 ? { $eq: pipelineIds[0] } : { $in: pipelineIds }\n }\n\n if (query.pipelineStageId === '__unassigned') {\n filters.pipeline_stage_id = { $eq: null }\n } else if (query.pipelineStageId) {\n filters.pipeline_stage_id = { $eq: query.pipelineStageId }\n }\n\n const ownerUserIds = query.ownerUserId ? normalizeUuidList([query.ownerUserId]) : []\n if (ownerUserIds.length > 0) {\n filters.owner_user_id =\n ownerUserIds.length === 1 ? { $eq: ownerUserIds[0] } : { $in: ownerUserIds }\n }\n\n // Currency filter (additive; sourced by the kanban Currency popover). Codes are\n // normalised to upper-case so `usd` / `USD` / mixed-case query params all hit the same\n // stored value (the deals API persists `value_currency` upper-case already).\n const currencyCodes = query.valueCurrency ? normalizeCurrencyList(query.valueCurrency) : []\n if (currencyCodes.length > 0) {\n filters.value_currency =\n currencyCodes.length === 1 ? { $eq: currencyCodes[0] } : { $in: currencyCodes }\n }\n\n const expectedCloseFrom = parseDateInput(query.expectedCloseAtFrom)\n const expectedCloseTo = parseDateInput(query.expectedCloseAtTo)\n if (expectedCloseFrom || expectedCloseTo) {\n const range: Record<string, Date> = {}\n if (expectedCloseFrom) range.$gte = expectedCloseFrom\n if (expectedCloseTo) range.$lte = expectedCloseTo\n filters.expected_close_at = range\n }\n\n if (query.isOverdue && !query.needsAttention) {\n const today = new Date()\n today.setHours(0, 0, 0, 0)\n if (statusList.length === 0) {\n filters.status = { $eq: 'open' }\n }\n const existingRange =\n filters.expected_close_at && typeof filters.expected_close_at === 'object'\n ? (filters.expected_close_at as Record<string, Date>)\n : {}\n existingRange.$lt = today\n filters.expected_close_at = existingRange\n }\n\n if (query.isStuck && !query.needsAttention && ctx) {\n const tenantId = ctx.auth?.tenantId\n // CrudCtx.auth carries `orgId` (not `organizationId`). The previous code referenced\n // `organizationId` which is always `undefined`, so the typeof check below silently\n // skipped the entire isStuck branch \u2014 `?isStuck=true` was a no-op on this endpoint.\n const organizationId = ctx.auth?.orgId\n if (typeof tenantId === 'string' && typeof organizationId === 'string') {\n const em = ctx.container.resolve<EntityManager>('em')\n const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)\n intersectIds(stuckIds)\n }\n }\n\n if (query.needsAttention && ctx) {\n const tenantId = ctx.auth?.tenantId\n const organizationId = ctx.auth?.orgId\n if (typeof tenantId === 'string' && typeof organizationId === 'string') {\n const em = ctx.container.resolve<EntityManager>('em')\n const attentionIds = await fetchNeedAttentionDealIds(em, organizationId, tenantId)\n intersectIds(attentionIds)\n }\n }\n\n // Pre-pagination association filter. Must run on the FULL dataset (before pagination),\n // otherwise matching deals on later pages disappear and `total` would be wrong. Read the\n // raw URL too so legacy `?personEntityId=` / `?companyEntityId=` keep working alongside the\n // canonical `?personId=` / `?companyId=`.\n const url = ctx?.request ? new URL(ctx.request.url) : null\n const personCandidates: unknown[] = [query.personId, query.personEntityId]\n const companyCandidates: unknown[] = [query.companyId, query.companyEntityId]\n if (url) {\n personCandidates.push(url.searchParams.getAll('personId'))\n personCandidates.push(url.searchParams.getAll('personEntityId'))\n companyCandidates.push(url.searchParams.getAll('companyId'))\n companyCandidates.push(url.searchParams.getAll('companyEntityId'))\n }\n const selectedPersonIds = normalizeUuidList(personCandidates)\n const selectedCompanyIds = normalizeUuidList(companyCandidates)\n if ((selectedPersonIds.length > 0 || selectedCompanyIds.length > 0) && ctx) {\n const tenantId = ctx.auth?.tenantId\n // `ctx.auth` exposes `orgId` (see AuthContext in @open-mercato/shared/lib/auth/server).\n // Read it under the correct key \u2014 the previous code's `organizationId` would always be\n // `undefined`, silently disabling association filtering on the deals list endpoint.\n const organizationId = ctx.auth?.orgId\n if (typeof tenantId === 'string' && typeof organizationId === 'string') {\n const em = ctx.container.resolve<EntityManager>('em')\n const matchedIds = await fetchDealIdsMatchingAssociations(\n em,\n organizationId,\n tenantId,\n selectedPersonIds,\n selectedCompanyIds,\n )\n // intersectIds with empty array \u2192 no rows; collapses the page to zero, total stays correct.\n intersectIds(matchedIds)\n }\n }\n\n if (ctx && advancedFilterTree) {\n const advancedFilters = mergeAdvancedFilterTree({ ...filters }, advancedFilterTree)\n const matchedIds = await findMatchingEntityIdsWithQueryEngine({\n ctx,\n entityId: E.customers.customer_deal,\n filters: advancedFilters,\n })\n if (matchedIds !== null) {\n intersectIds(matchedIds)\n }\n }\n\n if (restrictedIds !== null) {\n applyEntityIdRestriction(filters, restrictedIds)\n }\n\n return filters\n}\n\nconst crud = makeCrudRoute<unknown, unknown, DealListQuery>({\n metadata: routeMetadata,\n orm: {\n entity: CustomerDeal,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n indexer: {\n entityType: E.customers.customer_deal,\n },\n enrichers: { entityId: 'customers.deal' },\n list: {\n schema: dealListQuerySchema,\n entityId: E.customers.customer_deal,\n fields: [\n 'id',\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'pipeline_id',\n 'pipeline_stage_id',\n 'value_amount',\n 'value_currency',\n 'probability',\n 'expected_close_at',\n 'owner_user_id',\n 'source',\n 'closure_outcome',\n 'loss_reason_id',\n 'loss_notes',\n 'organization_id',\n 'tenant_id',\n 'created_at',\n 'updated_at',\n ],\n decorateCustomFields: {\n entityIds: E.customers.customer_deal,\n },\n sortFieldMap: {\n createdAt: 'created_at',\n updatedAt: 'updated_at',\n title: 'title',\n value: 'value_amount',\n probability: 'probability',\n expectedCloseAt: 'expected_close_at',\n },\n buildFilters: buildDealListFilters,\n },\n actions: {\n create: {\n commandId: 'customers.deals.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(dealCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.dealId ?? result?.id ?? null }),\n status: 201,\n },\n update: {\n commandId: 'customers.deals.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(dealUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'customers.deals.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id =\n parsed?.body?.id ??\n parsed?.id ??\n parsed?.query?.id ??\n (ctx.request ? new URL(ctx.request.url).searchParams.get('id') : null)\n if (!id) throw new CrudHttpError(400, { error: translate('customers.errors.deal_required', 'Deal id is required') })\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n hooks: {\n // afterList only DECORATES results with `people`/`companies` arrays \u2014 it must not filter,\n // because filtering after pagination would drop deals on later pages and rewrite `total`\n // to a misleading value. Association filtering happens pre-pagination in `buildFilters`\n // via `fetchDealIdsMatchingAssociations`.\n afterList: async (payload, ctx) => {\n const items = Array.isArray(payload.items) ? payload.items : []\n if (!items.length) return\n const scopeSource = (items[0] ?? {}) as Record<string, unknown>\n const tenantIdRaw = scopeSource.tenantId ?? scopeSource.tenant_id\n const fallbackTenantId = (typeof tenantIdRaw === 'string' && tenantIdRaw.trim().length ? tenantIdRaw : null) ?? ctx.auth?.tenantId ?? null\n const orgIdRaw = scopeSource.organizationId ?? scopeSource.organization_id\n const fallbackOrganizationId = (typeof orgIdRaw === 'string' && orgIdRaw.trim().length ? orgIdRaw : null) ?? ctx.auth?.orgId ?? null\n const ids = items\n .map((item: unknown) => {\n if (!item || typeof item !== 'object') return null\n const candidate = (item as Record<string, unknown>).id\n return typeof candidate === 'string' && candidate.trim().length ? candidate : null\n })\n .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)\n if (!ids.length) {\n payload.items = []\n payload.total = 0\n return\n }\n try {\n const em = (ctx.container.resolve('em') as EntityManager)\n const [allPersonLinks, allCompanyLinks] = await Promise.all([\n findWithDecryption(\n em,\n CustomerDealPersonLink,\n { deal: { $in: ids } },\n { populate: ['person'] },\n { tenantId: fallbackTenantId, organizationId: fallbackOrganizationId },\n ),\n findWithDecryption(\n em,\n CustomerDealCompanyLink,\n { deal: { $in: ids } },\n { populate: ['company'] },\n { tenantId: fallbackTenantId, organizationId: fallbackOrganizationId },\n ),\n ])\n\n const personAssignments = new Map<string, { id: string; label: string }[]>()\n allPersonLinks.forEach((link) => {\n const deal = link.deal\n const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null\n const dealId = typeof deal === 'string' ? deal\n : dealRecord && typeof dealRecord.id === 'string' ? dealRecord.id\n : null\n if (!dealId) return\n const personRef = link.person\n const personRecord = personRef && typeof personRef === 'object' ? personRef as unknown as Record<string, unknown> : null\n const personId = typeof personRef === 'string' ? personRef\n : personRecord && typeof personRecord.id === 'string' ? personRecord.id\n : null\n if (!personId) return\n const label = personRecord && typeof personRecord.displayName === 'string'\n ? personRecord.displayName\n : ''\n const bucket = personAssignments.get(dealId) ?? []\n if (!bucket.some((entry) => entry.id === personId)) {\n bucket.push({ id: personId, label })\n personAssignments.set(dealId, bucket)\n }\n })\n\n const companyAssignments = new Map<string, { id: string; label: string }[]>()\n allCompanyLinks.forEach((link) => {\n const deal = link.deal\n const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null\n const dealId = typeof deal === 'string' ? deal\n : dealRecord && typeof dealRecord.id === 'string' ? dealRecord.id\n : null\n if (!dealId) return\n const companyRef = link.company\n const companyRecord = companyRef && typeof companyRef === 'object' ? companyRef as unknown as Record<string, unknown> : null\n const companyId = typeof companyRef === 'string' ? companyRef\n : companyRecord && typeof companyRecord.id === 'string' ? companyRecord.id\n : null\n if (!companyId) return\n const label = companyRecord && typeof companyRecord.displayName === 'string'\n ? companyRecord.displayName\n : ''\n const bucket = companyAssignments.get(dealId) ?? []\n if (!bucket.some((entry) => entry.id === companyId)) {\n bucket.push({ id: companyId, label })\n companyAssignments.set(dealId, bucket)\n }\n })\n\n const enhancedItems = items\n .map((item: unknown) => {\n if (!item || typeof item !== 'object') return null\n const data = item as Record<string, unknown>\n const candidate = typeof data.id === 'string' ? data.id : null\n if (!candidate || !candidate.trim().length) return null\n const people = personAssignments.get(candidate) ?? []\n const companies = companyAssignments.get(candidate) ?? []\n const tenantIdRaw =\n typeof data.tenantId === 'string'\n ? data.tenantId\n : typeof data.tenant_id === 'string'\n ? data.tenant_id\n : null\n const organizationIdRaw =\n typeof data.organizationId === 'string'\n ? data.organizationId\n : typeof data.organization_id === 'string'\n ? data.organization_id\n : null\n const tenantId = tenantIdRaw && tenantIdRaw.trim().length ? tenantIdRaw.trim() : null\n const organizationId = organizationIdRaw && organizationIdRaw.trim().length ? organizationIdRaw.trim() : null\n return {\n ...data,\n personIds: people.map((entry) => entry.id),\n people,\n companyIds: companies.map((entry) => entry.id),\n companies,\n tenantId,\n organizationId,\n }\n })\n .filter(\n (item: Record<string, unknown> | null): item is Record<string, unknown> => item !== null,\n )\n\n payload.items = enhancedItems\n } catch (err) {\n // We swallow rather than fail the request because the kanban is still useful without\n // people/companies labels (cards just lose their company pill). Tag every item with\n // `_associations: { ok: false }` so a future surface can render a degraded-state hint\n // instead of silently showing cards without company badges.\n logger.warn('failed to decorate items with person/company links', { component: 'deals', err })\n payload.items = items.map((item: unknown) => {\n if (!item || typeof item !== 'object') return item\n return {\n ...(item as Record<string, unknown>),\n _associations: {\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n },\n }\n })\n }\n },\n },\n})\n\nconst { POST, PUT, DELETE } = crud\n\nexport { POST, PUT, DELETE }\nexport const GET = crud.GET\n\nconst dealAssociationSchema = z.object({\n id: z.string().uuid(),\n label: z.string().nullable(),\n})\n\nconst dealListItemSchema = z\n .object({\n id: z.string().uuid(),\n title: z.string().nullable(),\n description: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n pipeline_stage: z.string().nullable().optional(),\n pipeline_id: z.string().uuid().nullable().optional(),\n pipeline_stage_id: z.string().uuid().nullable().optional(),\n value_amount: z.number().nullable().optional(),\n value_currency: z.string().nullable().optional(),\n probability: z.number().nullable().optional(),\n expected_close_at: z.string().nullable().optional(),\n owner_user_id: z.string().uuid().nullable().optional(),\n source: z.string().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n personIds: z.array(z.string().uuid()).optional(),\n people: z.array(dealAssociationSchema).optional(),\n companyIds: z.array(z.string().uuid()).optional(),\n companies: z.array(dealAssociationSchema).optional(),\n organizationId: z.string().uuid().nullable().optional(),\n tenantId: z.string().uuid().nullable().optional(),\n })\n .passthrough()\n\nconst dealCreateResponseSchema = z.object({\n id: z.string().uuid().nullable(),\n})\n\nexport const openApi = createCustomersCrudOpenApi({\n resourceName: 'Deal',\n querySchema: dealListQuerySchema,\n listResponseSchema: createPagedListResponseSchema(dealListItemSchema),\n create: {\n schema: dealCreateSchema,\n responseSchema: dealCreateResponseSchema,\n description: 'Creates a sales deal, optionally associating people and companies.',\n },\n update: {\n schema: dealUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates pipeline position, metadata, or associations for an existing deal.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Deletes a deal by `id`. The identifier may be provided in the body or query parameters.',\n },\n})\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import { z } from 'zod'\nimport { makeCrudRoute } from '@open-mercato/shared/lib/crud/factory'\nimport { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { CustomerDeal, CustomerDealPersonLink, CustomerDealCompanyLink } from '../../data/entities'\nimport { dealCreateSchema, dealUpdateSchema } from '../../data/validators'\nimport { E } from '#generated/entities.ids.generated'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { parseBooleanFromUnknown } from '@open-mercato/shared/lib/boolean'\nimport {\n applyEntityIdRestriction,\n findMatchingEntityIdsWithQueryEngine,\n findMatchingEntityIdsBySearchTokensAcrossSources,\n parseScopedCommandInput,\n} from '../utils'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n createCustomersCrudOpenApi,\n createPagedListResponseSchema,\n defaultOkResponseSchema,\n} from '../openapi'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { isTenantDataEncryptionEnabled } from '@open-mercato/shared/lib/encryption/toggles'\nimport { escapeLikePattern } from '@open-mercato/shared/lib/db/escapeLikePattern'\nimport { consumeAdvancedFilterState, mergeAdvancedFilterTree } from '@open-mercato/shared/lib/crud/advanced-filter-integration'\nimport type { FilterGroup, FilterRule } from '@open-mercato/shared/lib/query/advanced-filter-tree'\nimport { fetchStuckDealIds } from '../../lib/stuckDeals'\nimport { expandDealStatusAliases } from '../../lib/dealStatus'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('customers')\n\nconst rawBodySchema = z.object({}).passthrough()\n\nconst stringOrStringArray = z.union([z.string(), z.array(z.string())])\nconst OPEN_DEAL_STATUSES = ['open', 'in_progress'] as const\n// Tree operators whose `status` values are rewritten through the canonical vocabulary.\n// Text (`contains`/`starts_with`/\u2026) and range (`between`) operators are left untouched\n// so a URL-supplied rule keeps its scalar semantics.\nconst STATUS_TREE_OPERATORS = new Set([\n 'is',\n 'equals',\n 'is_not',\n 'not_equals',\n 'is_any_of',\n 'has_any_of',\n 'is_none_of',\n 'has_none_of',\n])\nconst booleanQueryParam = z.preprocess((value) => {\n const parsed = parseBooleanFromUnknown(value)\n return parsed === null ? value : parsed\n}, z.boolean()).optional()\n\nexport const dealListQuerySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(50),\n id: z.string().uuid().optional(),\n search: z.string().optional(),\n status: stringOrStringArray.optional(),\n pipelineStage: z.string().optional(),\n pipelineId: stringOrStringArray.optional(),\n pipelineStageId: z.union([z.string().uuid(), z.literal('__unassigned')]).optional(),\n ownerUserId: stringOrStringArray.optional(),\n expectedCloseAtFrom: z.string().optional(),\n expectedCloseAtTo: z.string().optional(),\n isStuck: booleanQueryParam,\n isOverdue: booleanQueryParam,\n needsAttention: booleanQueryParam,\n valueCurrency: stringOrStringArray.optional(),\n sortField: z.string().optional(),\n sortDir: z.enum(['asc', 'desc']).optional(),\n /**\n * @deprecated Use `personId` instead. The `personEntityId` alias is kept for backward\n * compatibility with older clients and will be removed in a future minor release.\n */\n personEntityId: z.string().uuid().optional().describe('Deprecated; use personId'),\n /**\n * @deprecated Use `companyId` instead. The `companyEntityId` alias is kept for backward\n * compatibility with older clients and will be removed in a future minor release.\n */\n companyEntityId: z.string().uuid().optional().describe('Deprecated; use companyId'),\n personId: stringOrStringArray.optional(),\n companyId: stringOrStringArray.optional(),\n })\n .passthrough()\n\nconst routeMetadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.deals.view'] },\n POST: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n PUT: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n DELETE: { requireAuth: true, requireFeatures: ['customers.deals.manage'] },\n}\n\nexport const metadata = routeMetadata\n\nexport type DealListQuery = z.infer<typeof dealListQuerySchema>\n\nfunction parseUuid(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const trimmed = value.trim()\n if (!trimmed.length) return null\n const result = z.string().uuid().safeParse(trimmed)\n return result.success ? trimmed : null\n}\n\nfunction normalizeStringList(value: unknown): string[] {\n const set = new Set<string>()\n const visit = (entry: unknown) => {\n if (entry == null) return\n if (Array.isArray(entry)) {\n entry.forEach(visit)\n return\n }\n if (typeof entry !== 'string') return\n entry\n .split(',')\n .map((token) => token.trim())\n .filter((token) => token.length > 0)\n .forEach((token) => set.add(token))\n }\n visit(value)\n return Array.from(set)\n}\n\nfunction parseDateInput(value: unknown): Date | null {\n if (!(typeof value === 'string') || value.trim().length === 0) return null\n const date = new Date(value)\n return Number.isNaN(date.getTime()) ? null : date\n}\n\n/**\n * Pre-pagination ID narrowing for person/company filters.\n *\n * Mirrors the SQL the aggregate route uses (`EXISTS ... IN (...)`) so the list endpoint\n * and the lane-header aggregate return a consistent set of deals for the same filters.\n * Semantics: OR within a category (any selected person/company matches), AND across\n * categories (deal must match at least one person AND at least one company when both\n * filter lists are provided).\n *\n * Returns the matched deal IDs (UUIDs). An empty array means \"no deals match\" \u2014 callers\n * must intersect with `restrictedIds` (which will collapse the list to zero).\n */\nasync function fetchDealIdsMatchingAssociations(\n em: EntityManager,\n organizationId: string,\n tenantId: string,\n personIds: string[],\n companyIds: string[],\n): Promise<string[]> {\n if (!personIds.length && !companyIds.length) return []\n const where: string[] = [\n 'organization_id = ?',\n 'tenant_id = ?',\n 'deleted_at IS NULL',\n ]\n const values: Array<string | number> = [organizationId, tenantId]\n if (personIds.length) {\n const placeholders = personIds.map(() => '?').join(',')\n where.push(\n `EXISTS (SELECT 1 FROM customer_deal_people dp WHERE dp.deal_id = customer_deals.id AND dp.person_entity_id IN (${placeholders}))`,\n )\n values.push(...personIds)\n }\n if (companyIds.length) {\n const placeholders = companyIds.map(() => '?').join(',')\n where.push(\n `EXISTS (SELECT 1 FROM customer_deal_companies dc WHERE dc.deal_id = customer_deals.id AND dc.company_entity_id IN (${placeholders}))`,\n )\n values.push(...companyIds)\n }\n const rows = await em.getConnection().execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals WHERE ${where.join(' AND ')}`,\n values,\n )\n return rows.map((row) => row.id)\n}\n\nasync function fetchNeedAttentionDealIds(\n em: EntityManager,\n organizationId: string,\n tenantId: string,\n): Promise<string[]> {\n const connection = em.getConnection()\n const overdueRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n AND status = 'open'\n AND expected_close_at IS NOT NULL\n AND expected_close_at < CURRENT_DATE`,\n [organizationId, tenantId],\n )\n\n const attentionIds = new Set(overdueRows.map((row) => row.id))\n const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)\n if (stuckIds.length > 0) {\n const idPlaceholders = stuckIds.map(() => '?').join(',')\n const statusPlaceholders = OPEN_DEAL_STATUSES.map(() => '?').join(',')\n const openStuckRows = await connection.execute<Array<{ id: string }>>(\n `SELECT id FROM customer_deals\n WHERE organization_id = ?\n AND tenant_id = ?\n AND deleted_at IS NULL\n AND status IN (${statusPlaceholders})\n AND id IN (${idPlaceholders})`,\n [organizationId, tenantId, ...OPEN_DEAL_STATUSES, ...stuckIds],\n )\n for (const row of openStuckRows) attentionIds.add(row.id)\n }\n\n return Array.from(attentionIds)\n}\n\nfunction normalizeCurrencyList(value: unknown): string[] {\n const set = new Set<string>()\n const visit = (entry: unknown) => {\n if (entry == null) return\n if (Array.isArray(entry)) {\n entry.forEach(visit)\n return\n }\n if (typeof entry !== 'string') return\n entry\n .split(',')\n .map((token) => token.trim().toUpperCase())\n .filter((token) => /^[A-Z]{3}$/.test(token))\n .forEach((token) => set.add(token))\n }\n visit(value)\n return Array.from(set)\n}\n\nfunction normalizeUuidList(values: Array<unknown>): string[] {\n const set = new Set<string>()\n values.forEach((candidate) => {\n if (Array.isArray(candidate)) {\n candidate.forEach((entry) => {\n const parsed = parseUuid(entry)\n if (parsed) set.add(parsed)\n })\n return\n }\n if (typeof candidate === 'string' && candidate.includes(',')) {\n candidate\n .split(',')\n .map((entry) => entry.trim())\n .forEach((entry) => {\n const parsed = parseUuid(entry)\n if (parsed) set.add(parsed)\n })\n return\n }\n const parsed = parseUuid(candidate)\n if (parsed) set.add(parsed)\n })\n return Array.from(set)\n}\n\nexport async function buildDealListFilters(query: DealListQuery, ctx?: import('@open-mercato/shared/lib/crud/factory').CrudCtx) {\n const advancedFilterTree = consumeAdvancedFilterState(query)\n if (advancedFilterTree) {\n // Expand status aliases in the advanced-filter tree so List (tree path) and\n // Kanban (plain ?status=) share the same canonical vocabulary (#5107). Only\n // set-membership operators are rewritten \u2014 text/range operators on `status`\n // keep their pre-existing scalar semantics instead of being corrupted.\n const walk = (node: FilterGroup | FilterRule): void => {\n if (node.type === 'rule' && node.field === 'status' && STATUS_TREE_OPERATORS.has(node.operator)) {\n const listOperators = new Set(['is_any_of', 'has_any_of', 'is_none_of', 'has_none_of'])\n const rawValues: string[] = Array.isArray(node.value)\n ? (node.value as unknown[]).filter((v): v is string => typeof v === 'string')\n : typeof node.value === 'string'\n ? [node.value]\n : []\n if (rawValues.length === 0) return\n const expanded = expandDealStatusAliases(rawValues)\n if (listOperators.has(node.operator)) {\n node.value = expanded\n } else if (expanded.length === 1) {\n node.value = expanded[0]\n } else {\n node.value = expanded\n if (node.operator === 'is' || node.operator === 'equals') {\n node.operator = 'is_any_of'\n } else if (node.operator === 'is_not' || node.operator === 'not_equals') {\n node.operator = 'is_none_of'\n }\n }\n } else if (node.type === 'group') {\n for (const child of node.children) walk(child)\n }\n }\n walk(advancedFilterTree.root)\n }\n const filters: Record<string, unknown> = {}\n let restrictedIds: string[] | null = null\n\n const intersectIds = (ids: string[]) => {\n if (restrictedIds === null) {\n restrictedIds = ids\n return\n }\n const lookup = new Set(ids)\n restrictedIds = restrictedIds.filter((id) => lookup.has(id))\n }\n\n if (query.id) filters.id = { $eq: query.id }\n\n if (query.search) {\n const matchingIds = ctx\n ? await findMatchingEntityIdsBySearchTokensAcrossSources({\n ctx,\n query: 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 : null\n if (matchingIds !== null && matchingIds.length > 0) {\n intersectIds(matchingIds)\n } else if (isTenantDataEncryptionEnabled()) {\n // `customers:customer_deal.title` and `.description` are encrypted at rest\n // (see `encryption.ts`). The `$ilike` fallback below would silently match\n // nothing on the ciphertext and produce empty pages without disclosing why \u2014\n // collapse the result to \"no matches\" instead so the kanban + list views\n // agree, and lane aggregates stay consistent with the list endpoint.\n intersectIds([])\n } else {\n const searchPattern = `%${escapeLikePattern(query.search)}%`\n filters.$or = [\n { title: { $ilike: searchPattern } },\n { description: { $ilike: searchPattern } },\n ]\n }\n }\n\n const rawStatusList = query.status ? normalizeStringList(query.status) : []\n const statusList = expandDealStatusAliases(rawStatusList)\n if (statusList.length > 0) {\n filters.status = statusList.length === 1 ? { $eq: statusList[0] } : { $in: statusList }\n }\n\n if (query.pipelineStage) {\n filters.pipeline_stage = { $eq: query.pipelineStage }\n }\n\n const pipelineIds = query.pipelineId ? normalizeUuidList([query.pipelineId]) : []\n if (pipelineIds.length > 0) {\n filters.pipeline_id = pipelineIds.length === 1 ? { $eq: pipelineIds[0] } : { $in: pipelineIds }\n }\n\n if (query.pipelineStageId === '__unassigned') {\n filters.pipeline_stage_id = { $eq: null }\n } else if (query.pipelineStageId) {\n filters.pipeline_stage_id = { $eq: query.pipelineStageId }\n }\n\n const ownerUserIds = query.ownerUserId ? normalizeUuidList([query.ownerUserId]) : []\n if (ownerUserIds.length > 0) {\n filters.owner_user_id =\n ownerUserIds.length === 1 ? { $eq: ownerUserIds[0] } : { $in: ownerUserIds }\n }\n\n // Currency filter (additive; sourced by the kanban Currency popover). Codes are\n // normalised to upper-case so `usd` / `USD` / mixed-case query params all hit the same\n // stored value (the deals API persists `value_currency` upper-case already).\n const currencyCodes = query.valueCurrency ? normalizeCurrencyList(query.valueCurrency) : []\n if (currencyCodes.length > 0) {\n filters.value_currency =\n currencyCodes.length === 1 ? { $eq: currencyCodes[0] } : { $in: currencyCodes }\n }\n\n const expectedCloseFrom = parseDateInput(query.expectedCloseAtFrom)\n const expectedCloseTo = parseDateInput(query.expectedCloseAtTo)\n if (expectedCloseFrom || expectedCloseTo) {\n const range: Record<string, Date> = {}\n if (expectedCloseFrom) range.$gte = expectedCloseFrom\n if (expectedCloseTo) range.$lte = expectedCloseTo\n filters.expected_close_at = range\n }\n\n if (query.isOverdue && !query.needsAttention) {\n const today = new Date()\n today.setHours(0, 0, 0, 0)\n if (statusList.length === 0) {\n filters.status = { $eq: 'open' }\n }\n const existingRange =\n filters.expected_close_at && typeof filters.expected_close_at === 'object'\n ? (filters.expected_close_at as Record<string, Date>)\n : {}\n existingRange.$lt = today\n filters.expected_close_at = existingRange\n }\n\n if (query.isStuck && !query.needsAttention && ctx) {\n const tenantId = ctx.auth?.tenantId\n // CrudCtx.auth carries `orgId` (not `organizationId`). The previous code referenced\n // `organizationId` which is always `undefined`, so the typeof check below silently\n // skipped the entire isStuck branch \u2014 `?isStuck=true` was a no-op on this endpoint.\n const organizationId = ctx.auth?.orgId\n if (typeof tenantId === 'string' && typeof organizationId === 'string') {\n const em = ctx.container.resolve<EntityManager>('em')\n const stuckIds = await fetchStuckDealIds(em, organizationId, tenantId)\n intersectIds(stuckIds)\n }\n }\n\n if (query.needsAttention && ctx) {\n const tenantId = ctx.auth?.tenantId\n const organizationId = ctx.auth?.orgId\n if (typeof tenantId === 'string' && typeof organizationId === 'string') {\n const em = ctx.container.resolve<EntityManager>('em')\n const attentionIds = await fetchNeedAttentionDealIds(em, organizationId, tenantId)\n intersectIds(attentionIds)\n }\n }\n\n // Pre-pagination association filter. Must run on the FULL dataset (before pagination),\n // otherwise matching deals on later pages disappear and `total` would be wrong. Read the\n // raw URL too so legacy `?personEntityId=` / `?companyEntityId=` keep working alongside the\n // canonical `?personId=` / `?companyId=`.\n const url = ctx?.request ? new URL(ctx.request.url) : null\n const personCandidates: unknown[] = [query.personId, query.personEntityId]\n const companyCandidates: unknown[] = [query.companyId, query.companyEntityId]\n if (url) {\n personCandidates.push(url.searchParams.getAll('personId'))\n personCandidates.push(url.searchParams.getAll('personEntityId'))\n companyCandidates.push(url.searchParams.getAll('companyId'))\n companyCandidates.push(url.searchParams.getAll('companyEntityId'))\n }\n const selectedPersonIds = normalizeUuidList(personCandidates)\n const selectedCompanyIds = normalizeUuidList(companyCandidates)\n if ((selectedPersonIds.length > 0 || selectedCompanyIds.length > 0) && ctx) {\n const tenantId = ctx.auth?.tenantId\n // `ctx.auth` exposes `orgId` (see AuthContext in @open-mercato/shared/lib/auth/server).\n // Read it under the correct key \u2014 the previous code's `organizationId` would always be\n // `undefined`, silently disabling association filtering on the deals list endpoint.\n const organizationId = ctx.auth?.orgId\n if (typeof tenantId === 'string' && typeof organizationId === 'string') {\n const em = ctx.container.resolve<EntityManager>('em')\n const matchedIds = await fetchDealIdsMatchingAssociations(\n em,\n organizationId,\n tenantId,\n selectedPersonIds,\n selectedCompanyIds,\n )\n // intersectIds with empty array \u2192 no rows; collapses the page to zero, total stays correct.\n intersectIds(matchedIds)\n }\n }\n\n if (ctx && advancedFilterTree) {\n const advancedFilters = mergeAdvancedFilterTree({ ...filters }, advancedFilterTree)\n const matchedIds = await findMatchingEntityIdsWithQueryEngine({\n ctx,\n entityId: E.customers.customer_deal,\n filters: advancedFilters,\n })\n if (matchedIds !== null) {\n intersectIds(matchedIds)\n }\n }\n\n if (restrictedIds !== null) {\n applyEntityIdRestriction(filters, restrictedIds)\n }\n\n return filters\n}\n\nconst crud = makeCrudRoute<unknown, unknown, DealListQuery>({\n metadata: routeMetadata,\n orm: {\n entity: CustomerDeal,\n idField: 'id',\n orgField: 'organizationId',\n tenantField: 'tenantId',\n softDeleteField: 'deletedAt',\n },\n indexer: {\n entityType: E.customers.customer_deal,\n },\n enrichers: { entityId: 'customers.deal' },\n list: {\n schema: dealListQuerySchema,\n entityId: E.customers.customer_deal,\n fields: [\n 'id',\n 'title',\n 'description',\n 'status',\n 'pipeline_stage',\n 'pipeline_id',\n 'pipeline_stage_id',\n 'value_amount',\n 'value_currency',\n 'probability',\n 'expected_close_at',\n 'owner_user_id',\n 'source',\n 'closure_outcome',\n 'loss_reason_id',\n 'loss_notes',\n 'organization_id',\n 'tenant_id',\n 'created_at',\n 'updated_at',\n ],\n decorateCustomFields: {\n entityIds: E.customers.customer_deal,\n },\n sortFieldMap: {\n createdAt: 'created_at',\n updatedAt: 'updated_at',\n title: 'title',\n value: 'value_amount',\n probability: 'probability',\n expectedCloseAt: 'expected_close_at',\n },\n buildFilters: buildDealListFilters,\n },\n actions: {\n create: {\n commandId: 'customers.deals.create',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(dealCreateSchema, raw ?? {}, ctx, translate)\n },\n response: ({ result }) => ({ id: result?.dealId ?? result?.id ?? null }),\n status: 201,\n },\n update: {\n commandId: 'customers.deals.update',\n schema: rawBodySchema,\n mapInput: async ({ raw, ctx }) => {\n const { translate } = await resolveTranslations()\n return parseScopedCommandInput(dealUpdateSchema, raw ?? {}, ctx, translate)\n },\n response: () => ({ ok: true }),\n },\n delete: {\n commandId: 'customers.deals.delete',\n schema: rawBodySchema,\n mapInput: async ({ parsed, ctx }) => {\n const { translate } = await resolveTranslations()\n const id =\n parsed?.body?.id ??\n parsed?.id ??\n parsed?.query?.id ??\n (ctx.request ? new URL(ctx.request.url).searchParams.get('id') : null)\n if (!id) throw new CrudHttpError(400, { error: translate('customers.errors.deal_required', 'Deal id is required') })\n return { id }\n },\n response: () => ({ ok: true }),\n },\n },\n hooks: {\n // afterList only DECORATES results with `people`/`companies` arrays \u2014 it must not filter,\n // because filtering after pagination would drop deals on later pages and rewrite `total`\n // to a misleading value. Association filtering happens pre-pagination in `buildFilters`\n // via `fetchDealIdsMatchingAssociations`.\n afterList: async (payload, ctx) => {\n const items = Array.isArray(payload.items) ? payload.items : []\n if (!items.length) return\n const scopeSource = (items[0] ?? {}) as Record<string, unknown>\n const tenantIdRaw = scopeSource.tenantId ?? scopeSource.tenant_id\n const fallbackTenantId = (typeof tenantIdRaw === 'string' && tenantIdRaw.trim().length ? tenantIdRaw : null) ?? ctx.auth?.tenantId ?? null\n const orgIdRaw = scopeSource.organizationId ?? scopeSource.organization_id\n const fallbackOrganizationId = (typeof orgIdRaw === 'string' && orgIdRaw.trim().length ? orgIdRaw : null) ?? ctx.auth?.orgId ?? null\n const ids = items\n .map((item: unknown) => {\n if (!item || typeof item !== 'object') return null\n const candidate = (item as Record<string, unknown>).id\n return typeof candidate === 'string' && candidate.trim().length ? candidate : null\n })\n .filter((value: string | null): value is string => typeof value === 'string' && value.length > 0)\n if (!ids.length) {\n payload.items = []\n payload.total = 0\n return\n }\n try {\n const em = (ctx.container.resolve('em') as EntityManager)\n const [allPersonLinks, allCompanyLinks] = await Promise.all([\n findWithDecryption(\n em,\n CustomerDealPersonLink,\n { deal: { $in: ids } },\n { populate: ['person'] },\n { tenantId: fallbackTenantId, organizationId: fallbackOrganizationId },\n ),\n findWithDecryption(\n em,\n CustomerDealCompanyLink,\n { deal: { $in: ids } },\n { populate: ['company'] },\n { tenantId: fallbackTenantId, organizationId: fallbackOrganizationId },\n ),\n ])\n\n const personAssignments = new Map<string, { id: string; label: string }[]>()\n allPersonLinks.forEach((link) => {\n const deal = link.deal\n const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null\n const dealId = typeof deal === 'string' ? deal\n : dealRecord && typeof dealRecord.id === 'string' ? dealRecord.id\n : null\n if (!dealId) return\n const personRef = link.person\n const personRecord = personRef && typeof personRef === 'object' ? personRef as unknown as Record<string, unknown> : null\n const personId = typeof personRef === 'string' ? personRef\n : personRecord && typeof personRecord.id === 'string' ? personRecord.id\n : null\n if (!personId) return\n const label = personRecord && typeof personRecord.displayName === 'string'\n ? personRecord.displayName\n : ''\n const bucket = personAssignments.get(dealId) ?? []\n if (!bucket.some((entry) => entry.id === personId)) {\n bucket.push({ id: personId, label })\n personAssignments.set(dealId, bucket)\n }\n })\n\n const companyAssignments = new Map<string, { id: string; label: string }[]>()\n allCompanyLinks.forEach((link) => {\n const deal = link.deal\n const dealRecord = deal && typeof deal === 'object' ? deal as unknown as Record<string, unknown> : null\n const dealId = typeof deal === 'string' ? deal\n : dealRecord && typeof dealRecord.id === 'string' ? dealRecord.id\n : null\n if (!dealId) return\n const companyRef = link.company\n const companyRecord = companyRef && typeof companyRef === 'object' ? companyRef as unknown as Record<string, unknown> : null\n const companyId = typeof companyRef === 'string' ? companyRef\n : companyRecord && typeof companyRecord.id === 'string' ? companyRecord.id\n : null\n if (!companyId) return\n const label = companyRecord && typeof companyRecord.displayName === 'string'\n ? companyRecord.displayName\n : ''\n const bucket = companyAssignments.get(dealId) ?? []\n if (!bucket.some((entry) => entry.id === companyId)) {\n bucket.push({ id: companyId, label })\n companyAssignments.set(dealId, bucket)\n }\n })\n\n const enhancedItems = items\n .map((item: unknown) => {\n if (!item || typeof item !== 'object') return null\n const data = item as Record<string, unknown>\n const candidate = typeof data.id === 'string' ? data.id : null\n if (!candidate || !candidate.trim().length) return null\n const people = personAssignments.get(candidate) ?? []\n const companies = companyAssignments.get(candidate) ?? []\n const tenantIdRaw =\n typeof data.tenantId === 'string'\n ? data.tenantId\n : typeof data.tenant_id === 'string'\n ? data.tenant_id\n : null\n const organizationIdRaw =\n typeof data.organizationId === 'string'\n ? data.organizationId\n : typeof data.organization_id === 'string'\n ? data.organization_id\n : null\n const tenantId = tenantIdRaw && tenantIdRaw.trim().length ? tenantIdRaw.trim() : null\n const organizationId = organizationIdRaw && organizationIdRaw.trim().length ? organizationIdRaw.trim() : null\n return {\n ...data,\n personIds: people.map((entry) => entry.id),\n people,\n companyIds: companies.map((entry) => entry.id),\n companies,\n tenantId,\n organizationId,\n }\n })\n .filter(\n (item: Record<string, unknown> | null): item is Record<string, unknown> => item !== null,\n )\n\n payload.items = enhancedItems\n } catch (err) {\n // We swallow rather than fail the request because the kanban is still useful without\n // people/companies labels (cards just lose their company pill). Tag every item with\n // `_associations: { ok: false }` so a future surface can render a degraded-state hint\n // instead of silently showing cards without company badges.\n logger.warn('failed to decorate items with person/company links', { component: 'deals', err })\n payload.items = items.map((item: unknown) => {\n if (!item || typeof item !== 'object') return item\n return {\n ...(item as Record<string, unknown>),\n _associations: {\n ok: false,\n reason: err instanceof Error ? err.message : 'unknown',\n },\n }\n })\n }\n },\n },\n})\n\nconst { POST, PUT, DELETE } = crud\n\nexport { POST, PUT, DELETE }\nexport const GET = crud.GET\n\nconst dealAssociationSchema = z.object({\n id: z.string().uuid(),\n label: z.string().nullable(),\n})\n\nconst dealListItemSchema = z\n .object({\n id: z.string().uuid(),\n title: z.string().nullable(),\n description: z.string().nullable().optional(),\n status: z.string().nullable().optional(),\n pipeline_stage: z.string().nullable().optional(),\n pipeline_id: z.string().uuid().nullable().optional(),\n pipeline_stage_id: z.string().uuid().nullable().optional(),\n value_amount: z.number().nullable().optional(),\n value_currency: z.string().nullable().optional(),\n probability: z.number().nullable().optional(),\n expected_close_at: z.string().nullable().optional(),\n owner_user_id: z.string().uuid().nullable().optional(),\n source: z.string().nullable().optional(),\n organization_id: z.string().uuid().nullable().optional(),\n tenant_id: z.string().uuid().nullable().optional(),\n created_at: z.string().nullable().optional(),\n updated_at: z.string().nullable().optional(),\n personIds: z.array(z.string().uuid()).optional(),\n people: z.array(dealAssociationSchema).optional(),\n companyIds: z.array(z.string().uuid()).optional(),\n companies: z.array(dealAssociationSchema).optional(),\n organizationId: z.string().uuid().nullable().optional(),\n tenantId: z.string().uuid().nullable().optional(),\n })\n .passthrough()\n\nconst dealCreateResponseSchema = z.object({\n id: z.string().uuid().nullable(),\n})\n\nexport const openApi = createCustomersCrudOpenApi({\n resourceName: 'Deal',\n querySchema: dealListQuerySchema,\n listResponseSchema: createPagedListResponseSchema(dealListItemSchema),\n create: {\n schema: dealCreateSchema,\n responseSchema: dealCreateResponseSchema,\n description: 'Creates a sales deal, optionally associating people and companies.',\n },\n update: {\n schema: dealUpdateSchema,\n responseSchema: defaultOkResponseSchema,\n description: 'Updates pipeline position, metadata, or associations for an existing deal.',\n },\n del: {\n schema: z.object({ id: z.string().uuid() }),\n responseSchema: defaultOkResponseSchema,\n description: 'Deletes a deal by `id`. The identifier may be provided in the body or query parameters.',\n },\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["parsed", "tenantIdRaw"]
|
|
7
7
|
}
|
|
@@ -5,20 +5,15 @@ import { useDroppable } from "@dnd-kit/core";
|
|
|
5
5
|
import { EmptyState } from "@open-mercato/ui/primitives/empty-state";
|
|
6
6
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
7
7
|
import { translateWithFallback } from "@open-mercato/shared/lib/i18n/translate";
|
|
8
|
+
import { toneToDotClass } from "./toneClasses.js";
|
|
8
9
|
import { DealCard } from "./DealCard.js";
|
|
9
10
|
import { DashedTileButton } from "./DashedTileButton.js";
|
|
10
11
|
import { LANE_WIDTH_PX } from "./constants.js";
|
|
11
12
|
import { LaneCurrencyBreakdown } from "./LaneCurrencyBreakdown.js";
|
|
12
13
|
import { LaneResizeHandle } from "./LaneResizeHandle.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
warning: "bg-status-warning-icon",
|
|
17
|
-
info: "bg-status-info-icon",
|
|
18
|
-
neutral: "bg-status-neutral-icon",
|
|
19
|
-
brand: "bg-brand-violet",
|
|
20
|
-
pink: "bg-status-pink-icon"
|
|
21
|
-
};
|
|
14
|
+
function getAccentClass(tone) {
|
|
15
|
+
return toneToDotClass(tone, "bg-border");
|
|
16
|
+
}
|
|
22
17
|
const COUNT_BADGE_TONE_CLASS = {
|
|
23
18
|
success: "bg-status-success-bg text-status-success-text",
|
|
24
19
|
error: "bg-status-error-bg text-status-error-text",
|
|
@@ -28,10 +23,6 @@ const COUNT_BADGE_TONE_CLASS = {
|
|
|
28
23
|
brand: "bg-brand-violet/14 text-brand-violet",
|
|
29
24
|
pink: "bg-status-pink-bg text-status-pink-text"
|
|
30
25
|
};
|
|
31
|
-
function getAccentClass(tone) {
|
|
32
|
-
if (tone && tone in ACCENT_TONE_CLASS) return ACCENT_TONE_CLASS[tone];
|
|
33
|
-
return "bg-border";
|
|
34
|
-
}
|
|
35
26
|
function getCountBadgeClass(tone) {
|
|
36
27
|
if (tone && tone in COUNT_BADGE_TONE_CLASS) return COUNT_BADGE_TONE_CLASS[tone];
|
|
37
28
|
return "bg-muted text-muted-foreground";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../../../src/modules/customers/backend/customers/deals/pipeline/components/Lane.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useDroppable } from '@dnd-kit/core'\nimport { EmptyState } from '@open-mercato/ui/primitives/empty-state'\nimport type { RowActionItem } from '@open-mercato/ui/backend/RowActions'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { DealCard, type DealCardData } from './DealCard'\nimport { DashedTileButton } from './DashedTileButton'\nimport { LANE_WIDTH_PX } from './constants'\nimport { LaneCurrencyBreakdown } from './LaneCurrencyBreakdown'\nimport { LaneResizeHandle } from './LaneResizeHandle'\n\nexport type LaneStage = {\n id: string\n label: string\n tone: FilterOptionTone | null\n}\n\nexport type LaneAggregateProp = {\n count: number\n totalInBaseCurrency: number\n byCurrency: Array<{ currency: string; total: number; count: number }>\n baseCurrencyCode: string | null\n /** `true` when every currency in `byCurrency` was convertible to the base currency. */\n convertedAll: boolean\n /** Currencies present in `byCurrency` that lack an FX rate to base \u2014 excluded from totals. */\n missingRateCurrencies: string[]\n}\n\ntype LaneProps = {\n stage: LaneStage\n deals: DealCardData[]\n /** Server-side aggregate for accurate counts/totals across all deals in this stage, not just loaded ones */\n aggregate?: LaneAggregateProp | null\n selectedDealIds: Set<string>\n buildMenuItems: (deal: DealCardData) => RowActionItem[]\n activeDragDealId?: string | null\n isLoadingMore?: boolean\n /** Pixel width override (when the user has resized the column). Falls back to the default class. */\n width?: number | null\n onToggleSelect: (dealId: string) => void\n onComposeActivity: (dealId: string, type: 'call' | 'email' | 'note') => void\n onOpenDetail: (dealId: string) => void\n onQuickAddClick: (stageId: string) => void\n onLoadMore?: (stageId: string) => void\n /** Drag-resize callbacks. When provided, a vertical drag handle is rendered on the lane edge. */\n onResize?: (stageId: string, deltaPx: number) => void\n onResizeEnd?: (stageId: string) => void\n onResetWidth?: (stageId: string) => void\n}\n\n// 4px color bar tone \u2014 uses saturated icon tokens so the bar is visibly colored, not pale\nconst ACCENT_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-icon',\n error: 'bg-status-error-icon',\n warning: 'bg-status-warning-icon',\n info: 'bg-status-info-icon',\n neutral: 'bg-status-neutral-icon',\n brand: 'bg-brand-violet',\n pink: 'bg-status-pink-icon',\n}\n\n// Count pill bg uses very-light status background, text uses status text color\nconst COUNT_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-bg text-status-success-text',\n error: 'bg-status-error-bg text-status-error-text',\n warning: 'bg-status-warning-bg text-status-warning-text',\n info: 'bg-status-info-bg text-status-info-text',\n neutral: 'bg-status-neutral-bg text-status-neutral-text',\n brand: 'bg-brand-violet/14 text-brand-violet',\n pink: 'bg-status-pink-bg text-status-pink-text',\n}\n\nfunction getAccentClass(tone: FilterOptionTone | null): string {\n if (tone && tone in ACCENT_TONE_CLASS) return ACCENT_TONE_CLASS[tone]\n return 'bg-border'\n}\n\nfunction getCountBadgeClass(tone: FilterOptionTone | null): string {\n if (tone && tone in COUNT_BADGE_TONE_CLASS) return COUNT_BADGE_TONE_CLASS[tone]\n return 'bg-muted text-muted-foreground'\n}\n\nfunction compactCurrency(amount: number): string {\n if (amount >= 1_000_000) {\n const value = amount / 1_000_000\n return value >= 100 ? `${Math.round(value)}M` : `${value.toFixed(1).replace(/\\.0$/, '')}M`\n }\n if (amount >= 1_000) {\n const value = amount / 1_000\n return value >= 100 ? `${Math.round(value)}k` : `${value.toFixed(1).replace(/\\.0$/, '')}k`\n }\n return String(Math.round(amount))\n}\n\ntype CurrencyRow = { currency: string; total: number; count: number }\n\ntype LaneStats = {\n count: number\n /** Headline amount and the currency it is denominated in (largest single currency, raw, never converted). */\n primaryAmount: number\n primaryCurrency: string | null\n /** Per-currency rows sorted desc by total \u2014 the popover renders these one per line. */\n rows: CurrencyRow[]\n /** Sum of `rows` converted to base currency (zero when no base is configured / nothing converted). */\n totalInBaseCurrency: number\n baseCurrencyCode: string | null\n convertedAll: boolean\n missingRateCurrencies: string[]\n}\n\n/**\n * Build display stats.\n * - If a server aggregate is provided, prefer it (covers all deals, converts to tenant base currency).\n * - Otherwise fall back to summing loaded deals (used before aggregate query loads).\n *\n * The headline currency on the lane header is intentionally the LARGEST raw currency\n * (not the base-currency converted total) because the converted total can be misleading\n * when FX rates are missing \u2014 operators see a smaller number than the headline suggests.\n * The base-currency converted total is reserved for the breakdown popover footer, where\n * a partial-conversion indicator can disclose what's excluded.\n */\nfunction buildLaneStats(deals: DealCardData[], aggregate?: LaneAggregateProp | null): LaneStats {\n if (aggregate) {\n const rows = aggregate.byCurrency\n // Pick the single currency with the largest raw total to surface in the header. This\n // is always honest: the operator sees the actual largest cluster of value rather than\n // a synthetic base-currency total that may be partial.\n const headline = rows.length > 0 ? rows[0] : null\n return {\n count: aggregate.count,\n primaryAmount: headline ? headline.total : 0,\n primaryCurrency: headline ? headline.currency : null,\n rows,\n totalInBaseCurrency: aggregate.totalInBaseCurrency,\n baseCurrencyCode: aggregate.baseCurrencyCode,\n convertedAll: aggregate.convertedAll,\n missingRateCurrencies: aggregate.missingRateCurrencies,\n }\n }\n const totalsByCurrency = new Map<string, { total: number; count: number }>()\n for (const deal of deals) {\n if (typeof deal.valueAmount !== 'number' || !Number.isFinite(deal.valueAmount)) continue\n const code = deal.valueCurrency && deal.valueCurrency.length === 3\n ? deal.valueCurrency.toUpperCase()\n : 'USD'\n const entry = totalsByCurrency.get(code) ?? { total: 0, count: 0 }\n entry.total += deal.valueAmount\n entry.count += 1\n totalsByCurrency.set(code, entry)\n }\n const rows: CurrencyRow[] = Array.from(totalsByCurrency.entries())\n .map(([currency, value]) => ({ currency, total: value.total, count: value.count }))\n .sort((a, b) => b.total - a.total)\n if (rows.length === 0) {\n return {\n count: deals.length,\n primaryAmount: 0,\n primaryCurrency: null,\n rows,\n totalInBaseCurrency: 0,\n baseCurrencyCode: null,\n convertedAll: true,\n missingRateCurrencies: [],\n }\n }\n return {\n count: deals.length,\n primaryAmount: rows[0].total,\n primaryCurrency: rows[0].currency,\n rows,\n totalInBaseCurrency: 0,\n baseCurrencyCode: null,\n // We don't have rates client-side; mark as \"not converted\" only when there are\n // multiple currencies so the popover can show a \"configure FX rates\" hint.\n convertedAll: rows.length === 1,\n missingRateCurrencies: rows.length > 1 ? rows.slice(1).map((row) => row.currency) : [],\n }\n}\n\nfunction LaneImpl({\n stage,\n deals,\n aggregate,\n selectedDealIds,\n buildMenuItems,\n activeDragDealId,\n isLoadingMore = false,\n width,\n onToggleSelect,\n onComposeActivity,\n onOpenDetail,\n onQuickAddClick,\n onLoadMore,\n onResize,\n onResizeEnd,\n onResetWidth,\n}: LaneProps): React.ReactElement {\n const t = useT()\n const { setNodeRef, isOver } = useDroppable({\n id: `lane:${stage.id}`,\n data: { type: 'lane', stageId: stage.id },\n })\n const isDragActive = !!activeDragDealId\n const sourceLane = isDragActive && deals.some((deal) => deal.id === activeDragDealId)\n // Highlight drop zone when:\n // - the lane has the pointer over it during a drag, OR\n // - any drag is active and this lane is not the source (so all candidate targets are clearly tagged)\n const showDropHighlight = isOver || (isDragActive && !sourceLane)\n const stats = React.useMemo(() => buildLaneStats(deals, aggregate), [deals, aggregate])\n // Pull boolean out of the Set so each DealCard receives a stable primitive \u2014 that lets\n // React.memo on DealCard skip re-renders when the Set reference changes but its emptiness\n // does not. Flipping false\u2192true (first selection) or true\u2192false (last cleared) forces a\n // full pass over the lane's cards so their checkbox visibility updates atomically.\n const bulkSelectionActive = selectedDealIds.size > 0\n const totalLabel = stats.primaryAmount > 0 && stats.primaryCurrency\n ? `${compactCurrency(stats.primaryAmount)} ${stats.primaryCurrency}`\n : null\n const visibleCount = deals.length\n const totalCount = stats.count\n const hasMoreDeals = totalCount > visibleCount\n const accentClass = getAccentClass(stage.tone)\n const countBadgeClass = getCountBadgeClass(stage.tone)\n\n const handleQuickAdd = React.useCallback(() => {\n onQuickAddClick(stage.id)\n }, [onQuickAddClick, stage.id])\n\n return (\n <div\n className=\"relative flex flex-none flex-col gap-3\"\n style={{ width: typeof width === 'number' && Number.isFinite(width) ? `${width}px` : `${LANE_WIDTH_PX}px` }}\n >\n <div className=\"flex flex-col gap-2.5 overflow-clip rounded-lg bg-muted/40 px-4 py-3.5\">\n <div className={`h-1.5 w-full rounded-sm ${accentClass}`} aria-hidden=\"true\" />\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"flex min-w-0 items-center gap-2.5\">\n <span className=\"truncate text-sm font-bold uppercase leading-normal text-foreground\">\n {stage.label}\n </span>\n <span\n className={`inline-flex shrink-0 items-center rounded-full px-2 py-1 text-xs font-bold leading-normal ${countBadgeClass}`}\n title={\n hasMoreDeals\n ? translateWithFallback(\n t,\n 'customers.deals.kanban.lane.aria.partialCount',\n 'Showing {visible} of {total}',\n { visible: visibleCount, total: totalCount },\n )\n : undefined\n }\n >\n {totalCount}\n </span>\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {totalLabel ? (\n <span className=\"text-sm font-bold leading-normal text-foreground\">\n {totalLabel}\n </span>\n ) : null}\n {stats.rows.length > 1 ? (\n <LaneCurrencyBreakdown\n rows={stats.rows}\n baseCurrencyCode={stats.baseCurrencyCode}\n totalInBaseCurrency={stats.totalInBaseCurrency}\n convertedAll={stats.convertedAll}\n missingRateCurrencies={stats.missingRateCurrencies}\n triggerLabel={`+${stats.rows.length - 1}`}\n headingLabel={stage.label.toUpperCase()}\n headingCount={totalCount}\n />\n ) : null}\n </div>\n </div>\n </div>\n\n <DashedTileButton\n onClick={handleQuickAdd}\n className=\"h-11\"\n ariaLabel={translateWithFallback(\n t,\n 'customers.deals.kanban.lane.aria.quickAdd',\n 'Quickly add a deal to {stage}',\n { stage: stage.label },\n )}\n >\n <span className=\"text-base font-bold leading-none\">+</span>\n <span>{translateWithFallback(t, 'customers.deals.kanban.cta.quickDeal', 'Quick deal')}</span>\n </DashedTileButton>\n\n <div\n ref={setNodeRef}\n className={`flex min-h-[40vh] flex-col gap-3 rounded-lg p-1.5 ${\n isOver\n ? 'bg-accent-indigo/10 outline-dashed outline-2 outline-accent-indigo -outline-offset-[2px]'\n : showDropHighlight\n ? 'bg-muted/40 outline-dashed outline-1 outline-muted-foreground/40 -outline-offset-[2px]'\n : ''\n }`}\n >\n {deals.length === 0 ? (\n <EmptyState\n size=\"sm\"\n title={translateWithFallback(\n t,\n 'customers.deals.kanban.lane.empty',\n 'No deals in this stage yet.',\n )}\n />\n ) : (\n deals.map((deal) => (\n <DealCard\n key={deal.id}\n deal={deal}\n selected={selectedDealIds.has(deal.id)}\n bulkSelectionActive={bulkSelectionActive}\n buildMenuItems={buildMenuItems}\n isActiveDrag={activeDragDealId === deal.id}\n onToggleSelect={onToggleSelect}\n onComposeActivity={onComposeActivity}\n onOpenDetail={onOpenDetail}\n />\n ))\n )}\n {hasMoreDeals && onLoadMore ? (\n <DashedTileButton\n onClick={() => onLoadMore(stage.id)}\n disabled={isLoadingMore}\n className=\"mt-0.75 px-3 py-2.5 text-muted-foreground\"\n ariaLabel={translateWithFallback(\n t,\n 'customers.deals.kanban.lane.aria.loadMore',\n 'Load more deals in {stage}',\n { stage: stage.label },\n )}\n >\n {isLoadingMore\n ? translateWithFallback(t, 'customers.deals.kanban.lane.loadingMore', 'Loading\u2026')\n : translateWithFallback(\n t,\n 'customers.deals.kanban.lane.loadMore',\n 'Show more ({remaining})',\n { remaining: totalCount - visibleCount },\n )}\n </DashedTileButton>\n ) : null}\n </div>\n {onResize ? (\n <LaneResizeHandle\n onResize={(delta) => onResize(stage.id, delta)}\n onResizeEnd={() => onResizeEnd?.(stage.id)}\n onReset={() => onResetWidth?.(stage.id)}\n />\n ) : null}\n </div>\n )\n}\n\n// Memoize the lane so changes in a different lane (or in the active drag) don't re-render us.\n// Page passes booleans (isDragActive) instead of activeDragDealId so most lanes can skip re-rendering.\nexport const Lane = React.memo(LaneImpl, (prev, next) => {\n if (prev.stage !== next.stage) return false\n if (prev.deals !== next.deals) return false\n if (prev.aggregate !== next.aggregate) return false\n if (prev.selectedDealIds !== next.selectedDealIds) return false\n if (prev.buildMenuItems !== next.buildMenuItems) return false\n if (prev.activeDragDealId !== next.activeDragDealId) return false\n if (prev.isLoadingMore !== next.isLoadingMore) return false\n if (prev.width !== next.width) return false\n return true\n})\n\nexport default Lane\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport { useDroppable } from '@dnd-kit/core'\nimport { EmptyState } from '@open-mercato/ui/primitives/empty-state'\nimport type { RowActionItem } from '@open-mercato/ui/backend/RowActions'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\nimport type { FilterOptionTone } from '@open-mercato/shared/lib/query/advanced-filter'\nimport { toneToDotClass } from './toneClasses'\nimport { DealCard, type DealCardData } from './DealCard'\nimport { DashedTileButton } from './DashedTileButton'\nimport { LANE_WIDTH_PX } from './constants'\nimport { LaneCurrencyBreakdown } from './LaneCurrencyBreakdown'\nimport { LaneResizeHandle } from './LaneResizeHandle'\n\nexport type LaneStage = {\n id: string\n label: string\n tone: FilterOptionTone | null\n}\n\nexport type LaneAggregateProp = {\n count: number\n totalInBaseCurrency: number\n byCurrency: Array<{ currency: string; total: number; count: number }>\n baseCurrencyCode: string | null\n /** `true` when every currency in `byCurrency` was convertible to the base currency. */\n convertedAll: boolean\n /** Currencies present in `byCurrency` that lack an FX rate to base \u2014 excluded from totals. */\n missingRateCurrencies: string[]\n}\n\ntype LaneProps = {\n stage: LaneStage\n deals: DealCardData[]\n /** Server-side aggregate for accurate counts/totals across all deals in this stage, not just loaded ones */\n aggregate?: LaneAggregateProp | null\n selectedDealIds: Set<string>\n buildMenuItems: (deal: DealCardData) => RowActionItem[]\n activeDragDealId?: string | null\n isLoadingMore?: boolean\n /** Pixel width override (when the user has resized the column). Falls back to the default class. */\n width?: number | null\n onToggleSelect: (dealId: string) => void\n onComposeActivity: (dealId: string, type: 'call' | 'email' | 'note') => void\n onOpenDetail: (dealId: string) => void\n onQuickAddClick: (stageId: string) => void\n onLoadMore?: (stageId: string) => void\n /** Drag-resize callbacks. When provided, a vertical drag handle is rendered on the lane edge. */\n onResize?: (stageId: string, deltaPx: number) => void\n onResizeEnd?: (stageId: string) => void\n onResetWidth?: (stageId: string) => void\n}\n\n// 4px color bar tone \u2014 saturated icon tokens shared with the status filter pills\n// (toneClasses.ts) so the lane bar and the filter dots stay in lockstep (#5107 review).\nfunction getAccentClass(tone: FilterOptionTone | null): string {\n return toneToDotClass(tone, 'bg-border')\n}\n\n// Count pill bg uses very-light status background, text uses status text color\nconst COUNT_BADGE_TONE_CLASS: Record<FilterOptionTone, string> = {\n success: 'bg-status-success-bg text-status-success-text',\n error: 'bg-status-error-bg text-status-error-text',\n warning: 'bg-status-warning-bg text-status-warning-text',\n info: 'bg-status-info-bg text-status-info-text',\n neutral: 'bg-status-neutral-bg text-status-neutral-text',\n brand: 'bg-brand-violet/14 text-brand-violet',\n pink: 'bg-status-pink-bg text-status-pink-text',\n}\n\nfunction getCountBadgeClass(tone: FilterOptionTone | null): string {\n if (tone && tone in COUNT_BADGE_TONE_CLASS) return COUNT_BADGE_TONE_CLASS[tone]\n return 'bg-muted text-muted-foreground'\n}\n\nfunction compactCurrency(amount: number): string {\n if (amount >= 1_000_000) {\n const value = amount / 1_000_000\n return value >= 100 ? `${Math.round(value)}M` : `${value.toFixed(1).replace(/\\.0$/, '')}M`\n }\n if (amount >= 1_000) {\n const value = amount / 1_000\n return value >= 100 ? `${Math.round(value)}k` : `${value.toFixed(1).replace(/\\.0$/, '')}k`\n }\n return String(Math.round(amount))\n}\n\ntype CurrencyRow = { currency: string; total: number; count: number }\n\ntype LaneStats = {\n count: number\n /** Headline amount and the currency it is denominated in (largest single currency, raw, never converted). */\n primaryAmount: number\n primaryCurrency: string | null\n /** Per-currency rows sorted desc by total \u2014 the popover renders these one per line. */\n rows: CurrencyRow[]\n /** Sum of `rows` converted to base currency (zero when no base is configured / nothing converted). */\n totalInBaseCurrency: number\n baseCurrencyCode: string | null\n convertedAll: boolean\n missingRateCurrencies: string[]\n}\n\n/**\n * Build display stats.\n * - If a server aggregate is provided, prefer it (covers all deals, converts to tenant base currency).\n * - Otherwise fall back to summing loaded deals (used before aggregate query loads).\n *\n * The headline currency on the lane header is intentionally the LARGEST raw currency\n * (not the base-currency converted total) because the converted total can be misleading\n * when FX rates are missing \u2014 operators see a smaller number than the headline suggests.\n * The base-currency converted total is reserved for the breakdown popover footer, where\n * a partial-conversion indicator can disclose what's excluded.\n */\nfunction buildLaneStats(deals: DealCardData[], aggregate?: LaneAggregateProp | null): LaneStats {\n if (aggregate) {\n const rows = aggregate.byCurrency\n // Pick the single currency with the largest raw total to surface in the header. This\n // is always honest: the operator sees the actual largest cluster of value rather than\n // a synthetic base-currency total that may be partial.\n const headline = rows.length > 0 ? rows[0] : null\n return {\n count: aggregate.count,\n primaryAmount: headline ? headline.total : 0,\n primaryCurrency: headline ? headline.currency : null,\n rows,\n totalInBaseCurrency: aggregate.totalInBaseCurrency,\n baseCurrencyCode: aggregate.baseCurrencyCode,\n convertedAll: aggregate.convertedAll,\n missingRateCurrencies: aggregate.missingRateCurrencies,\n }\n }\n const totalsByCurrency = new Map<string, { total: number; count: number }>()\n for (const deal of deals) {\n if (typeof deal.valueAmount !== 'number' || !Number.isFinite(deal.valueAmount)) continue\n const code = deal.valueCurrency && deal.valueCurrency.length === 3\n ? deal.valueCurrency.toUpperCase()\n : 'USD'\n const entry = totalsByCurrency.get(code) ?? { total: 0, count: 0 }\n entry.total += deal.valueAmount\n entry.count += 1\n totalsByCurrency.set(code, entry)\n }\n const rows: CurrencyRow[] = Array.from(totalsByCurrency.entries())\n .map(([currency, value]) => ({ currency, total: value.total, count: value.count }))\n .sort((a, b) => b.total - a.total)\n if (rows.length === 0) {\n return {\n count: deals.length,\n primaryAmount: 0,\n primaryCurrency: null,\n rows,\n totalInBaseCurrency: 0,\n baseCurrencyCode: null,\n convertedAll: true,\n missingRateCurrencies: [],\n }\n }\n return {\n count: deals.length,\n primaryAmount: rows[0].total,\n primaryCurrency: rows[0].currency,\n rows,\n totalInBaseCurrency: 0,\n baseCurrencyCode: null,\n // We don't have rates client-side; mark as \"not converted\" only when there are\n // multiple currencies so the popover can show a \"configure FX rates\" hint.\n convertedAll: rows.length === 1,\n missingRateCurrencies: rows.length > 1 ? rows.slice(1).map((row) => row.currency) : [],\n }\n}\n\nfunction LaneImpl({\n stage,\n deals,\n aggregate,\n selectedDealIds,\n buildMenuItems,\n activeDragDealId,\n isLoadingMore = false,\n width,\n onToggleSelect,\n onComposeActivity,\n onOpenDetail,\n onQuickAddClick,\n onLoadMore,\n onResize,\n onResizeEnd,\n onResetWidth,\n}: LaneProps): React.ReactElement {\n const t = useT()\n const { setNodeRef, isOver } = useDroppable({\n id: `lane:${stage.id}`,\n data: { type: 'lane', stageId: stage.id },\n })\n const isDragActive = !!activeDragDealId\n const sourceLane = isDragActive && deals.some((deal) => deal.id === activeDragDealId)\n // Highlight drop zone when:\n // - the lane has the pointer over it during a drag, OR\n // - any drag is active and this lane is not the source (so all candidate targets are clearly tagged)\n const showDropHighlight = isOver || (isDragActive && !sourceLane)\n const stats = React.useMemo(() => buildLaneStats(deals, aggregate), [deals, aggregate])\n // Pull boolean out of the Set so each DealCard receives a stable primitive \u2014 that lets\n // React.memo on DealCard skip re-renders when the Set reference changes but its emptiness\n // does not. Flipping false\u2192true (first selection) or true\u2192false (last cleared) forces a\n // full pass over the lane's cards so their checkbox visibility updates atomically.\n const bulkSelectionActive = selectedDealIds.size > 0\n const totalLabel = stats.primaryAmount > 0 && stats.primaryCurrency\n ? `${compactCurrency(stats.primaryAmount)} ${stats.primaryCurrency}`\n : null\n const visibleCount = deals.length\n const totalCount = stats.count\n const hasMoreDeals = totalCount > visibleCount\n const accentClass = getAccentClass(stage.tone)\n const countBadgeClass = getCountBadgeClass(stage.tone)\n\n const handleQuickAdd = React.useCallback(() => {\n onQuickAddClick(stage.id)\n }, [onQuickAddClick, stage.id])\n\n return (\n <div\n className=\"relative flex flex-none flex-col gap-3\"\n style={{ width: typeof width === 'number' && Number.isFinite(width) ? `${width}px` : `${LANE_WIDTH_PX}px` }}\n >\n <div className=\"flex flex-col gap-2.5 overflow-clip rounded-lg bg-muted/40 px-4 py-3.5\">\n <div className={`h-1.5 w-full rounded-sm ${accentClass}`} aria-hidden=\"true\" />\n <div className=\"flex items-center justify-between gap-2\">\n <div className=\"flex min-w-0 items-center gap-2.5\">\n <span className=\"truncate text-sm font-bold uppercase leading-normal text-foreground\">\n {stage.label}\n </span>\n <span\n className={`inline-flex shrink-0 items-center rounded-full px-2 py-1 text-xs font-bold leading-normal ${countBadgeClass}`}\n title={\n hasMoreDeals\n ? translateWithFallback(\n t,\n 'customers.deals.kanban.lane.aria.partialCount',\n 'Showing {visible} of {total}',\n { visible: visibleCount, total: totalCount },\n )\n : undefined\n }\n >\n {totalCount}\n </span>\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {totalLabel ? (\n <span className=\"text-sm font-bold leading-normal text-foreground\">\n {totalLabel}\n </span>\n ) : null}\n {stats.rows.length > 1 ? (\n <LaneCurrencyBreakdown\n rows={stats.rows}\n baseCurrencyCode={stats.baseCurrencyCode}\n totalInBaseCurrency={stats.totalInBaseCurrency}\n convertedAll={stats.convertedAll}\n missingRateCurrencies={stats.missingRateCurrencies}\n triggerLabel={`+${stats.rows.length - 1}`}\n headingLabel={stage.label.toUpperCase()}\n headingCount={totalCount}\n />\n ) : null}\n </div>\n </div>\n </div>\n\n <DashedTileButton\n onClick={handleQuickAdd}\n className=\"h-11\"\n ariaLabel={translateWithFallback(\n t,\n 'customers.deals.kanban.lane.aria.quickAdd',\n 'Quickly add a deal to {stage}',\n { stage: stage.label },\n )}\n >\n <span className=\"text-base font-bold leading-none\">+</span>\n <span>{translateWithFallback(t, 'customers.deals.kanban.cta.quickDeal', 'Quick deal')}</span>\n </DashedTileButton>\n\n <div\n ref={setNodeRef}\n className={`flex min-h-[40vh] flex-col gap-3 rounded-lg p-1.5 ${\n isOver\n ? 'bg-accent-indigo/10 outline-dashed outline-2 outline-accent-indigo -outline-offset-[2px]'\n : showDropHighlight\n ? 'bg-muted/40 outline-dashed outline-1 outline-muted-foreground/40 -outline-offset-[2px]'\n : ''\n }`}\n >\n {deals.length === 0 ? (\n <EmptyState\n size=\"sm\"\n title={translateWithFallback(\n t,\n 'customers.deals.kanban.lane.empty',\n 'No deals in this stage yet.',\n )}\n />\n ) : (\n deals.map((deal) => (\n <DealCard\n key={deal.id}\n deal={deal}\n selected={selectedDealIds.has(deal.id)}\n bulkSelectionActive={bulkSelectionActive}\n buildMenuItems={buildMenuItems}\n isActiveDrag={activeDragDealId === deal.id}\n onToggleSelect={onToggleSelect}\n onComposeActivity={onComposeActivity}\n onOpenDetail={onOpenDetail}\n />\n ))\n )}\n {hasMoreDeals && onLoadMore ? (\n <DashedTileButton\n onClick={() => onLoadMore(stage.id)}\n disabled={isLoadingMore}\n className=\"mt-0.75 px-3 py-2.5 text-muted-foreground\"\n ariaLabel={translateWithFallback(\n t,\n 'customers.deals.kanban.lane.aria.loadMore',\n 'Load more deals in {stage}',\n { stage: stage.label },\n )}\n >\n {isLoadingMore\n ? translateWithFallback(t, 'customers.deals.kanban.lane.loadingMore', 'Loading\u2026')\n : translateWithFallback(\n t,\n 'customers.deals.kanban.lane.loadMore',\n 'Show more ({remaining})',\n { remaining: totalCount - visibleCount },\n )}\n </DashedTileButton>\n ) : null}\n </div>\n {onResize ? (\n <LaneResizeHandle\n onResize={(delta) => onResize(stage.id, delta)}\n onResizeEnd={() => onResizeEnd?.(stage.id)}\n onReset={() => onResetWidth?.(stage.id)}\n />\n ) : null}\n </div>\n )\n}\n\n// Memoize the lane so changes in a different lane (or in the active drag) don't re-render us.\n// Page passes booleans (isDragActive) instead of activeDragDealId so most lanes can skip re-rendering.\nexport const Lane = React.memo(LaneImpl, (prev, next) => {\n if (prev.stage !== next.stage) return false\n if (prev.deals !== next.deals) return false\n if (prev.aggregate !== next.aggregate) return false\n if (prev.selectedDealIds !== next.selectedDealIds) return false\n if (prev.buildMenuItems !== next.buildMenuItems) return false\n if (prev.activeDragDealId !== next.activeDragDealId) return false\n if (prev.isLoadingMore !== next.isLoadingMore) return false\n if (prev.width !== next.width) return false\n return true\n})\n\nexport default Lane\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["rows"]
|
|
7
7
|
}
|