@12-apps/discounts 1.1.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.
@@ -0,0 +1,135 @@
1
+ import type { WireRequest, WireResponse } from "@12-apps/wiring";
2
+
3
+ import { ForeignTargetError } from "./collections";
4
+ import type { DiscountsActor } from "./routes";
5
+ import { DiscountValidationError } from "./validate";
6
+
7
+ /**
8
+ * What this surface says out loud, and to whom (FUT-244).
9
+ *
10
+ * The manifest declares `observability: { namespace: "discounts" }`, which is a
11
+ * PROMISE: a discounts failure files under `discounts` rather than under the
12
+ * host app or nowhere. Declaring it bought nothing on its own — the binder
13
+ * built the namespaced logger, hung it on `assembled.loggers`, and the package
14
+ * never took it, so for the whole of 1.0.x a refused write and a store that
15
+ * threw were equally silent. This module is the other half.
16
+ *
17
+ * ## Why every line is a STRING, and never an object
18
+ *
19
+ * A host's `LoggerPort` is a Winston child in practice, whose formatter runs
20
+ * `util.inspect(…, { depth: 5 })` over any extra argument. That is not a
21
+ * theoretical leak in this org: a provider error retains the provider's parsed
22
+ * response body, buyer name, e-mail and CPF included, so passing the cause as a
23
+ * second argument is precisely how a buyer's details reach a third party.
24
+ * {@link errorText} folds the cause into the sentence instead, which is the
25
+ * same call the origin host's own `errorText` makes and for the same reason.
26
+ *
27
+ * ## Why the sentences here are NOT copy config
28
+ *
29
+ * Everything below is written for a DEVELOPER reading a log, so it is English
30
+ * and it is the package's own — the copy-portability rule governs what a USER
31
+ * reads. Nothing logged here is shown to anyone; the operator's sentence comes
32
+ * from {@link DiscountsServerCopy} and travels in the response body.
33
+ */
34
+
35
+ /**
36
+ * The three methods a host's logger must offer — the structural twin of
37
+ * `@12-apps/wiring`'s `LoggerPort` and of `createFeatureLogger`'s return.
38
+ *
39
+ * Declared here rather than imported so that the wiring contract stays a
40
+ * type-only devDependency: a host on no wiring at all still satisfies this by
41
+ * passing any Winston/pino child, and a host on wiring passes
42
+ * `assembled.loggers["@12-apps/discounts"]` unchanged.
43
+ */
44
+ export interface DiscountsLogger {
45
+ info(message: string): void;
46
+ warn(message: string): void;
47
+ error(message: string): void;
48
+ }
49
+
50
+ /**
51
+ * The form input a refusal names, or null when the throw was not one.
52
+ *
53
+ * Two classes rather than one shared base: a validation failure carries its own
54
+ * HTTP status and a foreign target is always a 422, and folding them into one
55
+ * error type to save this function would put a status field on a class that has
56
+ * exactly one. What they share is the FIELD, which is the only half a log line
57
+ * wants.
58
+ */
59
+ function refusedField(error: unknown): string | null {
60
+ if (error instanceof DiscountValidationError) return error.field;
61
+ if (error instanceof ForeignTargetError) return error.field;
62
+ return null;
63
+ }
64
+
65
+ /** The cause as a log line — never the object. See the module note. */
66
+ export function errorText(error: unknown): string {
67
+ return error instanceof Error ? error.message : String(error);
68
+ }
69
+
70
+ /**
71
+ * What a line may name: the tenant, the route, the discount, the field, the
72
+ * status. Never a body, never a coupon code, never a name an operator typed —
73
+ * a log line is read by whoever can read the log, and none of those are needed
74
+ * to find the failure.
75
+ */
76
+ function subject(route: string, request: WireRequest<DiscountsActor>): string {
77
+ const id = request.params.id;
78
+ return `${route}${id === undefined ? "" : ` [${id}]`} for tenant ${request.actor.clientId}`;
79
+ }
80
+
81
+ /**
82
+ * Wrap one descriptor's handler so that nothing it does is silent.
83
+ *
84
+ * Three outcomes, three levels, and the split is about who has to act:
85
+ *
86
+ * - a REFUSAL the package itself decided (a 4xx it returned, or the 422 a
87
+ * {@link refusedField} error folds into) is `warn` — the operator
88
+ * typed something the rules do not allow, which is worth seeing when a
89
+ * store reports "it will not let me save" and worth nobody's pager;
90
+ * - anything else THROWN is `error` and is re-thrown unchanged: the store's
91
+ * own failures (a unique clash, a dead connection, a foreign target) belong
92
+ * to the host's error mapping, and swallowing one here to make a tidy
93
+ * response is how a broken database becomes a 404 nobody investigates;
94
+ * - a write that SUCCEEDED is `info`, because a discount is a direct lever on
95
+ * what a buyer is charged and "who repriced the menu at 03:12" is a
96
+ * question that gets asked.
97
+ *
98
+ * The catch is what makes the promise total. Before this, only the two write
99
+ * routes had any catch at all ({@link DiscountValidationError} → 422) and the
100
+ * three read routes had none, so a store that threw inside `list` produced a
101
+ * stack in the host's catch-all with nothing naming discounts.
102
+ */
103
+ export function observed(
104
+ logger: DiscountsLogger,
105
+ route: string,
106
+ handle: (request: WireRequest<DiscountsActor>) => Promise<WireResponse>,
107
+ ): (request: WireRequest<DiscountsActor>) => Promise<WireResponse> {
108
+ return async (request) => {
109
+ try {
110
+ const response = await handle(request);
111
+ if (response.status >= 400) {
112
+ logger.warn(`${subject(route, request)} refused with ${response.status}`);
113
+ }
114
+ return response;
115
+ } catch (error) {
116
+ const field = refusedField(error);
117
+ if (field !== null) {
118
+ logger.warn(`${subject(route, request)} refused with 422 on field "${field}"`);
119
+ throw error;
120
+ }
121
+ logger.error(`${subject(route, request)} failed: ${errorText(error)}`);
122
+ throw error;
123
+ }
124
+ };
125
+ }
126
+
127
+ /** The one line a successful write leaves — see {@link observed}'s third case. */
128
+ export function logWrite(
129
+ logger: DiscountsLogger,
130
+ verb: string,
131
+ request: WireRequest<DiscountsActor>,
132
+ id: string,
133
+ ): void {
134
+ logger.info(`${verb} discount ${id} for tenant ${request.actor.clientId}`);
135
+ }
package/src/server/mcp.ts CHANGED
@@ -10,6 +10,7 @@ import type { WireMcpTool } from "@12-apps/wiring";
10
10
 
11
11
  import {
12
12
  DISCOUNT_SCOPES,
13
+ DISCOUNT_TARGET_TYPES,
13
14
  DISCOUNT_TRIGGERS,
14
15
  DISCOUNT_TYPES,
15
16
  MAX_COMBO_SLOTS,
@@ -164,6 +165,27 @@ export const createDiscountBody = z.object(discountWriteShape);
164
165
  /** Body for updating a discount (every field re-stated). */
165
166
  export const updateDiscountBody = z.object(discountWriteShape);
166
167
 
168
+ /**
169
+ * One collection of things a discount may be pointed at, and its rows.
170
+ *
171
+ * `targets` is the WHOLE collection rather than a page, deliberately: a picker
172
+ * that paginated would let an operator save a discount whose existing targets
173
+ * were never on screen. See `DiscountableOps.list`.
174
+ */
175
+ export const discountTargetGroupSchema = z.object({
176
+ targetType: z.enum(DISCOUNT_TARGET_TYPES),
177
+ slug: z.string(),
178
+ label: z.string(),
179
+ nests: z.boolean(),
180
+ targets: z.array(
181
+ z.object({
182
+ id: z.string(),
183
+ name: z.string(),
184
+ parentId: z.string().nullable().optional(),
185
+ }),
186
+ ),
187
+ });
188
+
167
189
  /** `{ data: { id } }` acknowledgement for update/delete. */
168
190
  const discountIdResponse = z.object({ data: z.object({ id: z.string() }) });
169
191
 
@@ -186,6 +208,17 @@ export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
186
208
  response: createSearchOutput(discountSchema),
187
209
  annotations: { readOnly: true },
188
210
  },
211
+ {
212
+ operationId: "listDiscountTargets",
213
+ method: "GET",
214
+ path: "/discounts/targets",
215
+ summary:
216
+ "List what a discount can be pointed at in this store: every registered collection (categories, products) with its rows. Read this before creating or updating a CATEGORY- or ITEM-scoped discount, or a combo slot — an id from anywhere else is refused as not belonging to the store.",
217
+ tags: ["discounts"],
218
+ params: discountCollectionParams,
219
+ response: z.object({ data: z.array(discountTargetGroupSchema) }),
220
+ annotations: { readOnly: true },
221
+ },
189
222
  {
190
223
  operationId: "getDiscount",
191
224
  method: "GET",
@@ -32,4 +32,5 @@ export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
32
32
  freeUnitsExceedCombo: "O combo precisa cobrar por pelo menos um item.",
33
33
  invalidMaxComboApplications:
34
34
  "O limite de combos por carrinho deve ser maior que zero (ou deixe em branco).",
35
+ foreignTarget: "Selecione categorias e produtos desta loja.",
35
36
  };
@@ -2,7 +2,15 @@ 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";
13
+ import { logWrite, observed, type DiscountsLogger } from "./logging";
6
14
  import { listDiscountsQuery } from "./mcp";
7
15
  import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
8
16
  import type { DiscountListInput, DiscountRecord, DiscountStore } from "./store";
@@ -45,6 +53,28 @@ export interface DiscountsApiConfig {
45
53
  store: DiscountStore;
46
54
  /** Every sentence this surface answers a human with. Required, no defaults. */
47
55
  copy: DiscountsServerCopy;
56
+ /**
57
+ * Where this surface says what it did. Required, and for the same reason
58
+ * `copy` is: a default would be a no-op, and a no-op default is exactly the
59
+ * silence this config exists to end — the manifest declares an
60
+ * `observability` namespace, so a host adopting through
61
+ * `@12-apps/wiring/consumer` already HAS the logger and passes
62
+ * `assembled.loggers["@12-apps/discounts"]`; a host on no wiring passes any
63
+ * `createFeatureLogger`-shaped child. See `./logging`.
64
+ */
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[];
48
78
  /**
49
79
  * The schema the list query is validated against. Optional ONLY because a
50
80
  * mechanical default is portable: the package's own, built from the search
@@ -61,9 +91,10 @@ function ok(body: unknown): WireResponse {
61
91
  }
62
92
 
63
93
  /** The `{ error, issues }` shape a form reads its per-input errors out of. */
64
- function fieldError(error: DiscountValidationError): WireResponse {
94
+ function fieldError(error: DiscountValidationError | ForeignTargetError): WireResponse {
95
+ const status = error instanceof DiscountValidationError ? error.status : 422;
65
96
  return {
66
- status: error.status,
97
+ status,
67
98
  body: { error: error.message, issues: { [error.field]: error.message } },
68
99
  };
69
100
  }
@@ -74,29 +105,53 @@ function acknowledged(id: string): WireResponse {
74
105
  }
75
106
 
76
107
  /**
77
- * 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.
78
110
  *
79
111
  * Both writes go through it, so the rules run exactly once per write and a
80
- * 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.
81
116
  */
82
- function writeFrom(body: unknown, copy: DiscountsServerCopy) {
117
+ async function prepareWrite(config: DiscountsApiConfig, clientId: string, body: unknown) {
83
118
  const input = toDiscountWriteInput(body as DiscountWriteBody);
84
- 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;
85
124
  }
86
125
 
87
126
  /**
88
- * Run a write, turning this package's own validation failure into the 422 the
127
+ * One descriptor's handler, observed and then folded.
128
+ *
129
+ * `observed` logs every outcome and changes none of them (see `./logging`);
130
+ * this fold turns THIS package's own validation failure into the 422 the
89
131
  * operator can act on. Everything else — a uniqueness clash, a foreign target,
90
132
  * a stale id — is the STORE's to raise, in the host's own error vocabulary,
91
133
  * and travels untouched to the host's error mapping.
134
+ *
135
+ * Every route goes through it, reads included. A read cannot raise a
136
+ * `DiscountValidationError`, but it can absolutely throw — and before this the
137
+ * three read routes had no catch at all, so a store that failed inside `list`
138
+ * reached the host's catch-all with nothing in the log naming discounts.
92
139
  */
93
- async function attempt(run: () => Promise<WireResponse>): Promise<WireResponse> {
94
- try {
95
- return await run();
96
- } catch (error) {
97
- if (error instanceof DiscountValidationError) return fieldError(error);
98
- throw error;
99
- }
140
+ function endpoint(
141
+ config: DiscountsApiConfig,
142
+ route: string,
143
+ handle: (request: WireRequest<DiscountsActor>) => Promise<WireResponse>,
144
+ ): (request: WireRequest<DiscountsActor>) => Promise<WireResponse> {
145
+ const observedHandle = observed(config.logger, route, handle);
146
+ return async (request) => {
147
+ try {
148
+ return await observedHandle(request);
149
+ } catch (error) {
150
+ const refusal = error instanceof DiscountValidationError || error instanceof ForeignTargetError;
151
+ if (refusal) return fieldError(error);
152
+ throw error;
153
+ }
154
+ };
100
155
  }
101
156
 
102
157
  function listRoute(config: DiscountsApiConfig): DiscountRoute {
@@ -105,7 +160,7 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
105
160
  method: "GET",
106
161
  path: "/discounts",
107
162
  permission: DISCOUNTS_READ,
108
- handle: async ({ actor, query }: WireRequest<DiscountsActor>) => {
163
+ handle: endpoint(config, "GET /discounts", async ({ actor, query }) => {
109
164
  const parsed = schema.safeParse(query);
110
165
  if (!parsed.success) {
111
166
  return { status: 400, body: { error: config.copy.invalidQuery } };
@@ -114,7 +169,36 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
114
169
  // pagination }` envelope the advertised response describes, and wrapping
115
170
  // it again would nest it under a second `data`.
116
171
  return ok(await config.store.list(actor.clientId, parsed.data));
117
- },
172
+ }),
173
+ };
174
+ }
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
+ ),
118
202
  };
119
203
  }
120
204
 
@@ -123,11 +207,11 @@ function readRoute(config: DiscountsApiConfig): DiscountRoute {
123
207
  method: "GET",
124
208
  path: "/discounts/:id",
125
209
  permission: DISCOUNTS_READ,
126
- handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
210
+ handle: endpoint(config, "GET /discounts/:id", async ({ actor, params }) => {
127
211
  const record = await config.store.get(actor.clientId, String(params.id));
128
212
  if (record === null) return { status: 404, body: { error: config.copy.notFound } };
129
213
  return ok({ data: record as DiscountRecord });
130
- },
214
+ }),
131
215
  };
132
216
  }
133
217
 
@@ -136,10 +220,13 @@ function createRoute(config: DiscountsApiConfig): DiscountRoute {
136
220
  method: "POST",
137
221
  path: "/discounts",
138
222
  permission: DISCOUNTS_WRITE,
139
- handle: ({ actor, body }: WireRequest<DiscountsActor>) =>
140
- attempt(async () =>
141
- ok({ data: await config.store.create(actor.clientId, writeFrom(body, config.copy)) }),
142
- ),
223
+ handle: endpoint(config, "POST /discounts", async (request) => {
224
+ const { actor, body } = request;
225
+ const write = await prepareWrite(config, actor.clientId, body);
226
+ const created = await config.store.create(actor.clientId, write);
227
+ logWrite(config.logger, "created", request, created.id);
228
+ return ok({ data: created });
229
+ }),
143
230
  };
144
231
  }
145
232
 
@@ -148,12 +235,14 @@ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
148
235
  method: "PATCH",
149
236
  path: "/discounts/:id",
150
237
  permission: DISCOUNTS_WRITE,
151
- handle: ({ actor, params, body }: WireRequest<DiscountsActor>) =>
152
- attempt(async () => {
153
- const id = String(params.id);
154
- await config.store.update(actor.clientId, id, writeFrom(body, config.copy));
155
- return acknowledged(id);
156
- }),
238
+ handle: endpoint(config, "PATCH /discounts/:id", async (request) => {
239
+ const { actor, params, body } = request;
240
+ const id = String(params.id);
241
+ const write = await prepareWrite(config, actor.clientId, body);
242
+ await config.store.update(actor.clientId, id, write);
243
+ logWrite(config.logger, "re-stated", request, id);
244
+ return acknowledged(id);
245
+ }),
157
246
  };
158
247
  }
159
248
 
@@ -162,21 +251,39 @@ function archiveRoute(config: DiscountsApiConfig): DiscountRoute {
162
251
  method: "DELETE",
163
252
  path: "/discounts/:id",
164
253
  permission: DISCOUNTS_WRITE,
165
- handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
254
+ handle: endpoint(config, "DELETE /discounts/:id", async (request) => {
255
+ const { actor, params } = request;
166
256
  const id = String(params.id);
167
257
  await config.store.archive(actor.clientId, id);
258
+ logWrite(config.logger, "archived", request, id);
168
259
  return acknowledged(id);
169
- },
260
+ }),
170
261
  };
171
262
  }
172
263
 
264
+ /** The three methods `observed` calls — asserted before any request arrives. */
265
+ function assertLogger(logger: DiscountsLogger | undefined): void {
266
+ const complete =
267
+ typeof logger?.info === "function" &&
268
+ typeof logger.warn === "function" &&
269
+ typeof logger.error === "function";
270
+ if (complete) return;
271
+ throw new Error(
272
+ "@12-apps/discounts: createApiDiscounts needs a logger with info/warn/error — " +
273
+ "the manifest declares an observability namespace, so a wiring host passes " +
274
+ 'assembled.loggers["@12-apps/discounts"]; there is no silent default.',
275
+ );
276
+ }
277
+
173
278
  /**
174
279
  * The five descriptors, config asserted at construction.
175
280
  *
176
- * Copy is checked HERE rather than at first request, because a missing
177
- * sentence is a wiring mistake and it should fail where the wiring is written
178
- * — the report-builder doctrine: required fields, no defaults, asserted at
179
- * assembly.
281
+ * Copy and the logger are checked HERE rather than at first request, because a
282
+ * missing sentence or a missing logger is a wiring mistake and it should fail
283
+ * where the wiring is written — the report-builder doctrine: required fields,
284
+ * no defaults, asserted at assembly. The logger earned that treatment the hard
285
+ * way: for the whole of 1.0.x the manifest promised these routes logged, the
286
+ * host built the namespaced logger, and nothing connected the two.
180
287
  */
181
288
  export function createApiDiscounts(config: DiscountsApiConfig): {
182
289
  routes: readonly DiscountRoute[];
@@ -188,9 +295,15 @@ export function createApiDiscounts(config: DiscountsApiConfig): {
188
295
  "every sentence this surface answers a human with is host config, with no defaults.",
189
296
  );
190
297
  }
298
+ assertLogger(config.logger);
299
+ assertCollections(config.collections ?? []);
191
300
  return {
192
301
  routes: [
193
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),
194
307
  readRoute(config),
195
308
  createRoute(config),
196
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
+ }