@open-mercato/core 0.10.1-develop.7774.1.9a13ef7580 → 0.10.1-develop.7776.1.3032ea0741

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/promotions/services/promotion-cache.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n Benefit,\n Promotion,\n Rule,\n RuleGroup,\n type PromotionBenefitAppliesTo,\n type PromotionRuleGroupOperator,\n} from '../data/entities'\nimport type {\n NormalizedBenefit,\n NormalizedPromotion,\n NormalizedRule,\n NormalizedRuleGroup,\n} from '../lib/types'\n\n// SPEC-055 \u00A7Cache Build Contract + \u00A7Normalized Promotion Shape. This file is the ONLY\n// place in the module that builds `NormalizedPromotion[]` from the database \u2014 the pure\n// evaluation pipeline (`lib/evaluation-engine.ts` and friends) never touches the\n// EntityManager, only ever receives the plain objects this file produces.\n\n// ---------------------------------------------------------------------------\n// Cache keys\n// ---------------------------------------------------------------------------\n\n// Literal format pinned by SPEC-055 \u00A7Evaluation Engine Data Flow pseudocode\n// (`key: promotions:active:{tenantId}:{organizationId}`). Key and tag share the same\n// value intentionally (one tree cache entry per tenant/org, mirrors\n// `inbox_ops/lib/cache.ts`'s COUNTS/SETTINGS keys) so invalidation never needs a\n// separate tag registry.\nexport function buildPromotionTreeCacheKey(tenantId: string, organizationId: string): string {\n return `promotions:active:${tenantId}:${organizationId}`\n}\n\n// Not specified verbatim by the spec (only \"a separate, small counters key\" is\n// required, \u00A7Cache Build Contract item 4) \u2014 scoped per-promotion, not per-tenant, so\n// a `usage.registered`/`usage.reverted` event for one promotion only ever evicts that\n// promotion's own counter, never the whole tenant's budget-capped set and never the\n// (expensive-to-rebuild) main tree cache.\nexport function buildPromotionUsageCounterCacheKey(\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): string {\n return `promotions:usage-counter:${tenantId}:${organizationId}:${promotionId}`\n}\n\nconst PROMOTION_TREE_CACHE_TTL_MS = 5 * 60 * 1000\nconst PROMOTION_USAGE_COUNTER_CACHE_TTL_MS = 5 * 60 * 1000\n\nasync function readPromotionCacheEntry<T>(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n): Promise<T | null> {\n try {\n const value = await runWithCacheTenant(tenantId, () => cache.get(key))\n return value === null || value === undefined ? null : (value as T)\n } catch {\n return null\n }\n}\n\nasync function writePromotionCacheEntry(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n value: unknown,\n tags: string[],\n ttlMs: number,\n): Promise<void> {\n try {\n await runWithCacheTenant(tenantId, () => cache.set(key, value, { ttl: ttlMs, tags }))\n } catch {\n // Best-effort: a cache write failure must not fail the read it is backing.\n }\n}\n\n// The subscriber handlers below (and `subscribers/*.ts`, which just wire an event id to\n// one of those handlers) call these two directly \u2014 they never build the tag string\n// themselves, so the key format stays single-sourced here.\nexport async function invalidatePromotionTreeCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionTreeCacheKey(tenantId, organizationId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\nexport async function invalidatePromotionUsageCounterCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\n// ---------------------------------------------------------------------------\n// Row shapes \u2014 the already-fetched, plain-object input to the pure normalization\n// below. Field names mirror `data/entities.ts` exactly (camelCase entity properties).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheRow = {\n id: string\n name: string\n order: number\n cumulative: boolean\n tags: string[]\n excludedTags: string[]\n excludeFlags: Record<string, boolean>\n eligibleCurrencies: string[]\n startsAt: Date | null\n endsAt: Date | null\n maxBudget: string | null\n budgetCurrency: string | null\n}\n\nexport type RuleGroupCacheRow = {\n id: string\n promotionId: string\n parentRuleGroupId: string | null\n operator: PromotionRuleGroupOperator\n sortOrder: number\n}\n\nexport type RuleCacheRow = {\n ruleGroupId: string\n promotionId: string\n ruleType: string\n config: Record<string, unknown>\n sortOrder: number\n}\n\nexport type BenefitCacheRow = {\n ruleGroupId: string\n promotionId: string\n benefitType: string\n appliesTo: PromotionBenefitAppliesTo\n config: Record<string, unknown>\n sortOrder: number\n}\n\n// ---------------------------------------------------------------------------\n// Deep freeze (\u00A7Normalized Promotion Shape: \"In non-production builds, objects are\n// deep-frozen to detect accidental mutation\")\n// ---------------------------------------------------------------------------\n\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {\n Object.freeze(value)\n for (const key of Object.getOwnPropertyNames(value)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\nfunction freezeOutsideProduction<T>(value: T): T {\n return process.env.NODE_ENV === 'production' ? value : deepFreeze(value)\n}\n\n// ---------------------------------------------------------------------------\n// Pure normalization / tree assembly \u2014 no EntityManager, no cache, fully testable\n// without a database connection. Extracted per SPEC-055 Step 2.5's test requirement.\n// ---------------------------------------------------------------------------\n\nfunction buildNormalizedRuleGroup(\n groupId: string,\n groupsForPromotion: RuleGroupCacheRow[],\n rulesForPromotion: RuleCacheRow[],\n benefitsForPromotion: BenefitCacheRow[],\n): NormalizedRuleGroup {\n const group = groupsForPromotion.find((candidate) => candidate.id === groupId)\n if (!group) {\n // \u00A7Cache Build Contract item 3: \"Any missing eager-loaded relation is treated as a\n // critical bug.\" A child id referenced by `parentRuleGroupId` that was not fetched\n // in the same batch is exactly that \u2014 never silently produce an incomplete tree.\n throw new Error(`[internal] promotion cache build is missing rule group ${groupId}`)\n }\n\n const rules: NormalizedRule[] = rulesForPromotion\n .filter((rule) => rule.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((rule) => ({ type: rule.ruleType, config: rule.config }))\n\n const benefits: NormalizedBenefit[] = benefitsForPromotion\n .filter((benefit) => benefit.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((benefit) => ({ type: benefit.benefitType, appliesTo: benefit.appliesTo, config: benefit.config }))\n\n const children: NormalizedRuleGroup[] = groupsForPromotion\n .filter((candidate) => candidate.parentRuleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((child) => buildNormalizedRuleGroup(child.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion))\n\n return { operator: group.operator, rules, benefits, children }\n}\n\n/**\n * Pure tree assembly for a single promotion. Accepts the full fetched batch for every\n * rule-group/rule/benefit row (not pre-filtered per promotion) and narrows internally by\n * `promotionId` \u2014 the real service fetches these arrays once for a whole batch of active\n * promotions (\u00A7Cache Build Contract item 1: \"a single cache build performs all required\n * database reads\", not N+1 per promotion) and calls this function once per promotion.\n *\n * `totalDiscountGranted` is passed in already resolved \u2014 this function never reads\n * `promotion_usages` itself (\u00A7Cache Build Contract item 4: the counter lives in a\n * separate, small cache key the caller resolves independently).\n */\nexport function buildNormalizedPromotion(\n promotionRow: PromotionCacheRow,\n ruleGroupRows: RuleGroupCacheRow[],\n ruleRows: RuleCacheRow[],\n benefitRows: BenefitCacheRow[],\n totalDiscountGranted: string,\n): NormalizedPromotion {\n const groupsForPromotion = ruleGroupRows.filter((group) => group.promotionId === promotionRow.id)\n const rulesForPromotion = ruleRows.filter((rule) => rule.promotionId === promotionRow.id)\n const benefitsForPromotion = benefitRows.filter((benefit) => benefit.promotionId === promotionRow.id)\n\n const root = groupsForPromotion.find((group) => group.parentRuleGroupId === null)\n if (!root) {\n throw new Error(\n `[internal] promotion cache build is missing the root rule group for promotion ${promotionRow.id}`,\n )\n }\n\n // \u00A7Cache Build Contract item 3: a rule/benefit whose owning `ruleGroupId` was not\n // among the batch-fetched groups is exactly the \"missing eager-loaded relation\"\n // scenario \u2014 never silently drop it from the tree.\n const groupIdsForPromotion = new Set(groupsForPromotion.map((group) => group.id))\n for (const rule of rulesForPromotion) {\n if (!groupIdsForPromotion.has(rule.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${rule.ruleGroupId} referenced by a rule for promotion ${promotionRow.id}`,\n )\n }\n }\n for (const benefit of benefitsForPromotion) {\n if (!groupIdsForPromotion.has(benefit.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${benefit.ruleGroupId} referenced by a benefit for promotion ${promotionRow.id}`,\n )\n }\n }\n\n const normalized: NormalizedPromotion = {\n id: promotionRow.id,\n name: promotionRow.name,\n order: promotionRow.order,\n cumulative: promotionRow.cumulative,\n tags: [...promotionRow.tags],\n excludedTags: [...promotionRow.excludedTags],\n excludeFlags: { ...promotionRow.excludeFlags },\n eligibleCurrencies: [...promotionRow.eligibleCurrencies],\n startsAt: promotionRow.startsAt ? promotionRow.startsAt.toISOString() : null,\n endsAt: promotionRow.endsAt ? promotionRow.endsAt.toISOString() : null,\n maxBudget: promotionRow.maxBudget,\n budgetCurrency: promotionRow.budgetCurrency,\n totalDiscountGranted,\n rootGroup: buildNormalizedRuleGroup(root.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion),\n }\n\n return freezeOutsideProduction(normalized)\n}\n\n// ---------------------------------------------------------------------------\n// Cache-backed service \u2014 the only place in this module that ever queries the\n// database for the evaluation engine's input (\u00A7Cache Build Contract item 2).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\nfunction resolvePromotionCacheStrategy(container: PromotionCacheContainer): CacheStrategy | null {\n try {\n return container.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\nfunction resolvePromotionEm(container: PromotionCacheContainer): EntityManager {\n // Exact precedent: `commands/promotions.ts` resolves/forks the same way \u2014\n // `(ctx.container.resolve('em') as EntityManager).fork()` \u2014 before any read/write.\n return (container.resolve<EntityManager>('em')).fork()\n}\n\nexport class PromotionCacheService {\n constructor(private readonly container: PromotionCacheContainer) {}\n\n async getActivePromotions(tenantId: string, organizationId: string): Promise<NormalizedPromotion[]> {\n const cache = resolvePromotionCacheStrategy(this.container)\n const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId)\n\n let promotions = cache ? await readPromotionCacheEntry<NormalizedPromotion[]>(cache, tenantId, treeKey) : null\n if (!promotions) {\n promotions = await this.buildActivePromotionTrees(tenantId, organizationId)\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, treeKey, promotions, [treeKey], PROMOTION_TREE_CACHE_TTL_MS)\n }\n }\n\n return Promise.all(\n promotions.map((promotion) => this.overlayUsageCounter(promotion, tenantId, organizationId)),\n )\n }\n\n // \u00A7Cache Build Contract item 4: \"read only for budget-capped promotions\" \u2014 an\n // uncapped promotion's `totalDiscountGranted` is never read back from\n // `promotion_usages`, it stays the inert '0' baked into the tree cache.\n private async overlayUsageCounter(\n promotion: NormalizedPromotion,\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion> {\n if (promotion.maxBudget === null) return promotion\n const totalDiscountGranted = await this.getUsageCounter(\n promotion.id,\n tenantId,\n organizationId,\n promotion.budgetCurrency,\n )\n return freezeOutsideProduction({ ...promotion, totalDiscountGranted })\n }\n\n private async getUsageCounter(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n const cache = resolvePromotionCacheStrategy(this.container)\n const key = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n\n if (cache) {\n const cached = await readPromotionCacheEntry<string>(cache, tenantId, key)\n if (cached !== null) return cached\n }\n\n const total = await this.sumPromotionUsage(promotionId, tenantId, organizationId, budgetCurrency)\n\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, key, total, [key], PROMOTION_USAGE_COUNTER_CACHE_TTL_MS)\n }\n\n return total\n }\n\n // \u00A7Cache Build Contract item 1 / \u00A7Promotion Usage Lifecycle: `SUM(total_discount_amount)\n // WHERE reverted_at IS NULL AND currency = budget_currency` \u2014 the same filter\n // `registerUsage`'s own re-read-sum uses.\n private async sumPromotionUsage(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n if (!budgetCurrency) return '0'\n const em = resolvePromotionEm(this.container)\n const rows = (await em.getConnection().execute(\n `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total\n FROM promotion_usages\n WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,\n [promotionId, organizationId, tenantId, budgetCurrency],\n )) as Array<{ total: string }>\n return rows[0]?.total ?? '0'\n }\n\n // \u00A7Cache Build Contract item 1: one query pass for the whole batch of active\n // promotions \u2014 every `RuleGroup`/`Rule`/`Benefit` row for them is fetched in three\n // fixed queries (run in parallel), never per-promotion.\n private async buildActivePromotionTrees(\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion[]> {\n const em = resolvePromotionEm(this.container)\n\n // \u00A7Cache Build Contract (rev R-H3b/P8): every `active = true` promotion, regardless\n // of `starts_at`/`ends_at` \u2014 date-window filtering happens only inside the evaluator\n // (`lib/evaluation-engine.ts`'s `isPromotionEligible`), never here.\n const promotionEntities = await em.find(\n Promotion,\n { tenantId, organizationId, active: true, deletedAt: null },\n // `lib/evaluation-engine.ts` documents that the cache is responsible for ordering\n // by `order ASC, id ASC` before evaluation ever sees the array.\n { orderBy: { order: 'asc', id: 'asc' } },\n )\n if (promotionEntities.length === 0) return []\n\n const promotionIds = promotionEntities.map((promotion) => promotion.id)\n\n const [ruleGroupEntities, ruleEntities, benefitEntities] = await Promise.all([\n em.find(RuleGroup, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Rule, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Benefit, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n ])\n\n const ruleGroupRows: RuleGroupCacheRow[] = ruleGroupEntities.map((group) => ({\n id: group.id,\n promotionId: group.promotionId,\n parentRuleGroupId: group.parentRuleGroupId ?? null,\n operator: group.operator,\n sortOrder: group.sortOrder,\n }))\n const ruleRows: RuleCacheRow[] = ruleEntities.map((rule) => ({\n ruleGroupId: rule.ruleGroupId,\n promotionId: rule.promotionId,\n ruleType: rule.ruleType,\n config: rule.config,\n sortOrder: rule.sortOrder,\n }))\n const benefitRows: BenefitCacheRow[] = benefitEntities.map((benefit) => ({\n ruleGroupId: benefit.ruleGroupId,\n promotionId: benefit.promotionId,\n benefitType: benefit.benefitType,\n appliesTo: benefit.appliesTo,\n config: benefit.config,\n sortOrder: benefit.sortOrder,\n }))\n\n return promotionEntities.map((promotion) => {\n const row: PromotionCacheRow = {\n id: promotion.id,\n name: promotion.name,\n order: promotion.order,\n cumulative: promotion.cumulative,\n tags: promotion.tags,\n excludedTags: promotion.excludedTags,\n excludeFlags: promotion.excludeFlags,\n eligibleCurrencies: promotion.eligibleCurrencies,\n startsAt: promotion.startsAt ?? null,\n endsAt: promotion.endsAt ?? null,\n maxBudget: promotion.maxBudget ?? null,\n budgetCurrency: promotion.budgetCurrency ?? null,\n }\n // Placeholder \u2014 `getActivePromotions` overlays the real snapshot for\n // budget-capped promotions from the separate counters cache key right after this\n // method returns (\u00A7Cache Build Contract item 4). Never read directly from the\n // tree cache for a budget decision.\n return buildNormalizedPromotion(row, ruleGroupRows, ruleRows, benefitRows, '0')\n })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Subscriber glue (`subscribers/*.ts` \u2014 ephemeral cache-invalidation handlers).\n// Kept here, not in `subscribers/` itself: every file under a module's `subscribers/`\n// folder is auto-discovered as a subscriber definition by `yarn generate` (it is scanned\n// unconditionally, with no `metadata` export treated as a critical bug further down the\n// pipeline, not as \"skip this file\") \u2014 mirrors `ecommerce/lib/cacheInvalidation.ts`\n// shared by that module's own `subscribers/*-cache-invalidation.ts` files.\n// ---------------------------------------------------------------------------\n\nexport type PromotionsSubscriberContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\nconst subscriberLogger = createLogger('promotions').child({ component: 'cache' })\n\nfunction readSubscriberEventScope(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): { tenantId: string | null; organizationId: string | null } {\n const data = (payload ?? {}) as Record<string, unknown>\n const tenantId =\n typeof data.tenantId === 'string' && data.tenantId.length > 0 ? data.tenantId : ctx.tenantId ?? null\n const organizationId =\n typeof data.organizationId === 'string' && data.organizationId.length > 0\n ? data.organizationId\n : ctx.organizationId ?? null\n return { tenantId, organizationId }\n}\n\nfunction readPromotionIdFromUsagePayload(payload: unknown): string | null {\n const data = (payload ?? {}) as Record<string, unknown>\n return typeof data.promotionId === 'string' && data.promotionId.length > 0 ? data.promotionId : null\n}\n\nfunction resolveSubscriberCache(ctx: PromotionsSubscriberContext): CacheStrategy | null {\n try {\n return ctx.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\n// \u00A7Cache Build Contract item 5: the main tree cache is invalidated ONLY by promotion/\n// code mutation events (`promotions.promotion.created|updated|deleted`,\n// `promotions.code.created|updated|deleted`) \u2014 never by a usage event. Wired to those\n// six exact event ids (not a `promotions.promotion.*`/`promotions.code.*` wildcard) so\n// the lifecycle events declared alongside them in `events.ts`\n// (`promotions.promotion.budget_exhausted`/`.expiring_soon`, `promotions.code.used`/\n// `.exhausted`) never trigger a rebuild they were not asked to.\nexport async function handlePromotionTreeCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n if (!tenantId || !organizationId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionTreeCache(cache, tenantId, organizationId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion tree cache invalidation failed', { tenantId, organizationId, err })\n }\n}\n\n// \u00A7Cache Build Contract item 4/5: `promotions.usage.registered`/`.reverted` evict only\n// the one promotion's small counters-key entry, never the main tree cache.\nexport async function handlePromotionUsageCounterCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n const promotionId = readPromotionIdFromUsagePayload(payload)\n if (!tenantId || !organizationId || !promotionId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionUsageCounterCache(cache, tenantId, organizationId, promotionId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion usage counter cache invalidation failed', {\n tenantId,\n organizationId,\n promotionId,\n err,\n })\n }\n}\n"],
5
- "mappings": "AACA,SAAS,0BAA8C;AACvD,SAAS,oBAAoB;AAC7B;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAGK;AAsBA,SAAS,2BAA2B,UAAkB,gBAAgC;AAC3F,SAAO,qBAAqB,QAAQ,IAAI,cAAc;AACxD;AAOO,SAAS,mCACd,UACA,gBACA,aACQ;AACR,SAAO,4BAA4B,QAAQ,IAAI,cAAc,IAAI,WAAW;AAC9E;AAEA,MAAM,8BAA8B,IAAI,KAAK;AAC7C,MAAM,uCAAuC,IAAI,KAAK;AAEtD,eAAe,wBACb,OACA,UACA,KACmB;AACnB,MAAI;AACF,UAAM,QAAQ,MAAM,mBAAmB,UAAU,MAAM,MAAM,IAAI,GAAG,CAAC;AACrE,WAAO,UAAU,QAAQ,UAAU,SAAY,OAAQ;AAAA,EACzD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAe,yBACb,OACA,UACA,KACA,OACA,MACA,OACe;AACf,MAAI;AACF,UAAM,mBAAmB,UAAU,MAAM,MAAM,IAAI,KAAK,OAAO,EAAE,KAAK,OAAO,KAAK,CAAC,CAAC;AAAA,EACtF,QAAQ;AAAA,EAER;AACF;AAKA,eAAsB,6BACpB,OACA,UACA,gBACe;AACf,MAAI,CAAC,MAAO;AACZ,QAAM,MAAM,2BAA2B,UAAU,cAAc;AAC/D,QAAM,mBAAmB,UAAU,MAAM,MAAM,aAAa,CAAC,GAAG,CAAC,CAAC;AACpE;AAEA,eAAsB,qCACpB,OACA,UACA,gBACA,aACe;AACf,MAAI,CAAC,MAAO;AACZ,QAAM,MAAM,mCAAmC,UAAU,gBAAgB,WAAW;AACpF,QAAM,mBAAmB,UAAU,MAAM,MAAM,aAAa,CAAC,GAAG,CAAC,CAAC;AACpE;AAoDA,SAAS,WAAc,OAAa;AAClC,MAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,OAAO,SAAS,KAAK,GAAG;AAC1E,WAAO,OAAO,KAAK;AACnB,eAAW,OAAO,OAAO,oBAAoB,KAAK,GAAG;AACnD,iBAAY,MAAkC,GAAG,CAAC;AAAA,IACpD;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,wBAA2B,OAAa;AAC/C,SAAO,QAAQ,IAAI,aAAa,eAAe,QAAQ,WAAW,KAAK;AACzE;AAOA,SAAS,yBACP,SACA,oBACA,mBACA,sBACqB;AACrB,QAAM,QAAQ,mBAAmB,KAAK,CAAC,cAAc,UAAU,OAAO,OAAO;AAC7E,MAAI,CAAC,OAAO;AAIV,UAAM,IAAI,MAAM,0DAA0D,OAAO,EAAE;AAAA,EACrF;AAEA,QAAM,QAA0B,kBAC7B,OAAO,CAAC,SAAS,KAAK,gBAAgB,OAAO,EAC7C,MAAM,EACN,KAAK,CAAC,GAAG,MAAM,EAAE,YAAY,EAAE,SAAS,EACxC,IAAI,CAAC,UAAU,EAAE,MAAM,KAAK,UAAU,QAAQ,KAAK,OAAO,EAAE;AAE/D,QAAM,WAAgC,qBACnC,OAAO,CAAC,YAAY,QAAQ,gBAAgB,OAAO,EACnD,MAAM,EACN,KAAK,CAAC,GAAG,MAAM,EAAE,YAAY,EAAE,SAAS,EACxC,IAAI,CAAC,aAAa,EAAE,MAAM,QAAQ,aAAa,WAAW,QAAQ,WAAW,QAAQ,QAAQ,OAAO,EAAE;AAEzG,QAAM,WAAkC,mBACrC,OAAO,CAAC,cAAc,UAAU,sBAAsB,OAAO,EAC7D,MAAM,EACN,KAAK,CAAC,GAAG,MAAM,EAAE,YAAY,EAAE,SAAS,EACxC,IAAI,CAAC,UAAU,yBAAyB,MAAM,IAAI,oBAAoB,mBAAmB,oBAAoB,CAAC;AAEjH,SAAO,EAAE,UAAU,MAAM,UAAU,OAAO,UAAU,SAAS;AAC/D;AAaO,SAAS,yBACd,cACA,eACA,UACA,aACA,sBACqB;AACrB,QAAM,qBAAqB,cAAc,OAAO,CAAC,UAAU,MAAM,gBAAgB,aAAa,EAAE;AAChG,QAAM,oBAAoB,SAAS,OAAO,CAAC,SAAS,KAAK,gBAAgB,aAAa,EAAE;AACxF,QAAM,uBAAuB,YAAY,OAAO,CAAC,YAAY,QAAQ,gBAAgB,aAAa,EAAE;AAEpG,QAAM,OAAO,mBAAmB,KAAK,CAAC,UAAU,MAAM,sBAAsB,IAAI;AAChF,MAAI,CAAC,MAAM;AACT,UAAM,IAAI;AAAA,MACR,iFAAiF,aAAa,EAAE;AAAA,IAClG;AAAA,EACF;AAKA,QAAM,uBAAuB,IAAI,IAAI,mBAAmB,IAAI,CAAC,UAAU,MAAM,EAAE,CAAC;AAChF,aAAW,QAAQ,mBAAmB;AACpC,QAAI,CAAC,qBAAqB,IAAI,KAAK,WAAW,GAAG;AAC/C,YAAM,IAAI;AAAA,QACR,0DAA0D,KAAK,WAAW,uCAAuC,aAAa,EAAE;AAAA,MAClI;AAAA,IACF;AAAA,EACF;AACA,aAAW,WAAW,sBAAsB;AAC1C,QAAI,CAAC,qBAAqB,IAAI,QAAQ,WAAW,GAAG;AAClD,YAAM,IAAI;AAAA,QACR,0DAA0D,QAAQ,WAAW,0CAA0C,aAAa,EAAE;AAAA,MACxI;AAAA,IACF;AAAA,EACF;AAEA,QAAM,aAAkC;AAAA,IACtC,IAAI,aAAa;AAAA,IACjB,MAAM,aAAa;AAAA,IACnB,OAAO,aAAa;AAAA,IACpB,YAAY,aAAa;AAAA,IACzB,MAAM,CAAC,GAAG,aAAa,IAAI;AAAA,IAC3B,cAAc,CAAC,GAAG,aAAa,YAAY;AAAA,IAC3C,cAAc,EAAE,GAAG,aAAa,aAAa;AAAA,IAC7C,oBAAoB,CAAC,GAAG,aAAa,kBAAkB;AAAA,IACvD,UAAU,aAAa,WAAW,aAAa,SAAS,YAAY,IAAI;AAAA,IACxE,QAAQ,aAAa,SAAS,aAAa,OAAO,YAAY,IAAI;AAAA,IAClE,WAAW,aAAa;AAAA,IACxB,gBAAgB,aAAa;AAAA,IAC7B;AAAA,IACA,WAAW,yBAAyB,KAAK,IAAI,oBAAoB,mBAAmB,oBAAoB;AAAA,EAC1G;AAEA,SAAO,wBAAwB,UAAU;AAC3C;AAWA,SAAS,8BAA8B,WAA0D;AAC/F,MAAI;AACF,WAAO,UAAU,QAAuB,OAAO,KAAK;AAAA,EACtD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,mBAAmB,WAAmD;AAG7E,SAAQ,UAAU,QAAuB,IAAI,EAAG,KAAK;AACvD;AAEO,MAAM,sBAAsB;AAAA,EACjC,YAA6B,WAAoC;AAApC;AAAA,EAAqC;AAAA,EAElE,MAAM,oBAAoB,UAAkB,gBAAwD;AAClG,UAAM,QAAQ,8BAA8B,KAAK,SAAS;AAC1D,UAAM,UAAU,2BAA2B,UAAU,cAAc;AAEnE,QAAI,aAAa,QAAQ,MAAM,wBAA+C,OAAO,UAAU,OAAO,IAAI;AAC1G,QAAI,CAAC,YAAY;AACf,mBAAa,MAAM,KAAK,0BAA0B,UAAU,cAAc;AAC1E,UAAI,OAAO;AACT,cAAM,yBAAyB,OAAO,UAAU,SAAS,YAAY,CAAC,OAAO,GAAG,2BAA2B;AAAA,MAC7G;AAAA,IACF;AAEA,WAAO,QAAQ;AAAA,MACb,WAAW,IAAI,CAAC,cAAc,KAAK,oBAAoB,WAAW,UAAU,cAAc,CAAC;AAAA,IAC7F;AAAA,EACF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,oBACZ,WACA,UACA,gBAC8B;AAC9B,QAAI,UAAU,cAAc,KAAM,QAAO;AACzC,UAAM,uBAAuB,MAAM,KAAK;AAAA,MACtC,UAAU;AAAA,MACV;AAAA,MACA;AAAA,MACA,UAAU;AAAA,IACZ;AACA,WAAO,wBAAwB,EAAE,GAAG,WAAW,qBAAqB,CAAC;AAAA,EACvE;AAAA,EAEA,MAAc,gBACZ,aACA,UACA,gBACA,gBACiB;AACjB,UAAM,QAAQ,8BAA8B,KAAK,SAAS;AAC1D,UAAM,MAAM,mCAAmC,UAAU,gBAAgB,WAAW;AAEpF,QAAI,OAAO;AACT,YAAM,SAAS,MAAM,wBAAgC,OAAO,UAAU,GAAG;AACzE,UAAI,WAAW,KAAM,QAAO;AAAA,IAC9B;AAEA,UAAM,QAAQ,MAAM,KAAK,kBAAkB,aAAa,UAAU,gBAAgB,cAAc;AAEhG,QAAI,OAAO;AACT,YAAM,yBAAyB,OAAO,UAAU,KAAK,OAAO,CAAC,GAAG,GAAG,oCAAoC;AAAA,IACzG;AAEA,WAAO;AAAA,EACT;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,kBACZ,aACA,UACA,gBACA,gBACiB;AACjB,QAAI,CAAC,eAAgB,QAAO;AAC5B,UAAM,KAAK,mBAAmB,KAAK,SAAS;AAC5C,UAAM,OAAQ,MAAM,GAAG,cAAc,EAAE;AAAA,MACrC;AAAA;AAAA;AAAA,MAGA,CAAC,aAAa,gBAAgB,UAAU,cAAc;AAAA,IACxD;AACA,WAAO,KAAK,CAAC,GAAG,SAAS;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA,EAKA,MAAc,0BACZ,UACA,gBACgC;AAChC,UAAM,KAAK,mBAAmB,KAAK,SAAS;AAK5C,UAAM,oBAAoB,MAAM,GAAG;AAAA,MACjC;AAAA,MACA,EAAE,UAAU,gBAAgB,QAAQ,MAAM,WAAW,KAAK;AAAA;AAAA;AAAA,MAG1D,EAAE,SAAS,EAAE,OAAO,OAAO,IAAI,MAAM,EAAE;AAAA,IACzC;AACA,QAAI,kBAAkB,WAAW,EAAG,QAAO,CAAC;AAE5C,UAAM,eAAe,kBAAkB,IAAI,CAAC,cAAc,UAAU,EAAE;AAEtE,UAAM,CAAC,mBAAmB,cAAc,eAAe,IAAI,MAAM,QAAQ,IAAI;AAAA,MAC3E,GAAG,KAAK,WAAW,EAAE,UAAU,gBAAgB,aAAa,EAAE,KAAK,aAAa,EAAE,CAAC;AAAA,MACnF,GAAG,KAAK,MAAM,EAAE,UAAU,gBAAgB,aAAa,EAAE,KAAK,aAAa,EAAE,CAAC;AAAA,MAC9E,GAAG,KAAK,SAAS,EAAE,UAAU,gBAAgB,aAAa,EAAE,KAAK,aAAa,EAAE,CAAC;AAAA,IACnF,CAAC;AAED,UAAM,gBAAqC,kBAAkB,IAAI,CAAC,WAAW;AAAA,MAC3E,IAAI,MAAM;AAAA,MACV,aAAa,MAAM;AAAA,MACnB,mBAAmB,MAAM,qBAAqB;AAAA,MAC9C,UAAU,MAAM;AAAA,MAChB,WAAW,MAAM;AAAA,IACnB,EAAE;AACF,UAAM,WAA2B,aAAa,IAAI,CAAC,UAAU;AAAA,MAC3D,aAAa,KAAK;AAAA,MAClB,aAAa,KAAK;AAAA,MAClB,UAAU,KAAK;AAAA,MACf,QAAQ,KAAK;AAAA,MACb,WAAW,KAAK;AAAA,IAClB,EAAE;AACF,UAAM,cAAiC,gBAAgB,IAAI,CAAC,aAAa;AAAA,MACvE,aAAa,QAAQ;AAAA,MACrB,aAAa,QAAQ;AAAA,MACrB,aAAa,QAAQ;AAAA,MACrB,WAAW,QAAQ;AAAA,MACnB,QAAQ,QAAQ;AAAA,MAChB,WAAW,QAAQ;AAAA,IACrB,EAAE;AAEF,WAAO,kBAAkB,IAAI,CAAC,cAAc;AAC1C,YAAM,MAAyB;AAAA,QAC7B,IAAI,UAAU;AAAA,QACd,MAAM,UAAU;AAAA,QAChB,OAAO,UAAU;AAAA,QACjB,YAAY,UAAU;AAAA,QACtB,MAAM,UAAU;AAAA,QAChB,cAAc,UAAU;AAAA,QACxB,cAAc,UAAU;AAAA,QACxB,oBAAoB,UAAU;AAAA,QAC9B,UAAU,UAAU,YAAY;AAAA,QAChC,QAAQ,UAAU,UAAU;AAAA,QAC5B,WAAW,UAAU,aAAa;AAAA,QAClC,gBAAgB,UAAU,kBAAkB;AAAA,MAC9C;AAKA,aAAO,yBAAyB,KAAK,eAAe,UAAU,aAAa,GAAG;AAAA,IAChF,CAAC;AAAA,EACH;AACF;AAiBA,MAAM,mBAAmB,aAAa,YAAY,EAAE,MAAM,EAAE,WAAW,QAAQ,CAAC;AAEhF,SAAS,yBACP,SACA,KAC4D;AAC5D,QAAM,OAAQ,WAAW,CAAC;AAC1B,QAAM,WACJ,OAAO,KAAK,aAAa,YAAY,KAAK,SAAS,SAAS,IAAI,KAAK,WAAW,IAAI,YAAY;AAClG,QAAM,iBACJ,OAAO,KAAK,mBAAmB,YAAY,KAAK,eAAe,SAAS,IACpE,KAAK,iBACL,IAAI,kBAAkB;AAC5B,SAAO,EAAE,UAAU,eAAe;AACpC;AAEA,SAAS,gCAAgC,SAAiC;AACxE,QAAM,OAAQ,WAAW,CAAC;AAC1B,SAAO,OAAO,KAAK,gBAAgB,YAAY,KAAK,YAAY,SAAS,IAAI,KAAK,cAAc;AAClG;AAEA,SAAS,uBAAuB,KAAwD;AACtF,MAAI;AACF,WAAO,IAAI,QAAuB,OAAO,KAAK;AAAA,EAChD,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AASA,eAAsB,qCACpB,SACA,KACe;AACf,QAAM,EAAE,UAAU,eAAe,IAAI,yBAAyB,SAAS,GAAG;AAC1E,MAAI,CAAC,YAAY,CAAC,eAAgB;AAClC,QAAM,QAAQ,uBAAuB,GAAG;AACxC,MAAI,CAAC,MAAO;AACZ,MAAI;AACF,UAAM,6BAA6B,OAAO,UAAU,cAAc;AAAA,EACpE,SAAS,KAAK;AACZ,qBAAiB,KAAK,uDAAuD,EAAE,UAAU,gBAAgB,IAAI,CAAC;AAAA,EAChH;AACF;AAIA,eAAsB,6CACpB,SACA,KACe;AACf,QAAM,EAAE,UAAU,eAAe,IAAI,yBAAyB,SAAS,GAAG;AAC1E,QAAM,cAAc,gCAAgC,OAAO;AAC3D,MAAI,CAAC,YAAY,CAAC,kBAAkB,CAAC,YAAa;AAClD,QAAM,QAAQ,uBAAuB,GAAG;AACxC,MAAI,CAAC,MAAO;AACZ,MAAI;AACF,UAAM,qCAAqC,OAAO,UAAU,gBAAgB,WAAW;AAAA,EACzF,SAAS,KAAK;AACZ,qBAAiB,KAAK,gEAAgE;AAAA,MACpF;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF,CAAC;AAAA,EACH;AACF;",
6
- "names": []
4
+ "sourcesContent": ["import { TransactionContext } from '@mikro-orm/core'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport { runWithCacheTenant, type CacheStrategy } from '@open-mercato/cache'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport {\n Benefit,\n Promotion,\n Rule,\n RuleGroup,\n type PromotionBenefitAppliesTo,\n type PromotionRuleGroupOperator,\n} from '../data/entities'\nimport type {\n NormalizedBenefit,\n NormalizedPromotion,\n NormalizedRule,\n NormalizedRuleGroup,\n} from '../lib/types'\n\n// SPEC-055 \u00A7Cache Build Contract + \u00A7Normalized Promotion Shape. This file is the ONLY\n// place in the module that builds `NormalizedPromotion[]` from the database \u2014 the pure\n// evaluation pipeline (`lib/evaluation-engine.ts` and friends) never touches the\n// EntityManager, only ever receives the plain objects this file produces.\n\n// ---------------------------------------------------------------------------\n// Cache keys\n// ---------------------------------------------------------------------------\n\n// Literal format pinned by SPEC-055 \u00A7Evaluation Engine Data Flow pseudocode\n// (`key: promotions:active:{tenantId}:{organizationId}`). Key and tag share the same\n// value intentionally (one tree cache entry per tenant/org, mirrors\n// `inbox_ops/lib/cache.ts`'s COUNTS/SETTINGS keys) so invalidation never needs a\n// separate tag registry.\nexport function buildPromotionTreeCacheKey(tenantId: string, organizationId: string): string {\n return `promotions:active:${tenantId}:${organizationId}`\n}\n\n// Not specified verbatim by the spec (only \"a separate, small counters key\" is\n// required, \u00A7Cache Build Contract item 4) \u2014 scoped per-promotion, not per-tenant, so\n// a `usage.registered`/`usage.reverted` event for one promotion only ever evicts that\n// promotion's own counter, never the whole tenant's budget-capped set and never the\n// (expensive-to-rebuild) main tree cache.\nexport function buildPromotionUsageCounterCacheKey(\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): string {\n return `promotions:usage-counter:${tenantId}:${organizationId}:${promotionId}`\n}\n\nconst PROMOTION_TREE_CACHE_TTL_MS = 5 * 60 * 1000\nconst PROMOTION_USAGE_COUNTER_CACHE_TTL_MS = 5 * 60 * 1000\n\nasync function readPromotionCacheEntry<T>(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n): Promise<T | null> {\n try {\n const value = await runWithCacheTenant(tenantId, () => cache.get(key))\n return value === null || value === undefined ? null : (value as T)\n } catch {\n return null\n }\n}\n\nasync function writePromotionCacheEntry(\n cache: CacheStrategy,\n tenantId: string,\n key: string,\n value: unknown,\n tags: string[],\n ttlMs: number,\n): Promise<void> {\n try {\n await runWithCacheTenant(tenantId, () => cache.set(key, value, { ttl: ttlMs, tags }))\n } catch {\n // Best-effort: a cache write failure must not fail the read it is backing.\n }\n}\n\n// The subscriber handlers below (and `subscribers/*.ts`, which just wire an event id to\n// one of those handlers) call these two directly \u2014 they never build the tag string\n// themselves, so the key format stays single-sourced here.\nexport async function invalidatePromotionTreeCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionTreeCacheKey(tenantId, organizationId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\nexport async function invalidatePromotionUsageCounterCache(\n cache: CacheStrategy | null | undefined,\n tenantId: string,\n organizationId: string,\n promotionId: string,\n): Promise<void> {\n if (!cache) return\n const tag = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n await runWithCacheTenant(tenantId, () => cache.deleteByTags([tag]))\n}\n\n// ---------------------------------------------------------------------------\n// Row shapes \u2014 the already-fetched, plain-object input to the pure normalization\n// below. Field names mirror `data/entities.ts` exactly (camelCase entity properties).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheRow = {\n id: string\n name: string\n order: number\n cumulative: boolean\n tags: string[]\n excludedTags: string[]\n excludeFlags: Record<string, boolean>\n eligibleCurrencies: string[]\n startsAt: Date | null\n endsAt: Date | null\n maxBudget: string | null\n budgetCurrency: string | null\n}\n\nexport type RuleGroupCacheRow = {\n id: string\n promotionId: string\n parentRuleGroupId: string | null\n operator: PromotionRuleGroupOperator\n sortOrder: number\n}\n\nexport type RuleCacheRow = {\n ruleGroupId: string\n promotionId: string\n ruleType: string\n config: Record<string, unknown>\n sortOrder: number\n}\n\nexport type BenefitCacheRow = {\n ruleGroupId: string\n promotionId: string\n benefitType: string\n appliesTo: PromotionBenefitAppliesTo\n config: Record<string, unknown>\n sortOrder: number\n}\n\n// ---------------------------------------------------------------------------\n// Deep freeze (\u00A7Normalized Promotion Shape: \"In non-production builds, objects are\n// deep-frozen to detect accidental mutation\")\n// ---------------------------------------------------------------------------\n\nfunction deepFreeze<T>(value: T): T {\n if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) {\n Object.freeze(value)\n for (const key of Object.getOwnPropertyNames(value)) {\n deepFreeze((value as Record<string, unknown>)[key])\n }\n }\n return value\n}\n\nfunction freezeOutsideProduction<T>(value: T): T {\n return process.env.NODE_ENV === 'production' ? value : deepFreeze(value)\n}\n\n// ---------------------------------------------------------------------------\n// Pure normalization / tree assembly \u2014 no EntityManager, no cache, fully testable\n// without a database connection. Extracted per SPEC-055 Step 2.5's test requirement.\n// ---------------------------------------------------------------------------\n\nfunction buildNormalizedRuleGroup(\n groupId: string,\n groupsForPromotion: RuleGroupCacheRow[],\n rulesForPromotion: RuleCacheRow[],\n benefitsForPromotion: BenefitCacheRow[],\n): NormalizedRuleGroup {\n const group = groupsForPromotion.find((candidate) => candidate.id === groupId)\n if (!group) {\n // \u00A7Cache Build Contract item 3: \"Any missing eager-loaded relation is treated as a\n // critical bug.\" A child id referenced by `parentRuleGroupId` that was not fetched\n // in the same batch is exactly that \u2014 never silently produce an incomplete tree.\n throw new Error(`[internal] promotion cache build is missing rule group ${groupId}`)\n }\n\n const rules: NormalizedRule[] = rulesForPromotion\n .filter((rule) => rule.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((rule) => ({ type: rule.ruleType, config: rule.config }))\n\n const benefits: NormalizedBenefit[] = benefitsForPromotion\n .filter((benefit) => benefit.ruleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((benefit) => ({ type: benefit.benefitType, appliesTo: benefit.appliesTo, config: benefit.config }))\n\n const children: NormalizedRuleGroup[] = groupsForPromotion\n .filter((candidate) => candidate.parentRuleGroupId === groupId)\n .slice()\n .sort((a, b) => a.sortOrder - b.sortOrder)\n .map((child) => buildNormalizedRuleGroup(child.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion))\n\n return { operator: group.operator, rules, benefits, children }\n}\n\n/**\n * Pure tree assembly for a single promotion. Accepts the full fetched batch for every\n * rule-group/rule/benefit row (not pre-filtered per promotion) and narrows internally by\n * `promotionId` \u2014 the real service fetches these arrays once for a whole batch of active\n * promotions (\u00A7Cache Build Contract item 1: \"a single cache build performs all required\n * database reads\", not N+1 per promotion) and calls this function once per promotion.\n *\n * `totalDiscountGranted` is passed in already resolved \u2014 this function never reads\n * `promotion_usages` itself (\u00A7Cache Build Contract item 4: the counter lives in a\n * separate, small cache key the caller resolves independently).\n */\nexport function buildNormalizedPromotion(\n promotionRow: PromotionCacheRow,\n ruleGroupRows: RuleGroupCacheRow[],\n ruleRows: RuleCacheRow[],\n benefitRows: BenefitCacheRow[],\n totalDiscountGranted: string,\n): NormalizedPromotion {\n const groupsForPromotion = ruleGroupRows.filter((group) => group.promotionId === promotionRow.id)\n const rulesForPromotion = ruleRows.filter((rule) => rule.promotionId === promotionRow.id)\n const benefitsForPromotion = benefitRows.filter((benefit) => benefit.promotionId === promotionRow.id)\n\n const root = groupsForPromotion.find((group) => group.parentRuleGroupId === null)\n if (!root) {\n throw new Error(\n `[internal] promotion cache build is missing the root rule group for promotion ${promotionRow.id}`,\n )\n }\n\n // \u00A7Cache Build Contract item 3: a rule/benefit whose owning `ruleGroupId` was not\n // among the batch-fetched groups is exactly the \"missing eager-loaded relation\"\n // scenario \u2014 never silently drop it from the tree.\n const groupIdsForPromotion = new Set(groupsForPromotion.map((group) => group.id))\n for (const rule of rulesForPromotion) {\n if (!groupIdsForPromotion.has(rule.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${rule.ruleGroupId} referenced by a rule for promotion ${promotionRow.id}`,\n )\n }\n }\n for (const benefit of benefitsForPromotion) {\n if (!groupIdsForPromotion.has(benefit.ruleGroupId)) {\n throw new Error(\n `[internal] promotion cache build is missing rule group ${benefit.ruleGroupId} referenced by a benefit for promotion ${promotionRow.id}`,\n )\n }\n }\n\n const normalized: NormalizedPromotion = {\n id: promotionRow.id,\n name: promotionRow.name,\n order: promotionRow.order,\n cumulative: promotionRow.cumulative,\n tags: [...promotionRow.tags],\n excludedTags: [...promotionRow.excludedTags],\n excludeFlags: { ...promotionRow.excludeFlags },\n eligibleCurrencies: [...promotionRow.eligibleCurrencies],\n startsAt: promotionRow.startsAt ? promotionRow.startsAt.toISOString() : null,\n endsAt: promotionRow.endsAt ? promotionRow.endsAt.toISOString() : null,\n maxBudget: promotionRow.maxBudget,\n budgetCurrency: promotionRow.budgetCurrency,\n totalDiscountGranted,\n rootGroup: buildNormalizedRuleGroup(root.id, groupsForPromotion, rulesForPromotion, benefitsForPromotion),\n }\n\n return freezeOutsideProduction(normalized)\n}\n\n// ---------------------------------------------------------------------------\n// Cache-backed service \u2014 the only place in this module that ever queries the\n// database for the evaluation engine's input (\u00A7Cache Build Contract item 2).\n// ---------------------------------------------------------------------------\n\nexport type PromotionCacheContainer = {\n resolve: <T = unknown>(name: string) => T\n}\n\nfunction resolvePromotionCacheStrategy(container: PromotionCacheContainer): CacheStrategy | null {\n if (resolvePromotionEm(container).isInTransaction()) return null\n try {\n return container.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\nfunction resolvePromotionEm(container: PromotionCacheContainer): EntityManager {\n const registeredEm = container.resolve<EntityManager>('em')\n const contextualEm = TransactionContext.getEntityManager(registeredEm.name) as EntityManager | undefined\n const em = contextualEm && contextualEm.getDriver() === registeredEm.getDriver() ? contextualEm : registeredEm\n return em.fork({ keepTransactionContext: true, disableContextResolution: Boolean(contextualEm && contextualEm !== em) })\n}\n\nexport class PromotionCacheService {\n constructor(private readonly container: PromotionCacheContainer) {}\n\n async getActivePromotions(tenantId: string, organizationId: string): Promise<NormalizedPromotion[]> {\n const em = resolvePromotionEm(this.container)\n if (em.isInTransaction()) {\n const promotions = await this.buildActivePromotionTrees(tenantId, organizationId)\n return this.buildTransactionalUsageSnapshot(em, promotions, tenantId, organizationId)\n }\n const cache = resolvePromotionCacheStrategy(this.container)\n const treeKey = buildPromotionTreeCacheKey(tenantId, organizationId)\n\n let promotions = cache ? await readPromotionCacheEntry<NormalizedPromotion[]>(cache, tenantId, treeKey) : null\n if (!promotions) {\n promotions = await this.buildActivePromotionTrees(tenantId, organizationId)\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, treeKey, promotions, [treeKey], PROMOTION_TREE_CACHE_TTL_MS)\n }\n }\n\n return Promise.all(\n promotions.map((promotion) => this.overlayUsageCounter(promotion, tenantId, organizationId)),\n )\n }\n\n private async buildTransactionalUsageSnapshot(\n em: EntityManager,\n promotions: NormalizedPromotion[],\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion[]> {\n const budgeted = promotions.filter((promotion) => promotion.maxBudget !== null)\n const currencies = [...new Set(budgeted.map((promotion) => promotion.budgetCurrency).filter((currency): currency is string => !!currency))]\n const totals = new Map<string, string>()\n if (budgeted.length > 0 && currencies.length > 0) {\n const promotionIds = budgeted.map((promotion) => promotion.id)\n const promotionSlots = promotionIds.map(() => '?').join(', ')\n const currencySlots = currencies.map(() => '?').join(', ')\n const rows = await em.execute<Array<{ promotion_id: string; currency: string; total: string }>>(\n `SELECT promotion_id, currency, COALESCE(SUM(total_discount_amount), 0)::text AS total\n FROM promotion_usages\n WHERE promotion_id IN (${promotionSlots}) AND currency IN (${currencySlots})\n AND organization_id = ? AND tenant_id = ? AND reverted_at IS NULL\n GROUP BY promotion_id, currency`,\n [...promotionIds, ...currencies, organizationId, tenantId],\n )\n for (const row of rows) totals.set(`${row.promotion_id}:${row.currency}`, row.total)\n }\n return promotions.map((promotion) => promotion.maxBudget === null ? promotion : freezeOutsideProduction({\n ...promotion,\n totalDiscountGranted: totals.get(`${promotion.id}:${promotion.budgetCurrency}`) ?? '0',\n }))\n }\n\n // \u00A7Cache Build Contract item 4: \"read only for budget-capped promotions\" \u2014 an\n // uncapped promotion's `totalDiscountGranted` is never read back from\n // `promotion_usages`, it stays the inert '0' baked into the tree cache.\n private async overlayUsageCounter(\n promotion: NormalizedPromotion,\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion> {\n if (promotion.maxBudget === null) return promotion\n const totalDiscountGranted = await this.getUsageCounter(\n promotion.id,\n tenantId,\n organizationId,\n promotion.budgetCurrency,\n )\n return freezeOutsideProduction({ ...promotion, totalDiscountGranted })\n }\n\n private async getUsageCounter(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n const cache = resolvePromotionCacheStrategy(this.container)\n const key = buildPromotionUsageCounterCacheKey(tenantId, organizationId, promotionId)\n\n if (cache) {\n const cached = await readPromotionCacheEntry<string>(cache, tenantId, key)\n if (cached !== null) return cached\n }\n\n const total = await this.sumPromotionUsage(promotionId, tenantId, organizationId, budgetCurrency)\n\n if (cache) {\n await writePromotionCacheEntry(cache, tenantId, key, total, [key], PROMOTION_USAGE_COUNTER_CACHE_TTL_MS)\n }\n\n return total\n }\n\n // \u00A7Cache Build Contract item 1 / \u00A7Promotion Usage Lifecycle: `SUM(total_discount_amount)\n // WHERE reverted_at IS NULL AND currency = budget_currency` \u2014 the same filter\n // `registerUsage`'s own re-read-sum uses.\n private async sumPromotionUsage(\n promotionId: string,\n tenantId: string,\n organizationId: string,\n budgetCurrency: string | null,\n ): Promise<string> {\n if (!budgetCurrency) return '0'\n const em = resolvePromotionEm(this.container)\n const rows = (await em.execute(\n `SELECT COALESCE(SUM(total_discount_amount), 0)::text AS total\n FROM promotion_usages\n WHERE promotion_id = ? AND organization_id = ? AND tenant_id = ? AND currency = ? AND reverted_at IS NULL`,\n [promotionId, organizationId, tenantId, budgetCurrency],\n )) as Array<{ total: string }>\n return rows[0]?.total ?? '0'\n }\n\n // \u00A7Cache Build Contract item 1: one query pass for the whole batch of active\n // promotions \u2014 every `RuleGroup`/`Rule`/`Benefit` row for them is fetched in three\n // fixed queries (run in parallel), never per-promotion.\n private async buildActivePromotionTrees(\n tenantId: string,\n organizationId: string,\n ): Promise<NormalizedPromotion[]> {\n const em = resolvePromotionEm(this.container)\n\n // \u00A7Cache Build Contract (rev R-H3b/P8): every `active = true` promotion, regardless\n // of `starts_at`/`ends_at` \u2014 date-window filtering happens only inside the evaluator\n // (`lib/evaluation-engine.ts`'s `isPromotionEligible`), never here.\n const promotionEntities = await em.find(\n Promotion,\n { tenantId, organizationId, active: true, deletedAt: null },\n // `lib/evaluation-engine.ts` documents that the cache is responsible for ordering\n // by `order ASC, id ASC` before evaluation ever sees the array.\n { orderBy: { order: 'asc', id: 'asc' } },\n )\n if (promotionEntities.length === 0) return []\n\n const promotionIds = promotionEntities.map((promotion) => promotion.id)\n\n const [ruleGroupEntities, ruleEntities, benefitEntities] = await Promise.all([\n em.find(RuleGroup, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Rule, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n em.find(Benefit, { tenantId, organizationId, promotionId: { $in: promotionIds } }),\n ])\n\n const ruleGroupRows: RuleGroupCacheRow[] = ruleGroupEntities.map((group) => ({\n id: group.id,\n promotionId: group.promotionId,\n parentRuleGroupId: group.parentRuleGroupId ?? null,\n operator: group.operator,\n sortOrder: group.sortOrder,\n }))\n const ruleRows: RuleCacheRow[] = ruleEntities.map((rule) => ({\n ruleGroupId: rule.ruleGroupId,\n promotionId: rule.promotionId,\n ruleType: rule.ruleType,\n config: rule.config,\n sortOrder: rule.sortOrder,\n }))\n const benefitRows: BenefitCacheRow[] = benefitEntities.map((benefit) => ({\n ruleGroupId: benefit.ruleGroupId,\n promotionId: benefit.promotionId,\n benefitType: benefit.benefitType,\n appliesTo: benefit.appliesTo,\n config: benefit.config,\n sortOrder: benefit.sortOrder,\n }))\n\n return promotionEntities.map((promotion) => {\n const row: PromotionCacheRow = {\n id: promotion.id,\n name: promotion.name,\n order: promotion.order,\n cumulative: promotion.cumulative,\n tags: promotion.tags,\n excludedTags: promotion.excludedTags,\n excludeFlags: promotion.excludeFlags,\n eligibleCurrencies: promotion.eligibleCurrencies,\n startsAt: promotion.startsAt ?? null,\n endsAt: promotion.endsAt ?? null,\n maxBudget: promotion.maxBudget ?? null,\n budgetCurrency: promotion.budgetCurrency ?? null,\n }\n // Placeholder \u2014 `getActivePromotions` overlays the real snapshot for\n // budget-capped promotions from the separate counters cache key right after this\n // method returns (\u00A7Cache Build Contract item 4). Never read directly from the\n // tree cache for a budget decision.\n return buildNormalizedPromotion(row, ruleGroupRows, ruleRows, benefitRows, '0')\n })\n }\n}\n\n// ---------------------------------------------------------------------------\n// Subscriber glue (`subscribers/*.ts` \u2014 ephemeral cache-invalidation handlers).\n// Kept here, not in `subscribers/` itself: every file under a module's `subscribers/`\n// folder is auto-discovered as a subscriber definition by `yarn generate` (it is scanned\n// unconditionally, with no `metadata` export treated as a critical bug further down the\n// pipeline, not as \"skip this file\") \u2014 mirrors `ecommerce/lib/cacheInvalidation.ts`\n// shared by that module's own `subscribers/*-cache-invalidation.ts` files.\n// ---------------------------------------------------------------------------\n\nexport type PromotionsSubscriberContext = {\n resolve: <T = unknown>(name: string) => T\n tenantId?: string | null\n organizationId?: string | null\n}\n\nconst subscriberLogger = createLogger('promotions').child({ component: 'cache' })\n\nfunction readSubscriberEventScope(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): { tenantId: string | null; organizationId: string | null } {\n const data = (payload ?? {}) as Record<string, unknown>\n const tenantId =\n typeof data.tenantId === 'string' && data.tenantId.length > 0 ? data.tenantId : ctx.tenantId ?? null\n const organizationId =\n typeof data.organizationId === 'string' && data.organizationId.length > 0\n ? data.organizationId\n : ctx.organizationId ?? null\n return { tenantId, organizationId }\n}\n\nfunction readPromotionIdFromUsagePayload(payload: unknown): string | null {\n const data = (payload ?? {}) as Record<string, unknown>\n return typeof data.promotionId === 'string' && data.promotionId.length > 0 ? data.promotionId : null\n}\n\nfunction resolveSubscriberCache(ctx: PromotionsSubscriberContext): CacheStrategy | null {\n try {\n return ctx.resolve<CacheStrategy>('cache') ?? null\n } catch {\n return null\n }\n}\n\n// \u00A7Cache Build Contract item 5: the main tree cache is invalidated ONLY by promotion/\n// code mutation events (`promotions.promotion.created|updated|deleted`,\n// `promotions.code.created|updated|deleted`) \u2014 never by a usage event. Wired to those\n// six exact event ids (not a `promotions.promotion.*`/`promotions.code.*` wildcard) so\n// the lifecycle events declared alongside them in `events.ts`\n// (`promotions.promotion.budget_exhausted`/`.expiring_soon`, `promotions.code.used`/\n// `.exhausted`) never trigger a rebuild they were not asked to.\nexport async function handlePromotionTreeCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n if (!tenantId || !organizationId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionTreeCache(cache, tenantId, organizationId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion tree cache invalidation failed', { tenantId, organizationId, err })\n }\n}\n\n// \u00A7Cache Build Contract item 4/5: `promotions.usage.registered`/`.reverted` evict only\n// the one promotion's small counters-key entry, never the main tree cache.\nexport async function handlePromotionUsageCounterCacheInvalidation(\n payload: unknown,\n ctx: PromotionsSubscriberContext,\n): Promise<void> {\n const { tenantId, organizationId } = readSubscriberEventScope(payload, ctx)\n const promotionId = readPromotionIdFromUsagePayload(payload)\n if (!tenantId || !organizationId || !promotionId) return\n const cache = resolveSubscriberCache(ctx)\n if (!cache) return\n try {\n await invalidatePromotionUsageCounterCache(cache, tenantId, organizationId, promotionId)\n } catch (err) {\n subscriberLogger.warn('[internal] promotion usage counter cache invalidation failed', {\n tenantId,\n organizationId,\n promotionId,\n err,\n })\n }\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["promotions"]
7
7
  }
@@ -0,0 +1,227 @@
1
+ import { NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { createRequestContainer } from "@open-mercato/shared/lib/di/container";
4
+ import { getAuthFromRequest } from "@open-mercato/shared/lib/auth/server";
5
+ import { resolveOrganizationScopeForRequest } from "@open-mercato/core/modules/directory/utils/organizationScope";
6
+ import { serializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
7
+ import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
8
+ import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/errors";
9
+ import {
10
+ bridgeLegacyGuard,
11
+ runMutationGuards
12
+ } from "@open-mercato/shared/lib/crud/mutation-guard-registry";
13
+ import { getAllMutationGuardInstances } from "@open-mercato/shared/lib/crud/mutation-guard-store";
14
+ import { createLogger } from "@open-mercato/shared/lib/logger";
15
+ import { getCommandInterceptorHttpRejection } from "@open-mercato/shared/lib/commands/errors";
16
+ import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
17
+ import { getTelemetryRuntime } from "@open-mercato/shared/lib/telemetry/runtime";
18
+ import { orderLineBulkUpsertSchema } from "../../../data/validators.js";
19
+ import { withScopedPayload } from "../../utils.js";
20
+ const logger = createLogger("sales");
21
+ function reportBatchRouteError(error, code, attributes) {
22
+ try {
23
+ getTelemetryRuntime()?.reportError(
24
+ error,
25
+ attributes ? { module: "sales", code, attributes } : { module: "sales", code }
26
+ );
27
+ } catch (reportingError) {
28
+ logger.warn("Failed to report batch order-line error", { code, reportingError });
29
+ }
30
+ }
31
+ function localizeIssues(issues, translate) {
32
+ return issues.map((issue) => {
33
+ const key = issue.params?.omKey;
34
+ if (typeof key !== "string") return issue;
35
+ return { ...issue, message: translate(key, issue.message) };
36
+ });
37
+ }
38
+ const COMMAND_ID = "sales.orders.lines.upsert_many";
39
+ const RESOURCE_KIND = "sales.order";
40
+ const metadata = {
41
+ POST: { requireAuth: true, requireFeatures: ["sales.orders.manage"] }
42
+ };
43
+ async function resolveGrantedFeatures(ctx) {
44
+ if (!ctx.auth?.sub) return [];
45
+ try {
46
+ const rbac = ctx.container.resolve("rbacService");
47
+ const features = await rbac?.getGrantedFeatures?.(ctx.auth.sub, {
48
+ tenantId: ctx.auth.tenantId,
49
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId
50
+ });
51
+ return features ?? [];
52
+ } catch {
53
+ return [];
54
+ }
55
+ }
56
+ async function runGuards(ctx, input) {
57
+ const guards = [...getAllMutationGuardInstances()];
58
+ const legacyGuard = bridgeLegacyGuard(ctx.container);
59
+ if (legacyGuard) guards.push(legacyGuard);
60
+ if (!guards.length || !ctx.auth?.tenantId) {
61
+ return { ok: true, afterSuccessCallbacks: [] };
62
+ }
63
+ return runMutationGuards(guards, input, {
64
+ userFeatures: await resolveGrantedFeatures(ctx)
65
+ });
66
+ }
67
+ async function runGuardAfterSuccessCallbacks(callbacks, input) {
68
+ for (const callback of callbacks) {
69
+ if (!callback.guard.afterSuccess) continue;
70
+ try {
71
+ await callback.guard.afterSuccess({
72
+ ...input,
73
+ metadata: callback.metadata ?? null
74
+ });
75
+ } catch (err) {
76
+ logger.error("Mutation guard afterSuccess failed", { guardId: callback.guard.id, err });
77
+ reportBatchRouteError(err, "sales.order_lines_guard_after_success_failed", {
78
+ guardId: callback.guard.id
79
+ });
80
+ }
81
+ }
82
+ }
83
+ async function resolveRequestContext(req) {
84
+ const container = await createRequestContainer();
85
+ const auth = await getAuthFromRequest(req);
86
+ const { translate } = await resolveTranslations();
87
+ if (!auth || !auth.tenantId) {
88
+ throw new CrudHttpError(401, { error: translate("sales.documents.errors.unauthorized", "Unauthorized") });
89
+ }
90
+ const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req });
91
+ if (scope?.selectionRejected) {
92
+ throw new CrudHttpError(422, {
93
+ error: translate(
94
+ "sales.documents.errors.organization_selection_invalid",
95
+ "Your selected organization is no longer available. Please re-select an organization and try again."
96
+ ),
97
+ code: "organization_selection_invalid"
98
+ });
99
+ }
100
+ const organizationId = scope ? scope.selectedId : auth.orgId ?? null;
101
+ if (!organizationId) {
102
+ throw new CrudHttpError(400, {
103
+ error: translate("sales.documents.errors.organization_required", "Organization context is required")
104
+ });
105
+ }
106
+ return {
107
+ container,
108
+ auth,
109
+ organizationScope: scope,
110
+ selectedOrganizationId: organizationId,
111
+ organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),
112
+ request: req
113
+ };
114
+ }
115
+ async function POST(req) {
116
+ try {
117
+ const ctx = await resolveRequestContext(req);
118
+ const { translate } = await resolveTranslations();
119
+ const payload = await readJsonSafe(req, {});
120
+ const scoped = withScopedPayload(payload ?? {}, ctx, translate);
121
+ let input = orderLineBulkUpsertSchema.parse(scoped);
122
+ const guardScope = {
123
+ tenantId: ctx.auth?.tenantId ?? "",
124
+ organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,
125
+ userId: ctx.auth?.sub ?? "",
126
+ resourceKind: RESOURCE_KIND,
127
+ resourceId: input.orderId,
128
+ operation: "update",
129
+ requestMethod: req.method,
130
+ requestHeaders: req.headers
131
+ };
132
+ const guardResult = await runGuards(ctx, {
133
+ ...guardScope,
134
+ mutationPayload: input
135
+ });
136
+ if (!guardResult.ok) {
137
+ return NextResponse.json(
138
+ guardResult.errorBody ?? { error: translate("sales.documents.errors.guard_blocked", "Operation blocked by guard") },
139
+ { status: guardResult.errorStatus ?? 422 }
140
+ );
141
+ }
142
+ if (guardResult.modifiedPayload) {
143
+ input = orderLineBulkUpsertSchema.parse({ ...input, ...guardResult.modifiedPayload });
144
+ }
145
+ const commandBus = ctx.container.resolve("commandBus");
146
+ const { result, logEntry } = await commandBus.execute(COMMAND_ID, { input: { body: input }, ctx });
147
+ const orderId = result?.orderId ?? input.orderId;
148
+ const jsonResponse = NextResponse.json({ orderId, lineIds: result?.lineIds ?? [] });
149
+ if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {
150
+ jsonResponse.headers.set(
151
+ "x-om-operation",
152
+ serializeOperationMetadata({
153
+ id: logEntry.id,
154
+ undoToken: logEntry.undoToken,
155
+ commandId: logEntry.commandId,
156
+ actionLabel: logEntry.actionLabel ?? null,
157
+ resourceKind: logEntry.resourceKind ?? RESOURCE_KIND,
158
+ resourceId: logEntry.resourceId ?? orderId,
159
+ executedAt: logEntry.createdAt instanceof Date ? logEntry.createdAt.toISOString() : typeof logEntry.createdAt === "string" ? logEntry.createdAt : (/* @__PURE__ */ new Date()).toISOString()
160
+ })
161
+ );
162
+ }
163
+ if (guardResult.afterSuccessCallbacks.length) {
164
+ await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, guardScope);
165
+ }
166
+ return jsonResponse;
167
+ } catch (err) {
168
+ if (isCrudHttpError(err)) {
169
+ return NextResponse.json(err.body, { status: err.status });
170
+ }
171
+ if (err instanceof z.ZodError) {
172
+ const { translate: translate2 } = await resolveTranslations();
173
+ return NextResponse.json(
174
+ {
175
+ error: translate2("sales.documents.items.bulk.errorInvalidInput", "Invalid input"),
176
+ details: localizeIssues(err.issues, translate2)
177
+ },
178
+ { status: 400 }
179
+ );
180
+ }
181
+ const interceptorRejection = getCommandInterceptorHttpRejection(err);
182
+ if (interceptorRejection) {
183
+ return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status });
184
+ }
185
+ const { translate } = await resolveTranslations();
186
+ logger.error("sales.orders.lines.upsert_many failed", { err });
187
+ reportBatchRouteError(err, "sales.order_lines_batch_failed");
188
+ return NextResponse.json(
189
+ { error: translate("sales.documents.items.errorSaveBatch", "Failed to save order lines.") },
190
+ { status: 400 }
191
+ );
192
+ }
193
+ }
194
+ const bulkUpsertResponseSchema = z.object({
195
+ orderId: z.string().uuid(),
196
+ lineIds: z.array(z.string().uuid())
197
+ });
198
+ const openApi = {
199
+ tag: "Sales",
200
+ summary: "Bulk upsert order lines",
201
+ methods: {
202
+ POST: {
203
+ summary: "Upsert and delete order lines in one pass",
204
+ description: "Writes a whole order line set in one aggregate load: entries carrying an id update that line, entries without one are appended, and deleteIds removes lines. The batch is all-or-nothing \u2014 any refused entry aborts it \u2014 and the order is renumbered 1..n, its totals recalculated and one undoable audit entry recorded.",
205
+ requestBody: {
206
+ contentType: "application/json",
207
+ schema: orderLineBulkUpsertSchema
208
+ },
209
+ responses: [
210
+ { status: 200, description: "Batch applied", schema: bulkUpsertResponseSchema },
211
+ { status: 400, description: "Invalid payload", schema: z.object({ error: z.string(), details: z.array(z.unknown()).optional() }) },
212
+ { status: 401, description: "Unauthorized", schema: z.object({ error: z.string() }) },
213
+ { status: 403, description: "Forbidden", schema: z.object({ error: z.string() }) },
214
+ { status: 404, description: "Order or line not found", schema: z.object({ error: z.string() }) },
215
+ { status: 409, description: "Conflict detected", schema: z.object({ error: z.string(), code: z.string().optional() }) },
216
+ { status: 422, description: "Selected organization is no longer available", schema: z.object({ error: z.string(), code: z.literal("organization_selection_invalid") }) },
217
+ { status: 423, description: "Record locked", schema: z.object({ error: z.string(), code: z.string().optional() }) }
218
+ ]
219
+ }
220
+ }
221
+ };
222
+ export {
223
+ POST,
224
+ metadata,
225
+ openApi
226
+ };
227
+ //# sourceMappingURL=route.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../../../../../src/modules/sales/api/order-lines/batch/route.ts"],
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'\nimport { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'\nimport { serializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib/commands'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport {\n bridgeLegacyGuard,\n runMutationGuards,\n type MutationGuard,\n type MutationGuardAfterInput,\n type MutationGuardInput,\n} from '@open-mercato/shared/lib/crud/mutation-guard-registry'\nimport { getAllMutationGuardInstances } from '@open-mercato/shared/lib/crud/mutation-guard-store'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { getCommandInterceptorHttpRejection } from '@open-mercato/shared/lib/commands/errors'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { getTelemetryRuntime } from '@open-mercato/shared/lib/telemetry/runtime'\nimport { orderLineBulkUpsertSchema } from '../../../data/validators'\nimport { withScopedPayload } from '../../utils'\n\nconst logger = createLogger('sales')\n\nfunction reportBatchRouteError(\n error: unknown,\n code: 'sales.order_lines_batch_failed' | 'sales.order_lines_guard_after_success_failed',\n attributes?: Record<string, string | number | boolean | undefined>,\n): void {\n try {\n getTelemetryRuntime()?.reportError(\n error,\n attributes ? { module: 'sales', code, attributes } : { module: 'sales', code },\n )\n } catch (reportingError) {\n logger.warn('Failed to report batch order-line error', { code, reportingError })\n }\n}\n\n/**\n * The bulk schema tags its own refusals with a translation key in `params`\n * because it runs outside a request and cannot translate. Resolve those here,\n * leaving zod's own built-in issues as they are.\n */\nfunction localizeIssues(\n issues: z.ZodError['issues'],\n translate: (key: string, fallback?: string) => string,\n): z.ZodError['issues'] {\n return issues.map((issue) => {\n const key = (issue as { params?: { omKey?: unknown } }).params?.omKey\n if (typeof key !== 'string') return issue\n return { ...issue, message: translate(key, issue.message) }\n })\n}\n\nconst COMMAND_ID = 'sales.orders.lines.upsert_many'\nconst RESOURCE_KIND = 'sales.order'\n\nexport const metadata = {\n POST: { requireAuth: true, requireFeatures: ['sales.orders.manage'] },\n}\n\ntype BulkUpsertResult = { orderId: string; lineIds: string[] }\n\ntype RbacServiceLike = {\n getGrantedFeatures?: (\n userId: string,\n scope: { tenantId?: string | null; organizationId?: string | null },\n ) => Promise<string[] | undefined>\n}\n\n/**\n * The features a guard's `features` gate is checked against. Read from RBAC\n * rather than off the token's `features` claim: a guard that only runs for\n * holders of a feature must not be skippable by a caller who can shape their\n * own claim.\n */\nasync function resolveGrantedFeatures(ctx: CommandRuntimeContext): Promise<string[]> {\n if (!ctx.auth?.sub) return []\n try {\n const rbac = ctx.container.resolve('rbacService') as RbacServiceLike | undefined\n const features = await rbac?.getGrantedFeatures?.(ctx.auth.sub, {\n tenantId: ctx.auth.tenantId,\n organizationId: ctx.selectedOrganizationId ?? ctx.auth.orgId,\n })\n return features ?? []\n } catch {\n // rbacService not registered \u2014 guards without a feature gate still run.\n return []\n }\n}\n\n/**\n * Every guard the shared CRUD factory would run for this mutation: the ones\n * modules register through `registerMutationGuards`, plus the legacy DI guard\n * bridged on top. Consulting only the legacy bridge let a registered policy\n * that denies an ordinary order write be bypassed through this route.\n */\nasync function runGuards(\n ctx: CommandRuntimeContext,\n input: MutationGuardInput,\n): Promise<{\n ok: boolean\n errorBody?: Record<string, unknown>\n errorStatus?: number\n modifiedPayload?: Record<string, unknown>\n afterSuccessCallbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>\n}> {\n const guards = [...getAllMutationGuardInstances()]\n const legacyGuard = bridgeLegacyGuard(ctx.container)\n if (legacyGuard) guards.push(legacyGuard)\n if (!guards.length || !ctx.auth?.tenantId) {\n return { ok: true, afterSuccessCallbacks: [] }\n }\n\n return runMutationGuards(guards, input, {\n userFeatures: await resolveGrantedFeatures(ctx),\n })\n}\n\n/**\n * These run after the batch has committed and after the success response \u2014\n * including its undo metadata \u2014 has been built. A throwing callback must not\n * turn a committed write into a reported failure: the caller would retry, and\n * entries without an id would append a second time. Each one is isolated and\n * logged, exactly as the shared CRUD factory does.\n */\nasync function runGuardAfterSuccessCallbacks(\n callbacks: Array<{ guard: MutationGuard; metadata: Record<string, unknown> | null }>,\n input: Omit<MutationGuardAfterInput, 'metadata'>,\n): Promise<void> {\n for (const callback of callbacks) {\n if (!callback.guard.afterSuccess) continue\n try {\n await callback.guard.afterSuccess({\n ...input,\n metadata: callback.metadata ?? null,\n })\n } catch (err) {\n logger.error('Mutation guard afterSuccess failed', { guardId: callback.guard.id, err })\n reportBatchRouteError(err, 'sales.order_lines_guard_after_success_failed', {\n guardId: callback.guard.id,\n })\n }\n }\n}\n\nasync function resolveRequestContext(req: Request): Promise<CommandRuntimeContext> {\n const container = await createRequestContainer()\n const auth = await getAuthFromRequest(req)\n const { translate } = await resolveTranslations()\n\n if (!auth || !auth.tenantId) {\n throw new CrudHttpError(401, { error: translate('sales.documents.errors.unauthorized', 'Unauthorized') })\n }\n\n const scope = await resolveOrganizationScopeForRequest({ container, auth, request: req })\n if (scope?.selectionRejected) {\n throw new CrudHttpError(422, {\n error: translate(\n 'sales.documents.errors.organization_selection_invalid',\n 'Your selected organization is no longer available. Please re-select an organization and try again.',\n ),\n code: 'organization_selection_invalid',\n })\n }\n const organizationId = scope ? scope.selectedId : auth.orgId ?? null\n if (!organizationId) {\n throw new CrudHttpError(400, {\n error: translate('sales.documents.errors.organization_required', 'Organization context is required'),\n })\n }\n\n return {\n container,\n auth,\n organizationScope: scope,\n selectedOrganizationId: organizationId,\n organizationIds: scope?.filterIds ?? (auth.orgId ? [auth.orgId] : null),\n request: req,\n }\n}\n\nexport async function POST(req: Request) {\n try {\n const ctx = await resolveRequestContext(req)\n const { translate } = await resolveTranslations()\n const payload = await readJsonSafe(req, {})\n const scoped = withScopedPayload(payload ?? {}, ctx, translate)\n let input = orderLineBulkUpsertSchema.parse(scoped)\n\n const guardScope = {\n tenantId: ctx.auth?.tenantId ?? '',\n organizationId: ctx.selectedOrganizationId ?? ctx.auth?.orgId ?? null,\n userId: ctx.auth?.sub ?? '',\n resourceKind: RESOURCE_KIND,\n resourceId: input.orderId,\n operation: 'update' as const,\n requestMethod: req.method,\n requestHeaders: req.headers,\n } satisfies Omit<MutationGuardAfterInput, 'metadata'>\n const guardResult = await runGuards(ctx, {\n ...guardScope,\n mutationPayload: input as Record<string, unknown>,\n })\n if (!guardResult.ok) {\n return NextResponse.json(\n guardResult.errorBody ?? { error: translate('sales.documents.errors.guard_blocked', 'Operation blocked by guard') },\n { status: guardResult.errorStatus ?? 422 },\n )\n }\n if (guardResult.modifiedPayload) {\n input = orderLineBulkUpsertSchema.parse({ ...input, ...guardResult.modifiedPayload })\n }\n\n const commandBus = ctx.container.resolve('commandBus') as CommandBus\n const { result, logEntry } = await commandBus.execute<\n { body: z.infer<typeof orderLineBulkUpsertSchema> },\n BulkUpsertResult\n >(COMMAND_ID, { input: { body: input }, ctx })\n\n const orderId = result?.orderId ?? input.orderId\n const jsonResponse = NextResponse.json({ orderId, lineIds: result?.lineIds ?? [] })\n\n if (logEntry?.undoToken && logEntry?.id && logEntry?.commandId) {\n jsonResponse.headers.set(\n 'x-om-operation',\n serializeOperationMetadata({\n id: logEntry.id,\n undoToken: logEntry.undoToken,\n commandId: logEntry.commandId,\n actionLabel: logEntry.actionLabel ?? null,\n resourceKind: logEntry.resourceKind ?? RESOURCE_KIND,\n resourceId: logEntry.resourceId ?? orderId,\n executedAt: logEntry.createdAt instanceof Date\n ? logEntry.createdAt.toISOString()\n : typeof logEntry.createdAt === 'string'\n ? logEntry.createdAt\n : new Date().toISOString(),\n })\n )\n }\n\n if (guardResult.afterSuccessCallbacks.length) {\n await runGuardAfterSuccessCallbacks(guardResult.afterSuccessCallbacks, guardScope)\n }\n\n return jsonResponse\n } catch (err) {\n if (isCrudHttpError(err)) {\n return NextResponse.json(err.body, { status: err.status })\n }\n if (err instanceof z.ZodError) {\n const { translate } = await resolveTranslations()\n return NextResponse.json(\n {\n error: translate('sales.documents.items.bulk.errorInvalidInput', 'Invalid input'),\n details: localizeIssues(err.issues, translate),\n },\n { status: 400 },\n )\n }\n const interceptorRejection = getCommandInterceptorHttpRejection(err)\n if (interceptorRejection) {\n return NextResponse.json(interceptorRejection.body, { status: interceptorRejection.status })\n }\n const { translate } = await resolveTranslations()\n logger.error('sales.orders.lines.upsert_many failed', { err })\n reportBatchRouteError(err, 'sales.order_lines_batch_failed')\n return NextResponse.json(\n { error: translate('sales.documents.items.errorSaveBatch', 'Failed to save order lines.') },\n { status: 400 }\n )\n }\n}\n\nconst bulkUpsertResponseSchema = z.object({\n orderId: z.string().uuid(),\n lineIds: z.array(z.string().uuid()),\n})\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Sales',\n summary: 'Bulk upsert order lines',\n methods: {\n POST: {\n summary: 'Upsert and delete order lines in one pass',\n description:\n 'Writes a whole order line set in one aggregate load: entries carrying an id update that line, '\n + 'entries without one are appended, and deleteIds removes lines. The batch is all-or-nothing \u2014 '\n + 'any refused entry aborts it \u2014 and the order is renumbered 1..n, its totals recalculated and '\n + 'one undoable audit entry recorded.',\n requestBody: {\n contentType: 'application/json',\n schema: orderLineBulkUpsertSchema,\n },\n responses: [\n { status: 200, description: 'Batch applied', schema: bulkUpsertResponseSchema },\n { status: 400, description: 'Invalid payload', schema: z.object({ error: z.string(), details: z.array(z.unknown()).optional() }) },\n { status: 401, description: 'Unauthorized', schema: z.object({ error: z.string() }) },\n { status: 403, description: 'Forbidden', schema: z.object({ error: z.string() }) },\n { status: 404, description: 'Order or line not found', schema: z.object({ error: z.string() }) },\n { status: 409, description: 'Conflict detected', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n { status: 422, description: 'Selected organization is no longer available', schema: z.object({ error: z.string(), code: z.literal('organization_selection_invalid') }) },\n { status: 423, description: 'Record locked', schema: z.object({ error: z.string(), code: z.string().optional() }) },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
6
+ "names": ["translate"]
7
+ }