@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.
- package/README.md +196 -16
- package/package.json +55 -6
- package/prisma/discounts.prisma +181 -0
- package/prisma/migrations/20260821140000_discounts_package_owned/migration.sql +286 -0
- package/scripts/sync-discounts-schema.mjs +53 -0
- package/src/engine/kinds.ts +62 -0
- package/src/index.ts +5 -0
- package/src/manifest/index.ts +45 -17
- package/src/manifest/web.ts +55 -0
- package/src/react/api.ts +185 -0
- package/src/react/copy.ts +239 -0
- package/src/react/create-web-discounts.tsx +162 -0
- package/src/react/discount-actions-menu.tsx +127 -0
- package/src/react/discount-card.tsx +92 -0
- package/src/react/discount-form-fields.tsx +174 -0
- package/src/react/discount-form.tsx +272 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +159 -0
- package/src/react/discounts-screen.tsx +314 -0
- package/src/react/format.ts +126 -0
- package/src/react/index.ts +72 -0
- package/src/react/pt-BR.ts +132 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/transport.ts +115 -0
- package/src/react/use-discounts-data.ts +103 -0
- package/src/server/collections.ts +290 -0
- package/src/server/copy.ts +12 -0
- package/src/server/index.ts +23 -0
- package/src/server/logging.ts +20 -3
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +74 -9
- package/src/server/targets.ts +102 -0
package/src/server/routes.ts
CHANGED
|
@@ -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
|
|
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
|
|
117
|
+
async function prepareWrite(config: DiscountsApiConfig, clientId: string, body: unknown) {
|
|
94
118
|
const input = toDiscountWriteInput(body as DiscountWriteBody);
|
|
95
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
}
|