@voyantjs/promotions 0.28.3

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.
Files changed (44) hide show
  1. package/README.md +38 -0
  2. package/dist/events.d.ts +38 -0
  3. package/dist/events.d.ts.map +1 -0
  4. package/dist/events.js +25 -0
  5. package/dist/index.d.ts +11 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/routes-shared.d.ts +14 -0
  9. package/dist/routes-shared.d.ts.map +1 -0
  10. package/dist/routes-shared.js +3 -0
  11. package/dist/routes.d.ts +345 -0
  12. package/dist/routes.d.ts.map +1 -0
  13. package/dist/routes.js +55 -0
  14. package/dist/schema.d.ts +655 -0
  15. package/dist/schema.d.ts.map +1 -0
  16. package/dist/schema.js +126 -0
  17. package/dist/service-booking-confirmed.d.ts +77 -0
  18. package/dist/service-booking-confirmed.d.ts.map +1 -0
  19. package/dist/service-booking-confirmed.js +134 -0
  20. package/dist/service-boundary-scheduler.d.ts +85 -0
  21. package/dist/service-boundary-scheduler.d.ts.map +1 -0
  22. package/dist/service-boundary-scheduler.js +141 -0
  23. package/dist/service-catalog-evaluator.d.ts +22 -0
  24. package/dist/service-catalog-evaluator.d.ts.map +1 -0
  25. package/dist/service-catalog-evaluator.js +33 -0
  26. package/dist/service-catalog-plane-promotions.d.ts +72 -0
  27. package/dist/service-catalog-plane-promotions.d.ts.map +1 -0
  28. package/dist/service-catalog-plane-promotions.js +119 -0
  29. package/dist/service-evaluator.d.ts +111 -0
  30. package/dist/service-evaluator.d.ts.map +1 -0
  31. package/dist/service-evaluator.js +264 -0
  32. package/dist/service-storefront.d.ts +40 -0
  33. package/dist/service-storefront.d.ts.map +1 -0
  34. package/dist/service-storefront.js +146 -0
  35. package/dist/service.d.ts +120 -0
  36. package/dist/service.d.ts.map +1 -0
  37. package/dist/service.js +296 -0
  38. package/dist/validation.d.ts +140 -0
  39. package/dist/validation.d.ts.map +1 -0
  40. package/dist/validation.js +134 -0
  41. package/dist/workflow-bulk-reindex.d.ts +55 -0
  42. package/dist/workflow-bulk-reindex.d.ts.map +1 -0
  43. package/dist/workflow-bulk-reindex.js +58 -0
  44. package/package.json +120 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Storefront resolvers — populate the previously-empty
3
+ * `/v1/public/products/:productId/offers` and `/v1/public/offers/:slug`
4
+ * endpoints in `@voyantjs/storefront` with real data.
5
+ *
6
+ * Wire via:
7
+ *
8
+ * const storefrontModule = createStorefrontHonoModule({
9
+ * offers: createPromotionsStorefrontResolvers(),
10
+ * })
11
+ *
12
+ * Per docs/architecture/promotions-architecture.md §8.
13
+ *
14
+ * V1 limitations:
15
+ * - Storefront calls don't carry an audience / market in the request
16
+ * context, so the resolver only filters by `products` + `global`
17
+ * scopes (the catalog plane handles audience-scoped projection).
18
+ * `markets` and `audiences` scoped offers don't appear in this
19
+ * listing endpoint.
20
+ * - Locale-aware offer names: the schema doesn't store translations
21
+ * yet (single-locale offer names per §12.1); `locale` is accepted
22
+ * but ignored. A `promotional_offer_translations` table mirrors
23
+ * `destinations_translations` if/when needed.
24
+ * - `applicableDepartureIds`: always empty per §12.7 — departure-
25
+ * scoped offers aren't modelled in v1.
26
+ * - Only auto-applied offers (no code) appear in `listApplicableOffers`;
27
+ * code-gated offers are still queryable via `getOfferBySlug`.
28
+ */
29
+ import { and, eq, inArray, isNull, lte, or, sql } from "drizzle-orm";
30
+ import { promotionalOfferProducts, promotionalOffers } from "./schema.js";
31
+ export function createPromotionsStorefrontResolvers() {
32
+ return {
33
+ async listApplicableOffers(input) {
34
+ const db = resolveDb(input);
35
+ if (!db)
36
+ return [];
37
+ const now = new Date();
38
+ // Active + currently-valid + auto-applied (no code) offers.
39
+ const baseRows = await db
40
+ .select()
41
+ .from(promotionalOffers)
42
+ .where(and(eq(promotionalOffers.active, true), isNull(promotionalOffers.code), or(isNull(promotionalOffers.validFrom), lte(promotionalOffers.validFrom, now)), or(isNull(promotionalOffers.validUntil), sql `${promotionalOffers.validUntil} >= ${now}`)));
43
+ if (baseRows.length === 0)
44
+ return [];
45
+ // Restrict to offers that match the product: either `global` scope
46
+ // (always matches) OR a product-shaped scope whose materialized
47
+ // link table contains this product.
48
+ const productLinkRows = await db
49
+ .select({ offerId: promotionalOfferProducts.offerId })
50
+ .from(promotionalOfferProducts)
51
+ .where(and(eq(promotionalOfferProducts.productId, input.productId), inArray(promotionalOfferProducts.offerId, baseRows.map((r) => r.id))));
52
+ const offersMatchingProduct = new Set(productLinkRows.map((r) => r.offerId));
53
+ const productLinkLookups = await loadApplicableProductIds(db, baseRows.map((r) => r.id));
54
+ const out = [];
55
+ for (const offer of baseRows) {
56
+ if (!matchesProduct(offer.scope, offersMatchingProduct.has(offer.id)))
57
+ continue;
58
+ out.push(toStorefrontDto(offer, productLinkLookups.get(offer.id) ?? []));
59
+ }
60
+ return out;
61
+ },
62
+ async getOfferBySlug(input) {
63
+ const db = resolveDb(input);
64
+ if (!db)
65
+ return null;
66
+ const rows = await db
67
+ .select()
68
+ .from(promotionalOffers)
69
+ .where(and(eq(promotionalOffers.slug, input.slug), eq(promotionalOffers.active, true)))
70
+ .limit(1);
71
+ const offer = rows[0];
72
+ if (!offer)
73
+ return null;
74
+ const links = await loadApplicableProductIds(db, [offer.id]);
75
+ return toStorefrontDto(offer, links.get(offer.id) ?? []);
76
+ },
77
+ };
78
+ }
79
+ function resolveDb(input) {
80
+ return input.db ?? undefined;
81
+ }
82
+ function matchesProduct(scope, inLinkTable) {
83
+ switch (scope.kind) {
84
+ case "global":
85
+ return true;
86
+ case "products":
87
+ case "categories":
88
+ case "destinations":
89
+ return inLinkTable;
90
+ // markets / audiences scopes don't render via this endpoint in v1
91
+ // (no audience / market in the request context). They're still
92
+ // honored by the catalog projection (PR3) when products are indexed.
93
+ case "markets":
94
+ case "audiences":
95
+ return false;
96
+ }
97
+ }
98
+ async function loadApplicableProductIds(db, offerIds) {
99
+ if (offerIds.length === 0)
100
+ return new Map();
101
+ const rows = await db
102
+ .select({
103
+ offerId: promotionalOfferProducts.offerId,
104
+ productId: promotionalOfferProducts.productId,
105
+ })
106
+ .from(promotionalOfferProducts)
107
+ .where(inArray(promotionalOfferProducts.offerId, offerIds));
108
+ const out = new Map();
109
+ for (const row of rows) {
110
+ const list = out.get(row.offerId) ?? [];
111
+ list.push(row.productId);
112
+ out.set(row.offerId, list);
113
+ }
114
+ return out;
115
+ }
116
+ function toStorefrontDto(offer, applicableProductIds) {
117
+ return {
118
+ id: offer.id,
119
+ name: offer.name,
120
+ slug: offer.slug,
121
+ description: offer.description,
122
+ discountType: offer.discountType,
123
+ // The DTO carries `discountValue` as a string for both flavors.
124
+ // Percentage offers store the percent ("20"); fixed_amount offers
125
+ // store the cents amount as a string ("500" for $5.00).
126
+ discountValue: offer.discountType === "percentage"
127
+ ? (offer.discountPercent ?? "0")
128
+ : String(offer.discountAmountCents ?? 0),
129
+ currency: offer.currency,
130
+ applicableProductIds,
131
+ // V1: departure-scoped offers aren't modelled (per §12.7).
132
+ applicableDepartureIds: [],
133
+ validFrom: offer.validFrom?.toISOString() ?? null,
134
+ validTo: offer.validUntil?.toISOString() ?? null,
135
+ minTravelers: typeof offer.conditions === "object" && offer.conditions != null
136
+ ? (offer.conditions.minPax ?? null)
137
+ : null,
138
+ // V1: no merchandising images on offers (per §2 non-goals).
139
+ imageMobileUrl: null,
140
+ imageDesktopUrl: null,
141
+ stackable: offer.stackable,
142
+ createdAt: offer.createdAt.toISOString(),
143
+ updatedAt: offer.updatedAt.toISOString(),
144
+ };
145
+ }
146
+ export const __test__ = { matchesProduct, toStorefrontDto };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Promotions service — CRUD over `promotional_offers` plus link-table
3
+ * materialization for product-shaped scopes.
4
+ *
5
+ * Per docs/architecture/promotions-architecture.md §13 (PR1 scope):
6
+ * - listOffers / getOfferById / createOffer / updateOffer / archiveOffer / deleteOffer
7
+ * - recomputeOfferLinks (rebuilds `promotional_offer_products` from current scope)
8
+ * - emit `promotion.changed` (when an `eventBus` is supplied via the runtime arg)
9
+ *
10
+ * The evaluator (PR2) and catalog-plane / booking-engine / scheduler wiring
11
+ * (PR3 + PR4 + PR3.boundary) are NOT in this PR.
12
+ *
13
+ * Cross-module reads:
14
+ * - `categories` scope expands via `product_category_products` (in @voyantjs/products)
15
+ * - `destinations` scope expands via `product_destinations` (in @voyantjs/products)
16
+ */
17
+ import type { EventBus } from "@voyantjs/core";
18
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
19
+ import { type PromotionChangedSource } from "./events.js";
20
+ import { type PromotionalOffer } from "./schema.js";
21
+ import type { InsertPromotionalOffer, PromotionalOfferListQuery, PromotionalOfferScope, UpdatePromotionalOffer } from "./validation.js";
22
+ export interface OfferMutationRuntime {
23
+ /**
24
+ * Optional event bus. When wired, every CRUD path emits
25
+ * `promotion.changed` so the catalog bridge reindexes affected products.
26
+ * Per docs/architecture/promotions-architecture.md §9.1.
27
+ */
28
+ eventBus?: EventBus;
29
+ /**
30
+ * Override the emission `source`. `createOffer` / `deleteOffer` default
31
+ * to `"created"` / `"deleted"`; `updateOffer` / `archiveOffer` default
32
+ * to `"updated"`. The boundary scheduler (PR3) overrides with
33
+ * `"expired"` for `validUntil` crossings.
34
+ */
35
+ source?: PromotionChangedSource;
36
+ }
37
+ /**
38
+ * Expand an offer's scope to the product set it covers. Used by
39
+ * `recomputeOfferLinks` and by the event emitter to populate
40
+ * `affected.productIds`.
41
+ *
42
+ * Returns `null` for slice-shaped scopes (`global`, `markets`, `audiences`)
43
+ * to signal that the link table should be empty AND that the event payload
44
+ * should fall back to `affected: { kind: "all" }` — these scopes can match
45
+ * an unbounded product set and we don't enumerate them at write time
46
+ * (per §9.1's resolution rules).
47
+ */
48
+ export declare function resolveScopeProductIds(db: PostgresJsDatabase, scope: PromotionalOfferScope): Promise<string[] | null>;
49
+ /**
50
+ * Rebuild the `promotional_offer_products` rows for an offer from its
51
+ * current scope. Idempotent: deletes any prior rows, inserts the
52
+ * freshly-resolved set. Slice-shaped scopes leave the table empty.
53
+ */
54
+ export declare function recomputeOfferLinks(db: PostgresJsDatabase, offerId: string, scope: PromotionalOfferScope): Promise<{
55
+ productIds: string[] | null;
56
+ }>;
57
+ declare function listOffers(db: PostgresJsDatabase, query: PromotionalOfferListQuery): Promise<{
58
+ data: {
59
+ id: string;
60
+ name: string;
61
+ slug: string;
62
+ description: string | null;
63
+ discountType: "percentage" | "fixed_amount";
64
+ discountPercent: string | null;
65
+ discountAmountCents: number | null;
66
+ currency: string | null;
67
+ scope: {
68
+ kind: "global";
69
+ } | {
70
+ kind: "products";
71
+ productIds: string[];
72
+ } | {
73
+ kind: "categories";
74
+ categoryIds: string[];
75
+ } | {
76
+ kind: "destinations";
77
+ destinationIds: string[];
78
+ } | {
79
+ kind: "markets";
80
+ marketIds: string[];
81
+ } | {
82
+ kind: "audiences";
83
+ audiences: ("staff" | "customer" | "partner" | "supplier")[];
84
+ };
85
+ conditions: {
86
+ minPax?: number | undefined;
87
+ };
88
+ validFrom: Date | null;
89
+ validUntil: Date | null;
90
+ code: string | null;
91
+ stackable: boolean;
92
+ active: boolean;
93
+ metadata: unknown;
94
+ createdAt: Date;
95
+ updatedAt: Date;
96
+ }[];
97
+ total: number;
98
+ limit: number;
99
+ offset: number;
100
+ }>;
101
+ declare function getOfferById(db: PostgresJsDatabase, id: string): Promise<PromotionalOffer | null>;
102
+ declare function createOffer(db: PostgresJsDatabase, input: InsertPromotionalOffer, runtime?: OfferMutationRuntime): Promise<PromotionalOffer>;
103
+ declare function updateOffer(db: PostgresJsDatabase, id: string, patch: UpdatePromotionalOffer, runtime?: OfferMutationRuntime): Promise<PromotionalOffer | null>;
104
+ declare function archiveOffer(db: PostgresJsDatabase, id: string, runtime?: OfferMutationRuntime): Promise<PromotionalOffer | null>;
105
+ declare function deleteOffer(db: PostgresJsDatabase, id: string, runtime?: OfferMutationRuntime): Promise<{
106
+ id: string;
107
+ } | null>;
108
+ export declare const promotionsService: {
109
+ listOffers: typeof listOffers;
110
+ getOfferById: typeof getOfferById;
111
+ createOffer: typeof createOffer;
112
+ updateOffer: typeof updateOffer;
113
+ archiveOffer: typeof archiveOffer;
114
+ deleteOffer: typeof deleteOffer;
115
+ recomputeOfferLinks: typeof recomputeOfferLinks;
116
+ resolveScopeProductIds: typeof resolveScopeProductIds;
117
+ };
118
+ export type PromotionsService = typeof promotionsService;
119
+ export {};
120
+ //# sourceMappingURL=service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAA;AAG9C,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,OAAO,EAIL,KAAK,sBAAsB,EAC5B,MAAM,aAAa,CAAA;AACpB,OAAO,EAEL,KAAK,gBAAgB,EAItB,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EACV,sBAAsB,EACtB,yBAAyB,EACzB,qBAAqB,EACrB,sBAAsB,EACvB,MAAM,iBAAiB,CAAA;AAExB,MAAM,WAAW,oBAAoB;IACnC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,QAAQ,CAAA;IACnB;;;;;OAKG;IACH,MAAM,CAAC,EAAE,sBAAsB,CAAA;CAChC;AAUD;;;;;;;;;;GAUG;AACH,wBAAsB,sBAAsB,CAC1C,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,qBAAqB,GAC3B,OAAO,CAAC,MAAM,EAAE,GAAG,IAAI,CAAC,CAyB1B;AAED;;;;GAIG;AACH,wBAAsB,mBAAmB,CACvC,EAAE,EAAE,kBAAkB,EACtB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,qBAAqB,GAC3B,OAAO,CAAC;IAAE,UAAU,EAAE,MAAM,EAAE,GAAG,IAAI,CAAA;CAAE,CAAC,CAS1C;AA8ED,iBAAe,UAAU,CAAC,EAAE,EAAE,kBAAkB,EAAE,KAAK,EAAE,yBAAyB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwBjF;AAED,iBAAe,YAAY,CAAC,EAAE,EAAE,kBAAkB,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAOhG;AAED,iBAAe,WAAW,CACxB,EAAE,EAAE,kBAAkB,EACtB,KAAK,EAAE,sBAAsB,EAC7B,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,gBAAgB,CAAC,CAa3B;AAED,iBAAe,WAAW,CACxB,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,sBAAsB,EAC7B,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAsClC;AAED,iBAAe,YAAY,CACzB,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,gBAAgB,GAAG,IAAI,CAAC,CAgBlC;AAED,iBAAe,WAAW,CACxB,EAAE,EAAE,kBAAkB,EACtB,EAAE,EAAE,MAAM,EACV,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC;IAAE,EAAE,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAiChC;AAED,eAAO,MAAM,iBAAiB;;;;;;;;;CAS7B,CAAA;AAED,MAAM,MAAM,iBAAiB,GAAG,OAAO,iBAAiB,CAAA"}
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Promotions service — CRUD over `promotional_offers` plus link-table
3
+ * materialization for product-shaped scopes.
4
+ *
5
+ * Per docs/architecture/promotions-architecture.md §13 (PR1 scope):
6
+ * - listOffers / getOfferById / createOffer / updateOffer / archiveOffer / deleteOffer
7
+ * - recomputeOfferLinks (rebuilds `promotional_offer_products` from current scope)
8
+ * - emit `promotion.changed` (when an `eventBus` is supplied via the runtime arg)
9
+ *
10
+ * The evaluator (PR2) and catalog-plane / booking-engine / scheduler wiring
11
+ * (PR3 + PR4 + PR3.boundary) are NOT in this PR.
12
+ *
13
+ * Cross-module reads:
14
+ * - `categories` scope expands via `product_category_products` (in @voyantjs/products)
15
+ * - `destinations` scope expands via `product_destinations` (in @voyantjs/products)
16
+ */
17
+ import { productCategoryProducts, productDestinations } from "@voyantjs/products/schema";
18
+ import { and, count, desc, eq, inArray, sql } from "drizzle-orm";
19
+ import { PROMOTION_CHANGED_EVENT, } from "./events.js";
20
+ import { promotionalOfferProducts, promotionalOfferRedemptions, promotionalOffers, } from "./schema.js";
21
+ /** Fields whose change does NOT affect projection or evaluation — safe to skip emit. */
22
+ const NON_PROJECTION_FIELDS = new Set(["description", "metadata"]);
23
+ function shouldEmitForUpdate(patch) {
24
+ const keys = Object.keys(patch);
25
+ return keys.some((key) => !NON_PROJECTION_FIELDS.has(key));
26
+ }
27
+ /**
28
+ * Expand an offer's scope to the product set it covers. Used by
29
+ * `recomputeOfferLinks` and by the event emitter to populate
30
+ * `affected.productIds`.
31
+ *
32
+ * Returns `null` for slice-shaped scopes (`global`, `markets`, `audiences`)
33
+ * to signal that the link table should be empty AND that the event payload
34
+ * should fall back to `affected: { kind: "all" }` — these scopes can match
35
+ * an unbounded product set and we don't enumerate them at write time
36
+ * (per §9.1's resolution rules).
37
+ */
38
+ export async function resolveScopeProductIds(db, scope) {
39
+ switch (scope.kind) {
40
+ case "products":
41
+ return [...new Set(scope.productIds)];
42
+ case "categories": {
43
+ if (scope.categoryIds.length === 0)
44
+ return [];
45
+ const rows = await db
46
+ .selectDistinct({ productId: productCategoryProducts.productId })
47
+ .from(productCategoryProducts)
48
+ .where(inArray(productCategoryProducts.categoryId, scope.categoryIds));
49
+ return rows.map((r) => r.productId);
50
+ }
51
+ case "destinations": {
52
+ if (scope.destinationIds.length === 0)
53
+ return [];
54
+ const rows = await db
55
+ .selectDistinct({ productId: productDestinations.productId })
56
+ .from(productDestinations)
57
+ .where(inArray(productDestinations.destinationId, scope.destinationIds));
58
+ return rows.map((r) => r.productId);
59
+ }
60
+ case "global":
61
+ case "markets":
62
+ case "audiences":
63
+ return null;
64
+ }
65
+ }
66
+ /**
67
+ * Rebuild the `promotional_offer_products` rows for an offer from its
68
+ * current scope. Idempotent: deletes any prior rows, inserts the
69
+ * freshly-resolved set. Slice-shaped scopes leave the table empty.
70
+ */
71
+ export async function recomputeOfferLinks(db, offerId, scope) {
72
+ const productIds = await resolveScopeProductIds(db, scope);
73
+ await db.delete(promotionalOfferProducts).where(eq(promotionalOfferProducts.offerId, offerId));
74
+ if (productIds && productIds.length > 0) {
75
+ await db
76
+ .insert(promotionalOfferProducts)
77
+ .values(productIds.map((productId) => ({ offerId, productId })));
78
+ }
79
+ return { productIds };
80
+ }
81
+ function toAffected(productIds) {
82
+ if (productIds === null)
83
+ return { kind: "all" };
84
+ return { kind: "products", productIds };
85
+ }
86
+ function unionAffectedProductIds(previousProductIds, nextProductIds) {
87
+ if (previousProductIds === null || nextProductIds === null)
88
+ return null;
89
+ return [...new Set([...previousProductIds, ...nextProductIds])];
90
+ }
91
+ async function emitChange(runtime, payload) {
92
+ const eventBus = runtime.eventBus;
93
+ if (!eventBus)
94
+ return;
95
+ await eventBus.emit(PROMOTION_CHANGED_EVENT, payload, {
96
+ category: "domain",
97
+ source: "service",
98
+ });
99
+ }
100
+ function normalizeCode(code) {
101
+ if (code == null)
102
+ return null;
103
+ return code.toLowerCase();
104
+ }
105
+ function toRowValues(input) {
106
+ return {
107
+ name: input.name,
108
+ slug: input.slug,
109
+ description: input.description ?? null,
110
+ discountType: input.discountType,
111
+ discountPercent: input.discountPercent != null ? String(input.discountPercent) : null,
112
+ discountAmountCents: input.discountAmountCents ?? null,
113
+ currency: input.currency ?? null,
114
+ scope: input.scope,
115
+ conditions: input.conditions ?? {},
116
+ validFrom: input.validFrom ?? null,
117
+ validUntil: input.validUntil ?? null,
118
+ code: normalizeCode(input.code),
119
+ stackable: input.stackable ?? false,
120
+ active: input.active ?? true,
121
+ metadata: input.metadata ?? null,
122
+ };
123
+ }
124
+ function toUpdateValues(patch) {
125
+ const out = {
126
+ updatedAt: new Date(),
127
+ };
128
+ if (patch.name !== undefined)
129
+ out.name = patch.name;
130
+ if (patch.slug !== undefined)
131
+ out.slug = patch.slug;
132
+ if (patch.description !== undefined)
133
+ out.description = patch.description ?? null;
134
+ if (patch.discountType !== undefined)
135
+ out.discountType = patch.discountType;
136
+ if (patch.discountPercent !== undefined) {
137
+ out.discountPercent = patch.discountPercent != null ? String(patch.discountPercent) : null;
138
+ }
139
+ if (patch.discountAmountCents !== undefined) {
140
+ out.discountAmountCents = patch.discountAmountCents ?? null;
141
+ }
142
+ if (patch.currency !== undefined)
143
+ out.currency = patch.currency ?? null;
144
+ if (patch.scope !== undefined)
145
+ out.scope = patch.scope;
146
+ if (patch.conditions !== undefined)
147
+ out.conditions = patch.conditions;
148
+ if (patch.validFrom !== undefined)
149
+ out.validFrom = patch.validFrom ?? null;
150
+ if (patch.validUntil !== undefined)
151
+ out.validUntil = patch.validUntil ?? null;
152
+ if (patch.code !== undefined)
153
+ out.code = normalizeCode(patch.code);
154
+ if (patch.stackable !== undefined)
155
+ out.stackable = patch.stackable;
156
+ if (patch.active !== undefined)
157
+ out.active = patch.active;
158
+ if (patch.metadata !== undefined)
159
+ out.metadata = patch.metadata ?? null;
160
+ return out;
161
+ }
162
+ async function listOffers(db, query) {
163
+ const where = [];
164
+ if (query.active !== undefined)
165
+ where.push(eq(promotionalOffers.active, query.active));
166
+ if (query.code !== undefined)
167
+ where.push(eq(promotionalOffers.code, query.code.toLowerCase()));
168
+ const filter = where.length > 0 ? and(...where) : undefined;
169
+ const limit = query.limit ?? 50;
170
+ const offset = query.offset ?? 0;
171
+ const [totalRow] = await db
172
+ .select({ total: count() })
173
+ .from(promotionalOffers)
174
+ .where(filter ?? sql `true`);
175
+ const total = totalRow?.total ?? 0;
176
+ const data = await db
177
+ .select()
178
+ .from(promotionalOffers)
179
+ .where(filter ?? sql `true`)
180
+ .orderBy(desc(promotionalOffers.createdAt))
181
+ .limit(limit)
182
+ .offset(offset);
183
+ return { data, total, limit, offset };
184
+ }
185
+ async function getOfferById(db, id) {
186
+ const [row] = await db
187
+ .select()
188
+ .from(promotionalOffers)
189
+ .where(eq(promotionalOffers.id, id))
190
+ .limit(1);
191
+ return row ?? null;
192
+ }
193
+ async function createOffer(db, input, runtime = {}) {
194
+ const [row] = await db.insert(promotionalOffers).values(toRowValues(input)).returning();
195
+ if (!row)
196
+ throw new Error("createOffer: insert returned no row");
197
+ const { productIds } = await recomputeOfferLinks(db, row.id, input.scope);
198
+ await emitChange(runtime, {
199
+ offerId: row.id,
200
+ source: runtime.source ?? "created",
201
+ affected: toAffected(productIds),
202
+ });
203
+ return row;
204
+ }
205
+ async function updateOffer(db, id, patch, runtime = {}) {
206
+ const updateValues = toUpdateValues(patch);
207
+ const previousScope = patch.scope !== undefined && shouldEmitForUpdate(patch)
208
+ ? (await getOfferById(db, id))?.scope
209
+ : null;
210
+ if (patch.scope !== undefined && previousScope === undefined)
211
+ return null;
212
+ const [row] = await db
213
+ .update(promotionalOffers)
214
+ .set(updateValues)
215
+ .where(eq(promotionalOffers.id, id))
216
+ .returning();
217
+ if (!row)
218
+ return null;
219
+ // Re-materialize links if the scope changed. The link table reflects
220
+ // the current scope at all times, so any scope edit (including a
221
+ // category-id list edit) requires a rebuild.
222
+ let productIds;
223
+ if (patch.scope !== undefined) {
224
+ productIds = (await recomputeOfferLinks(db, id, patch.scope)).productIds;
225
+ }
226
+ else {
227
+ productIds = await resolveScopeProductIds(db, row.scope);
228
+ }
229
+ if (shouldEmitForUpdate(patch)) {
230
+ const affectedProductIds = patch.scope !== undefined && previousScope
231
+ ? unionAffectedProductIds(await resolveScopeProductIds(db, previousScope), productIds)
232
+ : productIds;
233
+ await emitChange(runtime, {
234
+ offerId: row.id,
235
+ source: runtime.source ?? "updated",
236
+ affected: toAffected(affectedProductIds),
237
+ });
238
+ }
239
+ return row;
240
+ }
241
+ async function archiveOffer(db, id, runtime = {}) {
242
+ const [row] = await db
243
+ .update(promotionalOffers)
244
+ .set({ active: false, updatedAt: new Date() })
245
+ .where(eq(promotionalOffers.id, id))
246
+ .returning();
247
+ if (!row)
248
+ return null;
249
+ const productIds = await resolveScopeProductIds(db, row.scope);
250
+ await emitChange(runtime, {
251
+ offerId: row.id,
252
+ source: runtime.source ?? "updated",
253
+ affected: toAffected(productIds),
254
+ });
255
+ return row;
256
+ }
257
+ async function deleteOffer(db, id, runtime = {}) {
258
+ // Check redemptions FIRST so the caller gets a clearer error than the raw
259
+ // FK-violation that the RESTRICT would surface from the delete attempt.
260
+ const [redemptionCountRow] = await db
261
+ .select({ total: count() })
262
+ .from(promotionalOfferRedemptions)
263
+ .where(eq(promotionalOfferRedemptions.offerId, id));
264
+ const redemptionCount = redemptionCountRow?.total ?? 0;
265
+ if (redemptionCount > 0) {
266
+ throw new Error(`cannot delete offer ${id}: ${redemptionCount} redemption(s) exist; archive (set active = false) instead`);
267
+ }
268
+ // Capture the resolved product set BEFORE delete so we can emit the
269
+ // affected list after CASCADE wipes `promotional_offer_products`.
270
+ const existing = await getOfferById(db, id);
271
+ if (!existing)
272
+ return null;
273
+ const productIds = await resolveScopeProductIds(db, existing.scope);
274
+ const [deleted] = await db
275
+ .delete(promotionalOffers)
276
+ .where(eq(promotionalOffers.id, id))
277
+ .returning({ id: promotionalOffers.id });
278
+ if (!deleted)
279
+ return null;
280
+ await emitChange(runtime, {
281
+ offerId: deleted.id,
282
+ source: runtime.source ?? "deleted",
283
+ affected: toAffected(productIds),
284
+ });
285
+ return deleted;
286
+ }
287
+ export const promotionsService = {
288
+ listOffers,
289
+ getOfferById,
290
+ createOffer,
291
+ updateOffer,
292
+ archiveOffer,
293
+ deleteOffer,
294
+ recomputeOfferLinks,
295
+ resolveScopeProductIds,
296
+ };