@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.
- package/README.md +240 -19
- package/package.json +56 -7
- 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 +52 -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 +25 -0
- package/src/server/logging.ts +135 -0
- package/src/server/mcp.ts +33 -0
- package/src/server/pt-BR.ts +1 -0
- package/src/server/routes.ts +147 -34
- package/src/server/targets.ts +102 -0
|
@@ -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",
|
package/src/server/pt-BR.ts
CHANGED
|
@@ -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
|
};
|
package/src/server/routes.ts
CHANGED
|
@@ -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
|
|
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
|
|
117
|
+
async function prepareWrite(config: DiscountsApiConfig, clientId: string, body: unknown) {
|
|
83
118
|
const input = toDiscountWriteInput(body as DiscountWriteBody);
|
|
84
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 }
|
|
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 }
|
|
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: (
|
|
140
|
-
|
|
141
|
-
|
|
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: (
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
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
|
|
177
|
-
* sentence is a wiring mistake and it should fail
|
|
178
|
-
* — the report-builder doctrine: required fields,
|
|
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
|
+
}
|