@12-apps/discounts 1.9.0 → 1.10.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/package.json +3 -3
- package/src/server/copy.ts +28 -0
- package/src/server/routes.ts +38 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
|
|
6
6
|
"exports": {
|
|
@@ -61,9 +61,9 @@
|
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
63
|
"@12-apps/eslint-config": "^1.21.1",
|
|
64
|
-
"@12-apps/i18n": "^1.
|
|
64
|
+
"@12-apps/i18n": "^1.1.0",
|
|
65
65
|
"@12-apps/typescript-config": "^1.20.1",
|
|
66
|
-
"@12-apps/wiring": "^1.
|
|
66
|
+
"@12-apps/wiring": "^1.11.0",
|
|
67
67
|
"@electric-sql/pglite": "^0.2.17",
|
|
68
68
|
"@emotion/react": "^11.14.0",
|
|
69
69
|
"@emotion/styled": "^11.14.0",
|
package/src/server/copy.ts
CHANGED
|
@@ -98,3 +98,31 @@ export function missingServerCopy(copy: DiscountsServerCopy | undefined): string
|
|
|
98
98
|
if (copy === undefined) return [...COPY_KEYS];
|
|
99
99
|
return COPY_KEYS.filter((key) => typeof copy[key] !== "string" || copy[key].trim() === "");
|
|
100
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the config field takes once its copy can follow a reader.
|
|
104
|
+
*
|
|
105
|
+
* Declared here rather than imported from `@12-apps/i18n`: this package must
|
|
106
|
+
* stay liftable into a repo that has never heard of it, so the two agree
|
|
107
|
+
* STRUCTURALLY and nothing forces the dependency. The context is deliberately
|
|
108
|
+
* loose — a raw tag off `WireRequest.locale`, unnarrowed — because matching it
|
|
109
|
+
* is the host resolver's job.
|
|
110
|
+
*/
|
|
111
|
+
export type DiscountsCopyResolver<T> = (context: { readonly locale?: string | null }) => T;
|
|
112
|
+
export type DiscountsCopySource<T> = T | DiscountsCopyResolver<T>;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The copy a field is offering, at the moment it is needed.
|
|
116
|
+
*
|
|
117
|
+
* Call this where the sentence is USED — a factory that resolves once and keeps
|
|
118
|
+
* the result has re-frozen the language into its mount, and a single-locale
|
|
119
|
+
* host cannot tell the difference.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveDiscountsCopy<T>(
|
|
122
|
+
source: DiscountsCopySource<T>,
|
|
123
|
+
locale: string | undefined,
|
|
124
|
+
): T {
|
|
125
|
+
return typeof source === "function"
|
|
126
|
+
? (source as DiscountsCopyResolver<T>)({ locale })
|
|
127
|
+
: source;
|
|
128
|
+
}
|
package/src/server/routes.ts
CHANGED
|
@@ -9,7 +9,12 @@ import {
|
|
|
9
9
|
loadTargetGroups,
|
|
10
10
|
type DiscountableCollection,
|
|
11
11
|
} from "./collections";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
missingServerCopy,
|
|
14
|
+
resolveDiscountsCopy,
|
|
15
|
+
type DiscountsCopySource,
|
|
16
|
+
type DiscountsServerCopy,
|
|
17
|
+
} from "./copy";
|
|
13
18
|
import { logWrite, observed, type DiscountsLogger } from "./logging";
|
|
14
19
|
import { listDiscountsQuery } from "./mcp";
|
|
15
20
|
import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
|
|
@@ -51,8 +56,14 @@ export type DiscountRoute = WireRoute<DiscountsActor>;
|
|
|
51
56
|
export interface DiscountsApiConfig {
|
|
52
57
|
/** Where the rows are — see {@link DiscountStore}. */
|
|
53
58
|
store: DiscountStore;
|
|
54
|
-
/**
|
|
55
|
-
|
|
59
|
+
/**
|
|
60
|
+
* Every sentence this surface answers a human with. Required, no defaults.
|
|
61
|
+
*
|
|
62
|
+
* A bilingual host passes a RESOLVER instead — `localeCopy(DISCOUNTS_SERVER_COPY)`
|
|
63
|
+
* — and each request is answered in its own caller's language. The plain
|
|
64
|
+
* value stays legal, so a host with one audience changes nothing.
|
|
65
|
+
*/
|
|
66
|
+
copy: DiscountsCopySource<DiscountsServerCopy>;
|
|
56
67
|
/**
|
|
57
68
|
* Where this surface says what it did. Required, and for the same reason
|
|
58
69
|
* `copy` is: a default would be a no-op, and a no-op default is exactly the
|
|
@@ -114,11 +125,16 @@ function acknowledged(id: string): WireResponse {
|
|
|
114
125
|
* fold is what narrows the targets to the scope — checking the raw body would
|
|
115
126
|
* reject ids a COMBO-scoped write was about to drop anyway.
|
|
116
127
|
*/
|
|
117
|
-
async function prepareWrite(
|
|
128
|
+
async function prepareWrite(
|
|
129
|
+
config: DiscountsApiConfig,
|
|
130
|
+
copy: DiscountsServerCopy,
|
|
131
|
+
clientId: string,
|
|
132
|
+
body: unknown,
|
|
133
|
+
) {
|
|
118
134
|
const input = toDiscountWriteInput(body as DiscountWriteBody);
|
|
119
|
-
const write = { scalars: toDiscountScalars(input,
|
|
135
|
+
const write = { scalars: toDiscountScalars(input, copy), targets: targetsForScope(input) };
|
|
120
136
|
if (config.collections) {
|
|
121
|
-
await assertTargetsOwned(config.collections, clientId, write.targets,
|
|
137
|
+
await assertTargetsOwned(config.collections, clientId, write.targets, copy.foreignTarget);
|
|
122
138
|
}
|
|
123
139
|
return write;
|
|
124
140
|
}
|
|
@@ -160,10 +176,12 @@ function listRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
160
176
|
method: "GET",
|
|
161
177
|
path: "/discounts",
|
|
162
178
|
permission: DISCOUNTS_READ,
|
|
163
|
-
handle: endpoint(config, "GET /discounts", async ({ actor, query }) => {
|
|
179
|
+
handle: endpoint(config, "GET /discounts", async ({ actor, query, locale }) => {
|
|
164
180
|
const parsed = schema.safeParse(query);
|
|
165
181
|
if (!parsed.success) {
|
|
166
|
-
|
|
182
|
+
// Worded per request, from the tag the adapter put on it — never at the
|
|
183
|
+
// mount, which ran once at boot.
|
|
184
|
+
return { status: 400, body: { error: resolveDiscountsCopy(config.copy, locale).invalidQuery } };
|
|
167
185
|
}
|
|
168
186
|
// Returned as-is rather than wrapped: the page IS the `{ data,
|
|
169
187
|
// pagination }` envelope the advertised response describes, and wrapping
|
|
@@ -207,9 +225,11 @@ function readRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
207
225
|
method: "GET",
|
|
208
226
|
path: "/discounts/:id",
|
|
209
227
|
permission: DISCOUNTS_READ,
|
|
210
|
-
handle: endpoint(config, "GET /discounts/:id", async ({ actor, params }) => {
|
|
228
|
+
handle: endpoint(config, "GET /discounts/:id", async ({ actor, params, locale }) => {
|
|
211
229
|
const record = await config.store.get(actor.clientId, String(params.id));
|
|
212
|
-
if (record === null)
|
|
230
|
+
if (record === null) {
|
|
231
|
+
return { status: 404, body: { error: resolveDiscountsCopy(config.copy, locale).notFound } };
|
|
232
|
+
}
|
|
213
233
|
return ok({ data: record as DiscountRecord });
|
|
214
234
|
}),
|
|
215
235
|
};
|
|
@@ -221,8 +241,8 @@ function createRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
221
241
|
path: "/discounts",
|
|
222
242
|
permission: DISCOUNTS_WRITE,
|
|
223
243
|
handle: endpoint(config, "POST /discounts", async (request) => {
|
|
224
|
-
const { actor, body } = request;
|
|
225
|
-
const write = await prepareWrite(config, actor.clientId, body);
|
|
244
|
+
const { actor, body, locale } = request;
|
|
245
|
+
const write = await prepareWrite(config, resolveDiscountsCopy(config.copy, locale), actor.clientId, body);
|
|
226
246
|
const created = await config.store.create(actor.clientId, write);
|
|
227
247
|
logWrite(config.logger, "created", request, created.id);
|
|
228
248
|
return ok({ data: created });
|
|
@@ -236,9 +256,9 @@ function updateRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
|
236
256
|
path: "/discounts/:id",
|
|
237
257
|
permission: DISCOUNTS_WRITE,
|
|
238
258
|
handle: endpoint(config, "PATCH /discounts/:id", async (request) => {
|
|
239
|
-
const { actor, params, body } = request;
|
|
259
|
+
const { actor, params, body, locale } = request;
|
|
240
260
|
const id = String(params.id);
|
|
241
|
-
const write = await prepareWrite(config, actor.clientId, body);
|
|
261
|
+
const write = await prepareWrite(config, resolveDiscountsCopy(config.copy, locale), actor.clientId, body);
|
|
242
262
|
await config.store.update(actor.clientId, id, write);
|
|
243
263
|
logWrite(config.logger, "re-stated", request, id);
|
|
244
264
|
return acknowledged(id);
|
|
@@ -288,7 +308,10 @@ function assertLogger(logger: DiscountsLogger | undefined): void {
|
|
|
288
308
|
export function createApiDiscounts(config: DiscountsApiConfig): {
|
|
289
309
|
routes: readonly DiscountRoute[];
|
|
290
310
|
} {
|
|
291
|
-
|
|
311
|
+
// Validated against the DEFAULT rendering — a resolver asked for no locale.
|
|
312
|
+
// A host that forgot a sentence should fail at the mount, as it always did,
|
|
313
|
+
// rather than at whichever request first needs the missing one.
|
|
314
|
+
const missing = missingServerCopy(resolveDiscountsCopy(config.copy, undefined));
|
|
292
315
|
if (missing.length > 0) {
|
|
293
316
|
throw new Error(
|
|
294
317
|
`@12-apps/discounts: createApiDiscounts is missing copy for ${missing.join(", ")} — ` +
|