@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.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * How the discounts screens reach their data — the report-builder transport
3
+ * doctrine, which `@12-apps/entity-lifecycle` also follows: this is the ONLY
4
+ * way the surface performs I/O, so a caller supplying one has substituted the
5
+ * entire backend without stubbing a global. That is what makes the screens
6
+ * drivable from a story and testable without a server.
7
+ *
8
+ * The default is a same-origin `fetch` riding the browser's cookies.
9
+ */
10
+
11
+ /** A write outcome the screens BRANCH on, rather than a thrown mutation. */
12
+ export type DiscountsResult<T> =
13
+ | { ok: true; data: T }
14
+ | {
15
+ ok: false;
16
+ error: string;
17
+ /** Per-input messages the form paints, keyed by its own field names. */
18
+ fieldErrors?: Record<string, string>;
19
+ /** The status behind the failure; absent when nothing answered at all. */
20
+ status?: number;
21
+ };
22
+
23
+ /** A failed read, carrying the status a screen may want to branch on. */
24
+ export class DiscountsHttpError extends Error {
25
+ readonly status: number;
26
+ constructor(status: number, message: string) {
27
+ super(message);
28
+ this.name = "DiscountsHttpError";
29
+ this.status = status;
30
+ Object.setPrototypeOf(this, DiscountsHttpError.prototype);
31
+ }
32
+ }
33
+
34
+ export interface DiscountsTransport {
35
+ /** A read. Answers the whole JSON payload, envelope included. */
36
+ get<T>(path: string): Promise<T>;
37
+ /** A write. Answers a {@link DiscountsResult} rather than rejecting. */
38
+ send<T>(path: string, method: string, body?: unknown): Promise<DiscountsResult<T>>;
39
+ }
40
+
41
+ /** The refusal half of the envelope every `createApi*` package answers in. */
42
+ interface ErrorEnvelope {
43
+ error?: string;
44
+ issues?: Record<string, string>;
45
+ }
46
+
47
+ async function readJson<T>(path: string): Promise<T> {
48
+ const response = await fetch(path, {
49
+ credentials: "same-origin",
50
+ headers: { Accept: "application/json" },
51
+ });
52
+ if (!response.ok) {
53
+ const payload = (await response.json().catch(() => null)) as ErrorEnvelope | null;
54
+ throw new DiscountsHttpError(
55
+ response.status,
56
+ payload?.error ?? `HTTP ${response.status} for ${path}`,
57
+ );
58
+ }
59
+ return (await response.json()) as T;
60
+ }
61
+
62
+ /** The headers and body a write carries — the `Content-Type` only when there is one. */
63
+ function requestInit(method: string, body: unknown): RequestInit {
64
+ const hasBody = body !== undefined;
65
+ return {
66
+ method,
67
+ credentials: "same-origin",
68
+ headers: {
69
+ Accept: "application/json",
70
+ ...(hasBody ? { "Content-Type": "application/json" } : {}),
71
+ },
72
+ ...(hasBody ? { body: JSON.stringify(body) } : {}),
73
+ };
74
+ }
75
+
76
+ /** A non-2xx, folded into the shape a form branches on. */
77
+ function refusal<T>(
78
+ status: number,
79
+ payload: ErrorEnvelope | null,
80
+ fallbackError: string,
81
+ ): DiscountsResult<T> {
82
+ return {
83
+ ok: false,
84
+ error: payload?.error ?? fallbackError,
85
+ status,
86
+ ...(payload?.issues ? { fieldErrors: payload.issues } : {}),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * @param fallbackError What a failed write says when the server sent no
92
+ * sentence of its own — REQUIRED, the host's words. `createWebDiscounts`
93
+ * passes its (equally required) `copy.form.saveFailed`; only a host building
94
+ * the transport by hand writes it here.
95
+ */
96
+ export function httpDiscountsTransport(fallbackError: string): DiscountsTransport {
97
+ return {
98
+ get: readJson,
99
+ async send<T>(path: string, method: string, body?: unknown): Promise<DiscountsResult<T>> {
100
+ try {
101
+ const response = await fetch(path, requestInit(method, body));
102
+ const payload = (await response.json().catch(() => null)) as
103
+ | ({ data?: T } & ErrorEnvelope)
104
+ | null;
105
+ if (!response.ok) return refusal<T>(response.status, payload, fallbackError);
106
+ return { ok: true, data: (payload?.data ?? null) as T };
107
+ } catch (error) {
108
+ // No `status`, deliberately: its absence is the only thing separating
109
+ // "the server said no" from "nothing answered", and the two want
110
+ // different handling.
111
+ return { ok: false, error: error instanceof Error ? error.message : fallbackError };
112
+ }
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,103 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+
5
+ import type { DiscountsApiClient, DiscountsPage, WireTargetGroup } from "./api";
6
+
7
+ /**
8
+ * The screen's two reads, and the three states they can be in.
9
+ *
10
+ * A hand-rolled loader rather than a query library, and that is a dependency
11
+ * decision rather than a preference: a host on TanStack Query, SWR or nothing
12
+ * at all must all be able to mount this surface, and taking one of them as a
13
+ * peer would decide for them. What is lost is a shared cache across pages; what
14
+ * is kept is the one behaviour that actually matters on a server-driven grid —
15
+ * the PREVIOUS page stays on screen while the next one loads, so paging does
16
+ * not flash an empty table.
17
+ *
18
+ * The two reads are separate on purpose. The page changes on every filter
19
+ * keystroke; the registered collections change when a host adds one, which is
20
+ * never during a session. Refetching the catalog per keystroke would be the
21
+ * origin's mistake — it side-loaded both catalogs on every mount — at a higher
22
+ * frequency.
23
+ */
24
+
25
+ interface DiscountsData {
26
+ page: DiscountsPage | null;
27
+ /** Undefined until the collections have loaded — see the menu's `groups`. */
28
+ groups: readonly WireTargetGroup[] | undefined;
29
+ /** True only for the FIRST load; a refetch keeps the previous page on screen. */
30
+ loading: boolean;
31
+ error: string | null;
32
+ refresh: () => void;
33
+ }
34
+
35
+ /**
36
+ * @param search The owned query params for this render, already serialized. A
37
+ * string rather than an object so the effect's dependency is a value, not an
38
+ * identity — an object literal would re-fetch on every render.
39
+ */
40
+ export function useDiscountsData(
41
+ api: DiscountsApiClient,
42
+ search: string,
43
+ onError: (error: unknown, context: string) => void,
44
+ ): DiscountsData {
45
+ const [page, setPage] = useState<DiscountsPage | null>(null);
46
+ const [groups, setGroups] = useState<readonly WireTargetGroup[] | undefined>(undefined);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [nonce, setNonce] = useState(0);
49
+ // Every in-flight read is stamped, and only the LATEST one may write. Two
50
+ // keystrokes can be in flight at once and they can answer out of order; the
51
+ // slower one landing last would put an older page on screen with no clue
52
+ // anything went wrong.
53
+ const latest = useRef(0);
54
+
55
+ const report = useCallback(
56
+ (cause: unknown, context: string) => {
57
+ setError(cause instanceof Error ? cause.message : String(cause));
58
+ onError(cause, context);
59
+ },
60
+ [onError],
61
+ );
62
+
63
+ useEffect(() => {
64
+ const ticket = ++latest.current;
65
+ api
66
+ .list(search)
67
+ .then((next) => {
68
+ if (ticket !== latest.current) return;
69
+ setError(null);
70
+ setPage(next);
71
+ })
72
+ .catch((cause: unknown) => {
73
+ if (ticket !== latest.current) return;
74
+ report(cause, "discounts.list");
75
+ });
76
+ }, [api, search, nonce, report]);
77
+
78
+ useEffect(() => {
79
+ let live = true;
80
+ api
81
+ .targets()
82
+ .then((next) => {
83
+ if (live) setGroups(next);
84
+ })
85
+ .catch((cause: unknown) => {
86
+ // A failed catalog read is NOT a failed page. The grid is perfectly
87
+ // readable without it; only the edit form is withheld, which is what
88
+ // `groups: undefined` already means to the row menu.
89
+ if (live) onError(cause, "discounts.targets");
90
+ });
91
+ return () => {
92
+ live = false;
93
+ };
94
+ }, [api, onError]);
95
+
96
+ return {
97
+ page,
98
+ groups,
99
+ loading: page === null && error === null,
100
+ error,
101
+ refresh: useCallback(() => setNonce((value) => value + 1), []),
102
+ };
103
+ }
@@ -0,0 +1,290 @@
1
+ import { DISCOUNT_TARGET_TYPES, type DiscountTargetType } from "../engine/kinds";
2
+ import type { ComboRequirement } from "../engine/types";
3
+ import type { DiscountTargets } from "./validate";
4
+
5
+ /**
6
+ * How a host TABLE opts into being discountable (FUT-244).
7
+ *
8
+ * A discount points at rows this package will never see: a store's categories,
9
+ * its products, and — the day a host wants it — its suppliers or its shelves.
10
+ * Every one of those questions ("which of them exist", "are they this
11
+ * tenant's", "what is this one filed under") is answerable only by the host,
12
+ * and until now each host answered them in its own hand-written code beside
13
+ * its own tables.
14
+ *
15
+ * The shape is deliberately {@link LifecycleEntityRegistration}'s, from
16
+ * `@12-apps/entity-lifecycle`: identity plus a duck-typed ops seam the host
17
+ * implements against its own tables. The family resemblance is the point — a
18
+ * reader who knows one knows this one — and so is the difference: entity
19
+ * lifecycle registers a collection it DRIVES, with write ops, while this
20
+ * registers a catalog dimension it only QUERIES, so every op here reads.
21
+ *
22
+ * ## Why this is not a wiring capability
23
+ *
24
+ * `@12-apps/wiring`'s contract folder holds http, web, mcp, db, env, jobs,
25
+ * email, notifications and permissions — there is no registration or extension
26
+ * concept, so promoting this would be a genuinely new capability minted on two
27
+ * instances that differ in the way just described. Abstracting one contract
28
+ * over both risks fitting neither. It lives here, shaped like its sibling, and
29
+ * a third consumer is what would earn it a place in the contract.
30
+ */
31
+
32
+ /** One selectable row of a host collection, as a picker renders it. */
33
+ export interface DiscountTarget {
34
+ id: string;
35
+ /** The operator-facing name. Host vocabulary, host language. */
36
+ name: string;
37
+ /**
38
+ * The parent row, for a collection that NESTS (a category tree). A picker
39
+ * uses it to render a tree instead of a flat list, and the evaluator's
40
+ * category path is built from the same edges. Absent or null at the root.
41
+ */
42
+ parentId?: string | null;
43
+ }
44
+
45
+ /**
46
+ * The three reads this package needs of a host's table. Every one is scoped by
47
+ * tenant, and none of them writes: a discount never creates, renames or
48
+ * deletes a category — it only ever asks about one.
49
+ */
50
+ export interface DiscountableOps {
51
+ /**
52
+ * Every row of this collection the tenant owns, for the target picker.
53
+ *
54
+ * The whole collection, not a page: a picker that paginated would let an
55
+ * operator save a discount whose existing targets were never on screen, and
56
+ * silently drop them. A host with a catalog too large to hand over caps it
57
+ * and says so in its own surface — which is a host decision, not this
58
+ * package's.
59
+ */
60
+ list(clientId: string): Promise<readonly DiscountTarget[]>;
61
+ /**
62
+ * Whether EVERY id names a live row of this collection owned by `clientId`.
63
+ *
64
+ * This is the cross-tenant guard, and it is the reason the op is not
65
+ * optional. Without it a crafted write could point one store's promotion at
66
+ * another store's catalog, and the reverse menu-badge lookup would then
67
+ * advertise it there.
68
+ */
69
+ ownsAll(clientId: string, ids: readonly string[]): Promise<boolean>;
70
+ /**
71
+ * `child id → parent id` for the whole collection, for the ancestry walk a
72
+ * nesting collection needs (see {@link buildTargetPath}).
73
+ *
74
+ * Separate from {@link list} even though `list` carries `parentId`, because
75
+ * the two are read on different paths: `list` feeds an admin picker once,
76
+ * while this runs on the MONEY path — every cart mutation and every menu
77
+ * page's badge preview — where a host wants two indexed columns rather than
78
+ * the picker's whole payload. A flat collection omits it.
79
+ */
80
+ parents?(clientId: string): Promise<ReadonlyMap<string, string | null>>;
81
+ }
82
+
83
+ /** One host table, declared discountable. */
84
+ export interface DiscountableCollection {
85
+ /**
86
+ * Which dimension this collection answers for. Closed at two today; see
87
+ * {@link DISCOUNT_TARGET_TYPES} for what widening it would take.
88
+ */
89
+ targetType: DiscountTargetType;
90
+ /** The picker's URL segment and its `data-testid` stem, e.g. `"categories"`. */
91
+ slug: string;
92
+ /**
93
+ * What an operator calls this collection ("Categorias", "Produtos"). Host
94
+ * vocabulary in the host's language, and therefore host config — the same
95
+ * rule the copy ports follow, answered here rather than in a copy pack
96
+ * because a pack keyed by a host-chosen collection could not be typed.
97
+ */
98
+ label: string;
99
+ /** Whether a target covers the rows filed UNDER it. `parents` must be set. */
100
+ nests?: boolean;
101
+ ops: DiscountableOps;
102
+ }
103
+
104
+ /** The picker's payload for one collection: what it is, and what is in it. */
105
+ export interface DiscountTargetGroup {
106
+ targetType: DiscountTargetType;
107
+ slug: string;
108
+ label: string;
109
+ nests: boolean;
110
+ targets: readonly DiscountTarget[];
111
+ }
112
+
113
+ /**
114
+ * Raised when a write names a target that is not this tenant's.
115
+ *
116
+ * Its own class rather than a `DiscountValidationError` so that the sentence
117
+ * stays the host's while the FIELD stays the package's: a form paints
118
+ * `targets`, and `routes.ts` folds this into the same 422 envelope.
119
+ */
120
+ export class ForeignTargetError extends Error {
121
+ readonly field = "targets";
122
+ constructor(message: string) {
123
+ super(message);
124
+ this.name = "ForeignTargetError";
125
+ }
126
+ }
127
+
128
+ /** Every id a write points at, per dimension — the top-level pair AND the slots. */
129
+ export function targetIdsByType(
130
+ targets: DiscountTargets,
131
+ ): Readonly<Record<DiscountTargetType, string[]>> {
132
+ const categoryIds = new Set(targets.categoryIds);
133
+ const menuItemIds = new Set(targets.menuItemIds);
134
+ addSlotIds(targets.comboRequirements, categoryIds, menuItemIds);
135
+ return { CATEGORY: [...categoryIds], ITEM: [...menuItemIds] };
136
+ }
137
+
138
+ /**
139
+ * Fold the combo slots' own targets in.
140
+ *
141
+ * A separate function because it is where the gap was: the origin host checked
142
+ * `categoryIds` and `menuItemIds` and nothing else, so a crafted COMBO whose
143
+ * slots named another store's products would have passed — invisibly, because
144
+ * a combo scope drops the top-level pair and carries its targets in the slots.
145
+ * It was never live only because the slots were not yet persisted anywhere.
146
+ */
147
+ function addSlotIds(
148
+ requirements: readonly ComboRequirement[],
149
+ categoryIds: Set<string>,
150
+ menuItemIds: Set<string>,
151
+ ): void {
152
+ for (const requirement of requirements) {
153
+ requirement.categoryIds.forEach((id) => categoryIds.add(id));
154
+ requirement.menuItemIds.forEach((id) => menuItemIds.add(id));
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Refuse a write pointing at a row this tenant does not own, before it reaches
160
+ * the store.
161
+ *
162
+ * ## Why OUTSIDE the store's transaction is enough
163
+ *
164
+ * The origin host ran this check inside the write transaction, which reads as
165
+ * the safer arrangement, and for the leak that matters it makes no difference:
166
+ * a catalog row's tenant never changes, so an id that belongs to this tenant
167
+ * now cannot belong to another one by the time the write lands. The only race
168
+ * left is a target DELETED between the check and the write — whose outcome is a
169
+ * discount pointing at a row that no longer exists, which the evaluator already
170
+ * tolerates (an unmatched target simply covers nothing). Cross-tenant, the
171
+ * thing this exists to stop, is impossible either way.
172
+ *
173
+ * A dimension with no registered collection is NOT checked here, and the store
174
+ * is left to enforce it — see {@link DiscountsApiConfig.collections}.
175
+ */
176
+ export async function assertTargetsOwned(
177
+ collections: readonly DiscountableCollection[],
178
+ clientId: string,
179
+ targets: DiscountTargets,
180
+ message: string,
181
+ ): Promise<void> {
182
+ const byType = targetIdsByType(targets);
183
+ const checks = collections.map(async (collection) => {
184
+ const ids = byType[collection.targetType];
185
+ if (ids.length === 0) return true;
186
+ return collection.ops.ownsAll(clientId, ids);
187
+ });
188
+ const results = await Promise.all(checks);
189
+ if (results.some((owned) => !owned)) throw new ForeignTargetError(message);
190
+ }
191
+
192
+ /** Every registered collection's rows, as the target picker reads them. */
193
+ export async function loadTargetGroups(
194
+ collections: readonly DiscountableCollection[],
195
+ clientId: string,
196
+ ): Promise<readonly DiscountTargetGroup[]> {
197
+ return Promise.all(
198
+ collections.map(async (collection) => ({
199
+ targetType: collection.targetType,
200
+ slug: collection.slug,
201
+ label: collection.label,
202
+ nests: collection.nests ?? false,
203
+ targets: await collection.ops.list(clientId),
204
+ })),
205
+ );
206
+ }
207
+
208
+ /**
209
+ * A row's own id followed by its ancestors, nearest first — the path R3
210
+ * intersects a scoped discount's targets against, so a discount on a top-level
211
+ * category automatically covers items filed under its subcategories.
212
+ *
213
+ * The `seen` set is a cycle guard, and it is not decoration: this runs on the
214
+ * money path, so a pre-existing bad row must cost a truncated path and never an
215
+ * infinite loop inside a checkout.
216
+ *
217
+ * Pure, and exported, because the host builds the cart lines this package then
218
+ * prices — it needs the same walk on its side of the seam, and a second
219
+ * implementation of it is how the two would disagree about what a discount
220
+ * covers.
221
+ */
222
+ export function buildTargetPath(
223
+ targetId: string | null,
224
+ parentById: ReadonlyMap<string, string | null>,
225
+ ): string[] {
226
+ const path: string[] = [];
227
+ const seen = new Set<string>();
228
+ let cursor = targetId;
229
+ while (cursor !== null && !seen.has(cursor)) {
230
+ seen.add(cursor);
231
+ path.push(cursor);
232
+ cursor = parentById.get(cursor) ?? null;
233
+ }
234
+ return path;
235
+ }
236
+
237
+ /**
238
+ * The ancestry walk for one nesting collection, loaded ONCE per evaluation.
239
+ *
240
+ * Returned as a resolver rather than as a map so the caller cannot be tempted
241
+ * to walk the edges by hand and lose the cycle guard. A collection that does
242
+ * not nest — or one whose host declared no `parents` — resolves every id to
243
+ * itself, which is exactly what a flat dimension means.
244
+ */
245
+ export async function targetPathResolver(
246
+ collection: DiscountableCollection,
247
+ clientId: string,
248
+ ): Promise<(targetId: string | null) => string[]> {
249
+ if (!collection.nests || !collection.ops.parents) {
250
+ return (targetId) => (targetId === null ? [] : [targetId]);
251
+ }
252
+ const parents = await collection.ops.parents(clientId);
253
+ return (targetId) => buildTargetPath(targetId, parents);
254
+ }
255
+
256
+ /**
257
+ * Assert a registration set is usable, at construction rather than at the
258
+ * first request that trips over it.
259
+ *
260
+ * Two rules, both of which produce a surface that looks live and quietly does
261
+ * the wrong thing: two collections for one dimension means one of them silently
262
+ * never runs, and a nesting collection with no `parents` op means every target
263
+ * resolves to itself — a discount on a top-level category covering nothing
264
+ * filed beneath it, which reads as a pricing bug and not as a wiring one.
265
+ */
266
+ export function assertCollections(collections: readonly DiscountableCollection[]): void {
267
+ const seen = new Set<string>();
268
+ for (const collection of collections) {
269
+ if (!DISCOUNT_TARGET_TYPES.some((type) => type === collection.targetType)) {
270
+ throw new Error(
271
+ `@12-apps/discounts: "${collection.targetType}" is not a discountable dimension — ` +
272
+ `expected one of ${DISCOUNT_TARGET_TYPES.join(", ")}.`,
273
+ );
274
+ }
275
+ if (seen.has(collection.targetType)) {
276
+ throw new Error(
277
+ `@12-apps/discounts: two collections registered for ${collection.targetType} — ` +
278
+ "one of them would never be read.",
279
+ );
280
+ }
281
+ seen.add(collection.targetType);
282
+ if (collection.nests && !collection.ops.parents) {
283
+ throw new Error(
284
+ `@12-apps/discounts: the ${collection.targetType} collection nests but declares no ` +
285
+ "parents op — every target would resolve to itself and a discount on a parent " +
286
+ "would cover nothing filed under it.",
287
+ );
288
+ }
289
+ }
290
+ }
@@ -56,6 +56,17 @@ export interface DiscountsServerCopy {
56
56
  readonly freeUnitsExceedCombo: string;
57
57
  /** 422 — a per-cart combo cap of zero or less. */
58
58
  readonly invalidMaxComboApplications: string;
59
+ /**
60
+ * 422 — a target id that is not this tenant's, or no longer exists.
61
+ *
62
+ * Reported against `targets` whichever dimension it came from, and whether
63
+ * it arrived as a scope target or inside a combo slot: an operator picks
64
+ * from a list this surface handed them, so the only way to reach this is a
65
+ * crafted request or a row deleted while the form was open. One sentence
66
+ * covers both, and it must not name WHICH id — an id the caller did not
67
+ * already have is a fact about another store's catalog.
68
+ */
69
+ readonly foreignTarget: string;
59
70
  }
60
71
 
61
72
  const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
@@ -79,6 +90,7 @@ const COPY_KEYS: readonly (keyof DiscountsServerCopy)[] = [
79
90
  "invalidFreeUnits",
80
91
  "freeUnitsExceedCombo",
81
92
  "invalidMaxComboApplications",
93
+ "foreignTarget",
82
94
  ];
83
95
 
84
96
  /** Every key present and non-blank — checked at assembly, like the rest. */
@@ -17,6 +17,20 @@ export { missingServerCopy, type DiscountsServerCopy } from "./copy";
17
17
 
18
18
  export { errorText, type DiscountsLogger } from "./logging";
19
19
 
20
+ export {
21
+ assertCollections,
22
+ assertTargetsOwned,
23
+ buildTargetPath,
24
+ ForeignTargetError,
25
+ loadTargetGroups,
26
+ targetIdsByType,
27
+ targetPathResolver,
28
+ type DiscountableCollection,
29
+ type DiscountableOps,
30
+ type DiscountTarget,
31
+ type DiscountTargetGroup,
32
+ } from "./collections";
33
+
20
34
  export {
21
35
  DISCOUNTS_PERMISSIONS,
22
36
  DISCOUNTS_READ,
@@ -44,10 +58,19 @@ export {
44
58
 
45
59
  export { comboRequirementsForScope, toComboColumns, type ComboColumns } from "./validate-combo";
46
60
 
61
+ export {
62
+ fromTargetRows,
63
+ toTargetRows,
64
+ type DiscountComboSlotRow,
65
+ type DiscountTargetRow,
66
+ type DiscountTargetRows,
67
+ } from "./targets";
68
+
47
69
  export { discountSearchConfig } from "./search";
48
70
 
49
71
  export {
50
72
  comboRequirementSchema,
73
+ discountTargetGroupSchema,
51
74
  createDiscountBody,
52
75
  createDiscountListQuery,
53
76
  discountCollectionParams,
@@ -1,5 +1,6 @@
1
1
  import type { WireRequest, WireResponse } from "@12-apps/wiring";
2
2
 
3
+ import { ForeignTargetError } from "./collections";
3
4
  import type { DiscountsActor } from "./routes";
4
5
  import { DiscountValidationError } from "./validate";
5
6
 
@@ -46,6 +47,21 @@ export interface DiscountsLogger {
46
47
  error(message: string): void;
47
48
  }
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
+
49
65
  /** The cause as a log line — never the object. See the module note. */
50
66
  export function errorText(error: unknown): string {
51
67
  return error instanceof Error ? error.message : String(error);
@@ -68,7 +84,7 @@ function subject(route: string, request: WireRequest<DiscountsActor>): string {
68
84
  * Three outcomes, three levels, and the split is about who has to act:
69
85
  *
70
86
  * - a REFUSAL the package itself decided (a 4xx it returned, or the 422 a
71
- * {@link DiscountValidationError} folds into) is `warn` — the operator
87
+ * {@link refusedField} error folds into) is `warn` — the operator
72
88
  * typed something the rules do not allow, which is worth seeing when a
73
89
  * store reports "it will not let me save" and worth nobody's pager;
74
90
  * - anything else THROWN is `error` and is re-thrown unchanged: the store's
@@ -97,8 +113,9 @@ export function observed(
97
113
  }
98
114
  return response;
99
115
  } catch (error) {
100
- if (error instanceof DiscountValidationError) {
101
- logger.warn(`${subject(route, request)} refused with 422 on field "${error.field}"`);
116
+ const field = refusedField(error);
117
+ if (field !== null) {
118
+ logger.warn(`${subject(route, request)} refused with 422 on field "${field}"`);
102
119
  throw error;
103
120
  }
104
121
  logger.error(`${subject(route, request)} failed: ${errorText(error)}`);
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
  };