@12-apps/discounts 1.2.0 → 1.3.0

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.
@@ -2,6 +2,13 @@ import type { z } from "zod";
2
2
 
3
3
  import type { WireRequest, WireResponse, WireRoute } from "@12-apps/wiring";
4
4
 
5
+ import {
6
+ assertCollections,
7
+ assertTargetsOwned,
8
+ ForeignTargetError,
9
+ loadTargetGroups,
10
+ type DiscountableCollection,
11
+ } from "./collections";
5
12
  import { missingServerCopy, type DiscountsServerCopy } from "./copy";
6
13
  import { logWrite, observed, type DiscountsLogger } from "./logging";
7
14
  import { listDiscountsQuery } from "./mcp";
@@ -56,6 +63,18 @@ export interface DiscountsApiConfig {
56
63
  * `createFeatureLogger`-shaped child. See `./logging`.
57
64
  */
58
65
  logger: DiscountsLogger;
66
+ /**
67
+ * The host tables a discount may be pointed at — see `./collections`.
68
+ *
69
+ * OPTIONAL, and the omission is a real configuration rather than an
70
+ * oversight: a host that sells one undifferentiated thing has no catalog
71
+ * dimension to register, and ORDER-scoped promotions need none. What it
72
+ * costs is stated plainly rather than defaulted around — with no collection
73
+ * registered for a dimension, `GET /discounts/targets` reports nothing for
74
+ * it and the cross-tenant check on ITS ids does not run here, so the store
75
+ * remains responsible for refusing a foreign target.
76
+ */
77
+ collections?: readonly DiscountableCollection[];
59
78
  /**
60
79
  * The schema the list query is validated against. Optional ONLY because a
61
80
  * mechanical default is portable: the package's own, built from the search
@@ -72,9 +91,10 @@ function ok(body: unknown): WireResponse {
72
91
  }
73
92
 
74
93
  /** The `{ error, issues }` shape a form reads its per-input errors out of. */
75
- function fieldError(error: DiscountValidationError): WireResponse {
94
+ function fieldError(error: DiscountValidationError | ForeignTargetError): WireResponse {
95
+ const status = error instanceof DiscountValidationError ? error.status : 422;
76
96
  return {
77
- status: error.status,
97
+ status,
78
98
  body: { error: error.message, issues: { [error.field]: error.message } },
79
99
  };
80
100
  }
@@ -85,14 +105,22 @@ function acknowledged(id: string): WireResponse {
85
105
  }
86
106
 
87
107
  /**
88
- * Fold a validated body into what the store persists.
108
+ * Fold a validated body into what the store persists, and refuse a target that
109
+ * is not this tenant's.
89
110
  *
90
111
  * Both writes go through it, so the rules run exactly once per write and a
91
- * second caller cannot half-apply them.
112
+ * second caller cannot half-apply them. The ownership check is here rather than
113
+ * in each route for the same reason, and it runs AFTER the fold because the
114
+ * fold is what narrows the targets to the scope — checking the raw body would
115
+ * reject ids a COMBO-scoped write was about to drop anyway.
92
116
  */
93
- function writeFrom(body: unknown, copy: DiscountsServerCopy) {
117
+ async function prepareWrite(config: DiscountsApiConfig, clientId: string, body: unknown) {
94
118
  const input = toDiscountWriteInput(body as DiscountWriteBody);
95
- return { scalars: toDiscountScalars(input, copy), targets: targetsForScope(input) };
119
+ const write = { scalars: toDiscountScalars(input, config.copy), targets: targetsForScope(input) };
120
+ if (config.collections) {
121
+ await assertTargetsOwned(config.collections, clientId, write.targets, config.copy.foreignTarget);
122
+ }
123
+ return write;
96
124
  }
97
125
 
98
126
  /**
@@ -119,7 +147,8 @@ function endpoint(
119
147
  try {
120
148
  return await observedHandle(request);
121
149
  } catch (error) {
122
- if (error instanceof DiscountValidationError) return fieldError(error);
150
+ const refusal = error instanceof DiscountValidationError || error instanceof ForeignTargetError;
151
+ if (refusal) return fieldError(error);
123
152
  throw error;
124
153
  }
125
154
  };
@@ -144,6 +173,35 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
144
173
  };
145
174
  }
146
175
 
176
+ /**
177
+ * What an operator may point a discount at: every registered collection's rows,
178
+ * in one round trip.
179
+ *
180
+ * It exists so the FORM stops assembling its own reference data. The origin
181
+ * host's page side-loaded the tenant's whole category list and whole product
182
+ * list from two unrelated admin endpoints, at a page size it had to know
183
+ * (`?pageSize=500`), and gated its own render on both — three coupled decisions
184
+ * in a screen that only wanted "what can I tick".
185
+ *
186
+ * The route exists even with NO collection registered, answering an empty list.
187
+ * A route table that varied with config would make the advertised tool surface
188
+ * vary with it too, and "which endpoints does this package have" must be
189
+ * answerable without knowing how one host wired it.
190
+ *
191
+ * `discounts:write` rather than `discounts:read`: the grid needs none of this,
192
+ * only the editor does, and the narrower gate is the one that says so.
193
+ */
194
+ function targetsRoute(config: DiscountsApiConfig): DiscountRoute {
195
+ return {
196
+ method: "GET",
197
+ path: "/discounts/targets",
198
+ permission: DISCOUNTS_WRITE,
199
+ handle: endpoint(config, "GET /discounts/targets", async ({ actor }) =>
200
+ ok({ data: await loadTargetGroups(config.collections ?? [], actor.clientId) }),
201
+ ),
202
+ };
203
+ }
204
+
147
205
  function readRoute(config: DiscountsApiConfig): DiscountRoute {
148
206
  return {
149
207
  method: "GET",
@@ -164,7 +222,8 @@ function createRoute(config: DiscountsApiConfig): DiscountRoute {
164
222
  permission: DISCOUNTS_WRITE,
165
223
  handle: endpoint(config, "POST /discounts", async (request) => {
166
224
  const { actor, body } = request;
167
- const created = await config.store.create(actor.clientId, writeFrom(body, config.copy));
225
+ const write = await prepareWrite(config, actor.clientId, body);
226
+ const created = await config.store.create(actor.clientId, write);
168
227
  logWrite(config.logger, "created", request, created.id);
169
228
  return ok({ data: created });
170
229
  }),
@@ -179,7 +238,8 @@ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
179
238
  handle: endpoint(config, "PATCH /discounts/:id", async (request) => {
180
239
  const { actor, params, body } = request;
181
240
  const id = String(params.id);
182
- await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
241
+ const write = await prepareWrite(config, actor.clientId, body);
242
+ await config.store.update(actor.clientId, id, write);
183
243
  logWrite(config.logger, "re-stated", request, id);
184
244
  return acknowledged(id);
185
245
  }),
@@ -236,9 +296,14 @@ export function createApiDiscounts(config: DiscountsApiConfig): {
236
296
  );
237
297
  }
238
298
  assertLogger(config.logger);
299
+ assertCollections(config.collections ?? []);
239
300
  return {
240
301
  routes: [
241
302
  listRoute(config),
303
+ // BEFORE the `:id` read, because `/discounts/targets` would otherwise be
304
+ // matched by it in any router that resolves in declaration order — and
305
+ // the answer would be a 404 for a discount named "targets".
306
+ targetsRoute(config),
242
307
  readRoute(config),
243
308
  createRoute(config),
244
309
  updateRoute(config),
@@ -0,0 +1,102 @@
1
+ import type { DiscountTargetType } from "../engine/kinds";
2
+ import type { ComboRequirement } from "../engine/types";
3
+ import type { DiscountTargets } from "./validate";
4
+
5
+ /**
6
+ * The fold between what this package SPEAKS and what the schema STORES.
7
+ *
8
+ * The wire, the validator and the evaluator all speak id ARRAYS — `categoryIds`,
9
+ * `menuItemIds`, and a `ComboRequirement` carrying one of each. The partial
10
+ * stores by-value ROWS: `(target_type, target_id)`, with a nullable `slot_id`
11
+ * telling a scope target from a combo slot's.
12
+ *
13
+ * Both shapes are right for their side. Arrays are what a form binds to and
14
+ * what a matcher iterates; rows are what makes the schema shippable and the
15
+ * reverse read ("which rules touch this row") an index. What is NOT right is
16
+ * each host writing the translation by hand, because it is exactly the kind of
17
+ * mechanical mapping where a dropped `slotId` produces a combo whose slots have
18
+ * silently merged — a discount that still saves, still lists, and prices the
19
+ * wrong thing.
20
+ *
21
+ * So it lives here, in one place, tested in both directions. The host's store
22
+ * stays persistence: it hands these rows to Prisma and hands Prisma's rows back.
23
+ */
24
+
25
+ /** One row of `discount_targets`, minus the ids the database assigns. */
26
+ export interface DiscountTargetRow {
27
+ targetType: DiscountTargetType;
28
+ targetId: string;
29
+ }
30
+
31
+ /** One row of `discount_combo_slots`, with the targets that belong to it. */
32
+ export interface DiscountComboSlotRow {
33
+ /** The operator's order — also the order a card describes the combo in. */
34
+ position: number;
35
+ quantity: number;
36
+ targets: readonly DiscountTargetRow[];
37
+ }
38
+
39
+ /** Everything a write persists BESIDE the discount's own columns. */
40
+ export interface DiscountTargetRows {
41
+ /** What a CATEGORY- or ITEM-scoped rule covers (`slot_id IS NULL`). */
42
+ scopeTargets: readonly DiscountTargetRow[];
43
+ /** The combo's slots, each with its own targets. Empty unless COMBO-scoped. */
44
+ comboSlots: readonly DiscountComboSlotRow[];
45
+ }
46
+
47
+ function rowsFor(
48
+ categoryIds: readonly string[],
49
+ menuItemIds: readonly string[],
50
+ ): DiscountTargetRow[] {
51
+ return [
52
+ ...categoryIds.map((targetId) => ({ targetType: "CATEGORY" as const, targetId })),
53
+ ...menuItemIds.map((targetId) => ({ targetType: "ITEM" as const, targetId })),
54
+ ];
55
+ }
56
+
57
+ /**
58
+ * Fold a validated write's targets into the rows the schema stores.
59
+ *
60
+ * The slots keep their INDEX as `position` rather than being handed an
61
+ * arbitrary order: the list an operator built is the list a card reads out, and
62
+ * two slots of the same size are otherwise indistinguishable once stored.
63
+ */
64
+ export function toTargetRows(targets: DiscountTargets): DiscountTargetRows {
65
+ return {
66
+ scopeTargets: rowsFor(targets.categoryIds, targets.menuItemIds),
67
+ comboSlots: targets.comboRequirements.map((requirement, index) => ({
68
+ position: index,
69
+ quantity: requirement.quantity,
70
+ targets: rowsFor(requirement.categoryIds, requirement.menuItemIds),
71
+ })),
72
+ };
73
+ }
74
+
75
+ function idsOf(rows: readonly DiscountTargetRow[], type: DiscountTargetType): string[] {
76
+ return rows.filter((row) => row.targetType === type).map((row) => row.targetId);
77
+ }
78
+
79
+ /**
80
+ * Fold stored rows back into the id arrays a record and a rule are built from.
81
+ *
82
+ * The slots are sorted by `position` HERE rather than left to the caller's
83
+ * `orderBy`: the order is part of what the combo means, and a store that
84
+ * forgot the clause would produce a subtly different offer on every read
85
+ * depending on how the database felt like returning the rows.
86
+ */
87
+ export function fromTargetRows(rows: DiscountTargetRows): {
88
+ categoryIds: string[];
89
+ menuItemIds: string[];
90
+ comboRequirements: ComboRequirement[];
91
+ } {
92
+ const slots = [...rows.comboSlots].sort((left, right) => left.position - right.position);
93
+ return {
94
+ categoryIds: idsOf(rows.scopeTargets, "CATEGORY"),
95
+ menuItemIds: idsOf(rows.scopeTargets, "ITEM"),
96
+ comboRequirements: slots.map((slot) => ({
97
+ menuItemIds: idsOf(slot.targets, "ITEM"),
98
+ categoryIds: idsOf(slot.targets, "CATEGORY"),
99
+ quantity: slot.quantity,
100
+ })),
101
+ };
102
+ }