@12-apps/discounts 1.0.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 +110 -0
- package/package.json +73 -0
- package/src/engine/allocate.ts +133 -0
- package/src/engine/eligibility.ts +160 -0
- package/src/engine/evaluate-passes.ts +217 -0
- package/src/engine/evaluate.ts +339 -0
- package/src/engine/kinds.ts +87 -0
- package/src/engine/preview.ts +164 -0
- package/src/engine/pt-BR.ts +26 -0
- package/src/engine/rejection-copy.ts +69 -0
- package/src/engine/types.ts +147 -0
- package/src/index.ts +69 -0
- package/src/manifest/index.ts +52 -0
- package/src/manifest/server.ts +23 -0
- package/src/server/contribution.ts +42 -0
- package/src/server/copy.ts +64 -0
- package/src/server/index.ts +54 -0
- package/src/server/mcp.ts +197 -0
- package/src/server/pt-BR.ts +26 -0
- package/src/server/routes.ts +200 -0
- package/src/server/search.ts +45 -0
- package/src/server/store.ts +101 -0
- package/src/server/validate.ts +296 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/server` — the admin CRUD surface.
|
|
3
|
+
*
|
|
4
|
+
* Behind its own subpath so a browser bundle that only prices a cart (the
|
|
5
|
+
* package root) never resolves zod or the search engine. The wiring manifest
|
|
6
|
+
* for these capabilities is `@12-apps/discounts/manifest/server`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
createApiDiscounts,
|
|
11
|
+
type DiscountRoute,
|
|
12
|
+
type DiscountsActor,
|
|
13
|
+
type DiscountsApiConfig,
|
|
14
|
+
} from "./routes";
|
|
15
|
+
|
|
16
|
+
export { missingServerCopy, type DiscountsServerCopy } from "./copy";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
DISCOUNTS_PERMISSIONS,
|
|
20
|
+
DISCOUNTS_READ,
|
|
21
|
+
DISCOUNTS_WRITE,
|
|
22
|
+
} from "./contribution";
|
|
23
|
+
|
|
24
|
+
export type {
|
|
25
|
+
DiscountListInput,
|
|
26
|
+
DiscountPage,
|
|
27
|
+
DiscountRecord,
|
|
28
|
+
DiscountStore,
|
|
29
|
+
DiscountWrite,
|
|
30
|
+
} from "./store";
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
DiscountValidationError,
|
|
34
|
+
targetsForScope,
|
|
35
|
+
toDiscountScalars,
|
|
36
|
+
toDiscountWriteInput,
|
|
37
|
+
type DiscountScalars,
|
|
38
|
+
type DiscountTargets,
|
|
39
|
+
type DiscountWriteBody,
|
|
40
|
+
type DiscountWriteInput,
|
|
41
|
+
} from "./validate";
|
|
42
|
+
|
|
43
|
+
export { discountSearchConfig } from "./search";
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
createDiscountBody,
|
|
47
|
+
createDiscountListQuery,
|
|
48
|
+
discountCollectionParams,
|
|
49
|
+
discountItemParams,
|
|
50
|
+
discountSchema,
|
|
51
|
+
DISCOUNTS_MCP_TOOLS,
|
|
52
|
+
listDiscountsQuery,
|
|
53
|
+
updateDiscountBody,
|
|
54
|
+
} from "./mcp";
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createSearchInput,
|
|
5
|
+
createSearchOutput,
|
|
6
|
+
searchConfigSchema,
|
|
7
|
+
type SearchConfig,
|
|
8
|
+
} from "@12-apps/shared-helpers/search";
|
|
9
|
+
import type { WireMcpTool } from "@12-apps/wiring";
|
|
10
|
+
|
|
11
|
+
import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES, MAX_PERCENT_OFF_BP } from "../engine/kinds";
|
|
12
|
+
import { discountSearchConfig } from "./search";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The discounts tool surface — authored ONCE, beside the route descriptors it
|
|
16
|
+
* proxies to, and reused as the runtime validators. The advertised schema and
|
|
17
|
+
* what the handler accepts are the same objects, so they cannot drift.
|
|
18
|
+
*
|
|
19
|
+
* Every enum comes from `../engine/kinds` rather than being spelled out as
|
|
20
|
+
* literals: those same arrays back a host's CHECK constraints, so a value an
|
|
21
|
+
* agent can send is by construction a value the database will store.
|
|
22
|
+
*
|
|
23
|
+
* Paths are RELATIVE to the http capability's mount — the wiring consumer
|
|
24
|
+
* prefixes the adoption's `mountPath`, so a tool's URL and its route's URL
|
|
25
|
+
* cannot disagree.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Path params for the tenant-scoped discounts collection. */
|
|
29
|
+
export const discountCollectionParams = z.object({
|
|
30
|
+
tenantSlug: z.string().min(1),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Path params for a single discount. */
|
|
34
|
+
export const discountItemParams = z.object({
|
|
35
|
+
tenantSlug: z.string().min(1),
|
|
36
|
+
id: z.string().min(1),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A calendar date (`YYYY-MM-DD`), read at UTC midnight by the API.
|
|
41
|
+
*
|
|
42
|
+
* No custom message: a sentence here would be a default in one language, and
|
|
43
|
+
* the schema's own path is what a client renders. The SEMANTIC failure — a
|
|
44
|
+
* date that is not a date — is reported by `toDiscountScalars` with the host's
|
|
45
|
+
* `invalidDate` copy and the field to paint.
|
|
46
|
+
*/
|
|
47
|
+
const calendarDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A discount as the API returns it (JSON-serialized: dates become ISO
|
|
51
|
+
* strings). The two join tables are flattened into id arrays.
|
|
52
|
+
*/
|
|
53
|
+
export const discountSchema = z.object({
|
|
54
|
+
id: z.string(),
|
|
55
|
+
name: z.string(),
|
|
56
|
+
type: z.enum(DISCOUNT_TYPES),
|
|
57
|
+
/** Basis points off (1..10000). Non-null iff `type = PERCENTAGE`. */
|
|
58
|
+
percentOffBp: z.number().int().nullable(),
|
|
59
|
+
/** Cents off. Non-null iff `type = FIXED_AMOUNT`. */
|
|
60
|
+
amountOffCents: z.number().int().nullable(),
|
|
61
|
+
scope: z.enum(DISCOUNT_SCOPES),
|
|
62
|
+
trigger: z.enum(DISCOUNT_TRIGGERS),
|
|
63
|
+
code: z.string().nullable(),
|
|
64
|
+
startsAt: z.string().nullable(),
|
|
65
|
+
endsAt: z.string().nullable(),
|
|
66
|
+
minSubtotalCents: z.number().int().nullable(),
|
|
67
|
+
usageLimit: z.number().int().nullable(),
|
|
68
|
+
perBuyerLimit: z.number().int().nullable(),
|
|
69
|
+
usageCount: z.number().int(),
|
|
70
|
+
stackable: z.boolean(),
|
|
71
|
+
active: z.boolean(),
|
|
72
|
+
categoryIds: z.array(z.string()),
|
|
73
|
+
menuItemIds: z.array(z.string()),
|
|
74
|
+
createdAt: z.string(),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The list query, from the same config the store's engine reads.
|
|
79
|
+
*
|
|
80
|
+
* A host that tunes its page-size ceilings by environment, or that adds a pill
|
|
81
|
+
* of its own vocabulary, builds its own with this factory and hands the result
|
|
82
|
+
* to BOTH `createApiDiscounts({ listQuery })` and the adoption's
|
|
83
|
+
* `mcpOverrides.listDiscounts.query` — one schema, advertised and enforced.
|
|
84
|
+
*/
|
|
85
|
+
export function createDiscountListQuery<E extends Record<string, z.ZodTypeAny>>(
|
|
86
|
+
config: SearchConfig,
|
|
87
|
+
extraShape?: E,
|
|
88
|
+
) {
|
|
89
|
+
return createSearchInput(discountSearchConfig, config, extraShape);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The package's own list query: the search config's DEFAULTS, not the
|
|
94
|
+
* environment's. This module is imported by an offline tool generator and by
|
|
95
|
+
* browsers; reading `process.env` at module scope would make the advertised
|
|
96
|
+
* surface depend on where it was generated.
|
|
97
|
+
*/
|
|
98
|
+
export const listDiscountsQuery = createDiscountListQuery(searchConfigSchema.parse({}));
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* The write body, shared by create and update: a discount is always saved
|
|
102
|
+
* WHOLE (the form re-states every field), so there is no partial-patch shape
|
|
103
|
+
* to diverge from. The cross-field rules — a rate iff PERCENTAGE, a code iff
|
|
104
|
+
* CODE, a target iff CATEGORY/ITEM, `endsAt` strictly after `startsAt` — are
|
|
105
|
+
* enforced in `./validate`, where they produce a sentence naming the field
|
|
106
|
+
* instead of a Zod path.
|
|
107
|
+
*/
|
|
108
|
+
const discountWriteShape = {
|
|
109
|
+
name: z.string().min(2).max(120),
|
|
110
|
+
type: z.enum(DISCOUNT_TYPES),
|
|
111
|
+
percentOffBp: z.number().int().min(1).max(MAX_PERCENT_OFF_BP).nullable().optional(),
|
|
112
|
+
amountOffCents: z.number().int().positive().nullable().optional(),
|
|
113
|
+
scope: z.enum(DISCOUNT_SCOPES),
|
|
114
|
+
trigger: z.enum(DISCOUNT_TRIGGERS),
|
|
115
|
+
code: z.string().min(1).max(64).nullable().optional(),
|
|
116
|
+
startsAt: calendarDateSchema.nullable().optional(),
|
|
117
|
+
endsAt: calendarDateSchema.nullable().optional(),
|
|
118
|
+
minSubtotalCents: z.number().int().positive().nullable().optional(),
|
|
119
|
+
usageLimit: z.number().int().positive().nullable().optional(),
|
|
120
|
+
perBuyerLimit: z.number().int().positive().nullable().optional(),
|
|
121
|
+
stackable: z.boolean(),
|
|
122
|
+
active: z.boolean(),
|
|
123
|
+
categoryIds: z.array(z.string().min(1)).optional(),
|
|
124
|
+
menuItemIds: z.array(z.string().min(1)).optional(),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** Body for creating a discount. */
|
|
128
|
+
export const createDiscountBody = z.object(discountWriteShape);
|
|
129
|
+
|
|
130
|
+
/** Body for updating a discount (every field re-stated). */
|
|
131
|
+
export const updateDiscountBody = z.object(discountWriteShape);
|
|
132
|
+
|
|
133
|
+
/** `{ data: { id } }` acknowledgement for update/delete. */
|
|
134
|
+
const discountIdResponse = z.object({ data: z.object({ id: z.string() }) });
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The tools, one per route descriptor. `annotations` travel WITH the tool —
|
|
138
|
+
* the package knows perfectly well that `listDiscounts` only reads and that
|
|
139
|
+
* `deleteDiscount` is the destructive one, and a host's policy table would
|
|
140
|
+
* otherwise restate all five by hand.
|
|
141
|
+
*/
|
|
142
|
+
export const DISCOUNTS_MCP_TOOLS: readonly WireMcpTool<z.ZodType>[] = [
|
|
143
|
+
{
|
|
144
|
+
operationId: "listDiscounts",
|
|
145
|
+
method: "GET",
|
|
146
|
+
path: "/discounts",
|
|
147
|
+
summary:
|
|
148
|
+
"List a store's discounts and promotions (admin). Backend filter (`q` over name and coupon code, `type_in` PERCENTAGE|FIXED_AMOUNT, `scope_in` ORDER|CATEGORY|ITEM, `trigger_in` AUTOMATIC|CODE, `active`), `sort` (field:asc|desc), and pagination (`page`/`pageSize`). Returns the page plus `pagination` (total, pageCount, hasNextPage).",
|
|
149
|
+
tags: ["discounts"],
|
|
150
|
+
params: discountCollectionParams,
|
|
151
|
+
query: listDiscountsQuery,
|
|
152
|
+
response: createSearchOutput(discountSchema),
|
|
153
|
+
annotations: { readOnly: true },
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
operationId: "getDiscount",
|
|
157
|
+
method: "GET",
|
|
158
|
+
path: "/discounts/{id}",
|
|
159
|
+
summary: "Read one of a store's discounts by id (admin).",
|
|
160
|
+
tags: ["discounts"],
|
|
161
|
+
params: discountItemParams,
|
|
162
|
+
response: z.object({ data: discountSchema }),
|
|
163
|
+
annotations: { readOnly: true },
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
operationId: "createDiscount",
|
|
167
|
+
method: "POST",
|
|
168
|
+
path: "/discounts",
|
|
169
|
+
summary:
|
|
170
|
+
"Create a discount for a store (admin). A percentage is basis points (1..10000); a fixed amount is integer cents. A CODE-triggered discount needs a coupon code, unique within the store; a CATEGORY/ITEM-scoped one needs at least one target.",
|
|
171
|
+
tags: ["discounts"],
|
|
172
|
+
params: discountCollectionParams,
|
|
173
|
+
body: createDiscountBody,
|
|
174
|
+
response: z.object({ data: discountSchema }),
|
|
175
|
+
},
|
|
176
|
+
{
|
|
177
|
+
operationId: "updateDiscount",
|
|
178
|
+
method: "PATCH",
|
|
179
|
+
path: "/discounts/{id}",
|
|
180
|
+
summary: "Update a store's discount (every field is re-stated, targets included).",
|
|
181
|
+
tags: ["discounts"],
|
|
182
|
+
params: discountItemParams,
|
|
183
|
+
body: updateDiscountBody,
|
|
184
|
+
response: discountIdResponse,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
operationId: "deleteDiscount",
|
|
188
|
+
method: "DELETE",
|
|
189
|
+
path: "/discounts/{id}",
|
|
190
|
+
summary:
|
|
191
|
+
"Delete a store's discount (admin). Soft-delete: the discount stops applying and leaves the list, while the orders that already redeemed it keep their snapshot and its redemption counter is preserved.",
|
|
192
|
+
tags: ["discounts"],
|
|
193
|
+
params: discountItemParams,
|
|
194
|
+
response: discountIdResponse,
|
|
195
|
+
annotations: { destructive: true },
|
|
196
|
+
},
|
|
197
|
+
];
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { DiscountsServerCopy } from "./copy";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The pt-BR pack — a NAMED export a host passes by hand
|
|
5
|
+
* (`copy: PT_BR_DISCOUNTS_SERVER_COPY`), never a default. The filename is what
|
|
6
|
+
* exempts this file from the copy-portability gate: Portuguese may ship, it
|
|
7
|
+
* may not be silent.
|
|
8
|
+
*
|
|
9
|
+
* Every sentence names what the operator must change, because these are form
|
|
10
|
+
* errors: the route reports them with the offending FIELD alongside, so an
|
|
11
|
+
* admin form can paint that input rather than only flashing a banner.
|
|
12
|
+
*/
|
|
13
|
+
export const PT_BR_DISCOUNTS_SERVER_COPY: DiscountsServerCopy = {
|
|
14
|
+
invalidQuery: "Parâmetros de consulta inválidos.",
|
|
15
|
+
notFound: "Desconto não encontrado.",
|
|
16
|
+
invalidPercent: "Informe uma porcentagem maior que 0 e no máximo 100.",
|
|
17
|
+
invalidAmount: "Informe um valor de desconto maior que zero.",
|
|
18
|
+
codeRequired: "Informe o código do cupom que o cliente vai digitar.",
|
|
19
|
+
categoryTargetRequired: "Selecione ao menos uma categoria para este desconto.",
|
|
20
|
+
itemTargetRequired: "Selecione ao menos um produto para este desconto.",
|
|
21
|
+
invalidDate: "Data inválida. Use o formato AAAA-MM-DD.",
|
|
22
|
+
endsBeforeStarts: "A data de término deve ser posterior à data de início.",
|
|
23
|
+
invalidMinSubtotal: "O pedido mínimo deve ser maior que zero (ou deixe em branco).",
|
|
24
|
+
invalidUsageLimit: "O limite de usos deve ser maior que zero (ou deixe em branco).",
|
|
25
|
+
invalidPerBuyerLimit: "O limite por cliente deve ser maior que zero (ou deixe em branco).",
|
|
26
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
|
|
3
|
+
import type { WireRequest, WireResponse, WireRoute } from "@12-apps/wiring";
|
|
4
|
+
|
|
5
|
+
import { missingServerCopy, type DiscountsServerCopy } from "./copy";
|
|
6
|
+
import { listDiscountsQuery } from "./mcp";
|
|
7
|
+
import { DISCOUNTS_READ, DISCOUNTS_WRITE } from "./contribution";
|
|
8
|
+
import type { DiscountListInput, DiscountRecord, DiscountStore } from "./store";
|
|
9
|
+
import {
|
|
10
|
+
DiscountValidationError,
|
|
11
|
+
targetsForScope,
|
|
12
|
+
toDiscountScalars,
|
|
13
|
+
toDiscountWriteInput,
|
|
14
|
+
type DiscountWriteBody,
|
|
15
|
+
} from "./validate";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The admin discounts HTTP surface (FUT-244), as framework-neutral wiring
|
|
19
|
+
* descriptors: tenant-scoped CRUD over the promotions an operator manages.
|
|
20
|
+
*
|
|
21
|
+
* What is NOT here is as deliberate as what is. There is no lifecycle or
|
|
22
|
+
* approval branch (FUT-235 D7) — a pricing rule with a live redemption counter
|
|
23
|
+
* is not versioned catalog content, so a write applies immediately or fails.
|
|
24
|
+
* There is no authentication, no tenant resolution and no RBAC: each
|
|
25
|
+
* descriptor DECLARES the permission it needs and the host's guard answers it,
|
|
26
|
+
* because which tier a route requires is host policy, and the actor arrives
|
|
27
|
+
* already resolved.
|
|
28
|
+
*
|
|
29
|
+
* Delete is a SOFT archive rather than a row removal, and that is a product
|
|
30
|
+
* decision rather than an implementation detail: the orders that already
|
|
31
|
+
* redeemed this discount keep their snapshot, and its redemption counter stays
|
|
32
|
+
* readable for reporting.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Whoever the host resolved: the tenant this request may act inside. */
|
|
36
|
+
export interface DiscountsActor {
|
|
37
|
+
/** The tenant's id — every store call is scoped to it. */
|
|
38
|
+
clientId: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type DiscountRoute = WireRoute<DiscountsActor>;
|
|
42
|
+
|
|
43
|
+
export interface DiscountsApiConfig {
|
|
44
|
+
/** Where the rows are — see {@link DiscountStore}. */
|
|
45
|
+
store: DiscountStore;
|
|
46
|
+
/** Every sentence this surface answers a human with. Required, no defaults. */
|
|
47
|
+
copy: DiscountsServerCopy;
|
|
48
|
+
/**
|
|
49
|
+
* The schema the list query is validated against. Optional ONLY because a
|
|
50
|
+
* mechanical default is portable: the package's own, built from the search
|
|
51
|
+
* config's defaults. A host that tunes page-size ceilings by environment, or
|
|
52
|
+
* that adds a pill of its own vocabulary, passes the schema it also
|
|
53
|
+
* advertises through `mcpOverrides.listDiscounts.query` — one object, so the
|
|
54
|
+
* advertised surface and the enforced one cannot drift.
|
|
55
|
+
*/
|
|
56
|
+
listQuery?: z.ZodType<DiscountListInput>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ok(body: unknown): WireResponse {
|
|
60
|
+
return { status: 200, body };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `{ error, issues }` shape a form reads its per-input errors out of. */
|
|
64
|
+
function fieldError(error: DiscountValidationError): WireResponse {
|
|
65
|
+
return {
|
|
66
|
+
status: error.status,
|
|
67
|
+
body: { error: error.message, issues: { [error.field]: error.message } },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The id acknowledgement update and delete answer with. */
|
|
72
|
+
function acknowledged(id: string): WireResponse {
|
|
73
|
+
return ok({ data: { id } });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Fold a validated body into what the store persists.
|
|
78
|
+
*
|
|
79
|
+
* Both writes go through it, so the rules run exactly once per write and a
|
|
80
|
+
* second caller cannot half-apply them.
|
|
81
|
+
*/
|
|
82
|
+
function writeFrom(body: unknown, copy: DiscountsServerCopy) {
|
|
83
|
+
const input = toDiscountWriteInput(body as DiscountWriteBody);
|
|
84
|
+
return { scalars: toDiscountScalars(input, copy), targets: targetsForScope(input) };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Run a write, turning this package's own validation failure into the 422 the
|
|
89
|
+
* operator can act on. Everything else — a uniqueness clash, a foreign target,
|
|
90
|
+
* a stale id — is the STORE's to raise, in the host's own error vocabulary,
|
|
91
|
+
* and travels untouched to the host's error mapping.
|
|
92
|
+
*/
|
|
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
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function listRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
103
|
+
const schema = config.listQuery ?? (listDiscountsQuery as z.ZodType<DiscountListInput>);
|
|
104
|
+
return {
|
|
105
|
+
method: "GET",
|
|
106
|
+
path: "/discounts",
|
|
107
|
+
permission: DISCOUNTS_READ,
|
|
108
|
+
handle: async ({ actor, query }: WireRequest<DiscountsActor>) => {
|
|
109
|
+
const parsed = schema.safeParse(query);
|
|
110
|
+
if (!parsed.success) {
|
|
111
|
+
return { status: 400, body: { error: config.copy.invalidQuery } };
|
|
112
|
+
}
|
|
113
|
+
// Returned as-is rather than wrapped: the page IS the `{ data,
|
|
114
|
+
// pagination }` envelope the advertised response describes, and wrapping
|
|
115
|
+
// it again would nest it under a second `data`.
|
|
116
|
+
return ok(await config.store.list(actor.clientId, parsed.data));
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
122
|
+
return {
|
|
123
|
+
method: "GET",
|
|
124
|
+
path: "/discounts/:id",
|
|
125
|
+
permission: DISCOUNTS_READ,
|
|
126
|
+
handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
|
|
127
|
+
const record = await config.store.get(actor.clientId, String(params.id));
|
|
128
|
+
if (record === null) return { status: 404, body: { error: config.copy.notFound } };
|
|
129
|
+
return ok({ data: record as DiscountRecord });
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function createRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
135
|
+
return {
|
|
136
|
+
method: "POST",
|
|
137
|
+
path: "/discounts",
|
|
138
|
+
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
|
+
),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function updateRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
147
|
+
return {
|
|
148
|
+
method: "PATCH",
|
|
149
|
+
path: "/discounts/:id",
|
|
150
|
+
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
|
+
}),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function archiveRoute(config: DiscountsApiConfig): DiscountRoute {
|
|
161
|
+
return {
|
|
162
|
+
method: "DELETE",
|
|
163
|
+
path: "/discounts/:id",
|
|
164
|
+
permission: DISCOUNTS_WRITE,
|
|
165
|
+
handle: async ({ actor, params }: WireRequest<DiscountsActor>) => {
|
|
166
|
+
const id = String(params.id);
|
|
167
|
+
await config.store.archive(actor.clientId, id);
|
|
168
|
+
return acknowledged(id);
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The five descriptors, config asserted at construction.
|
|
175
|
+
*
|
|
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.
|
|
180
|
+
*/
|
|
181
|
+
export function createApiDiscounts(config: DiscountsApiConfig): {
|
|
182
|
+
routes: readonly DiscountRoute[];
|
|
183
|
+
} {
|
|
184
|
+
const missing = missingServerCopy(config.copy);
|
|
185
|
+
if (missing.length > 0) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`@12-apps/discounts: createApiDiscounts is missing copy for ${missing.join(", ")} — ` +
|
|
188
|
+
"every sentence this surface answers a human with is host config, with no defaults.",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
routes: [
|
|
193
|
+
listRoute(config),
|
|
194
|
+
readRoute(config),
|
|
195
|
+
createRoute(config),
|
|
196
|
+
updateRoute(config),
|
|
197
|
+
archiveRoute(config),
|
|
198
|
+
],
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { defineSearchConfig } from "@12-apps/shared-helpers/search";
|
|
2
|
+
|
|
3
|
+
import { DISCOUNT_SCOPES, DISCOUNT_TRIGGERS, DISCOUNT_TYPES } from "../engine/kinds";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The admin discounts-list search contract (FUT-244): the backend equivalent of
|
|
7
|
+
* a promotions grid's search box, pills and sort. Search is case-insensitive
|
|
8
|
+
* over the discount name AND its coupon code, because an operator hunting for
|
|
9
|
+
* `BEMVINDO10` types the code, not the promotion's marketing name.
|
|
10
|
+
*
|
|
11
|
+
* The three closed sets come from `../engine/kinds` rather than being spelled
|
|
12
|
+
* out again here: the same arrays back a host's CHECK constraints and the MCP
|
|
13
|
+
* enums, so a value this config would accept is by construction a value the
|
|
14
|
+
* database will store.
|
|
15
|
+
*
|
|
16
|
+
* The grid's fourth pill — the VIGÊNCIA one (running / scheduled / ended) — is
|
|
17
|
+
* deliberately ABSENT. It is a comparison between two nullable columns and the
|
|
18
|
+
* current instant, which `filterableFields` cannot express at all, so it rides
|
|
19
|
+
* as a separate `window` parameter the store resolves into its own predicate.
|
|
20
|
+
* The parameter's VALUES are host words, which is why the store owns them: see
|
|
21
|
+
* `DiscountListInput` in `./store`.
|
|
22
|
+
*/
|
|
23
|
+
export const discountSearchConfig = defineSearchConfig({
|
|
24
|
+
searchableFields: ["name", "code"],
|
|
25
|
+
searchMode: "insensitive",
|
|
26
|
+
filterableFields: {
|
|
27
|
+
type: { type: "string", operators: ["in"], allowedValues: [...DISCOUNT_TYPES] },
|
|
28
|
+
scope: { type: "string", operators: ["in"], allowedValues: [...DISCOUNT_SCOPES] },
|
|
29
|
+
trigger: { type: "string", operators: ["in"], allowedValues: [...DISCOUNT_TRIGGERS] },
|
|
30
|
+
active: { type: "boolean", operators: ["eq"] },
|
|
31
|
+
},
|
|
32
|
+
sortableFields: [
|
|
33
|
+
"name",
|
|
34
|
+
"code",
|
|
35
|
+
"type",
|
|
36
|
+
"scope",
|
|
37
|
+
"startsAt",
|
|
38
|
+
"endsAt",
|
|
39
|
+
"usageCount",
|
|
40
|
+
"createdAt",
|
|
41
|
+
],
|
|
42
|
+
// Newest first: a promotions list is a working queue, and the thing an
|
|
43
|
+
// operator just created is the thing they want to see.
|
|
44
|
+
defaultSort: { field: "createdAt", direction: "desc" },
|
|
45
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { PaginatedResult, ParsedSearchInput } from "@12-apps/shared-helpers/search";
|
|
2
|
+
|
|
3
|
+
import type { DiscountScope, DiscountTrigger, DiscountType } from "../engine/kinds";
|
|
4
|
+
import type { DiscountScalars, DiscountTargets } from "./validate";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The persistence seam — the ONE thing this package does not do.
|
|
8
|
+
*
|
|
9
|
+
* A discount's rows relate to a host's own catalog and orders: targets point
|
|
10
|
+
* at its categories and items, redemptions at its orders and its buyers. A
|
|
11
|
+
* shipped Prisma partial would therefore only compile inside a host that
|
|
12
|
+
* already has those tables under those names, which is why the manifest
|
|
13
|
+
* declares no `db` capability and this interface exists instead. The host
|
|
14
|
+
* owns the schema, the tenant scoping, the transactions and the uniqueness
|
|
15
|
+
* conflicts; the package owns the rules and the wire.
|
|
16
|
+
*
|
|
17
|
+
* Everything here is `clientId`-first for the same reason the origin
|
|
18
|
+
* implementation is: a discount is a direct lever on what a buyer is charged,
|
|
19
|
+
* so a query that forgot its tenant would not merely leak a row — it would let
|
|
20
|
+
* one store edit another store's prices.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One discount as the admin API returns it, before JSON serialization. */
|
|
24
|
+
export interface DiscountRecord {
|
|
25
|
+
id: string;
|
|
26
|
+
name: string;
|
|
27
|
+
type: DiscountType | string;
|
|
28
|
+
percentOffBp: number | null;
|
|
29
|
+
amountOffCents: number | null;
|
|
30
|
+
scope: DiscountScope | string;
|
|
31
|
+
trigger: DiscountTrigger | string;
|
|
32
|
+
code: string | null;
|
|
33
|
+
startsAt: Date | null;
|
|
34
|
+
endsAt: Date | null;
|
|
35
|
+
minSubtotalCents: number | null;
|
|
36
|
+
usageLimit: number | null;
|
|
37
|
+
perBuyerLimit: number | null;
|
|
38
|
+
usageCount: number;
|
|
39
|
+
stackable: boolean;
|
|
40
|
+
active: boolean;
|
|
41
|
+
/**
|
|
42
|
+
* The join tables flattened into plain id arrays: a form's target pickers
|
|
43
|
+
* bind to ids, and a client has no business knowing there are two joins.
|
|
44
|
+
*/
|
|
45
|
+
categoryIds: string[];
|
|
46
|
+
menuItemIds: string[];
|
|
47
|
+
createdAt: Date;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* What a write persists: the validated columns and the scope-narrowed targets.
|
|
52
|
+
*
|
|
53
|
+
* The store receives them ALREADY folded, never the raw body — every rule
|
|
54
|
+
* (`toDiscountScalars`) has run, the unused half of each either/or pair is
|
|
55
|
+
* NULL, and the targets are de-duplicated. So a store implementation is
|
|
56
|
+
* persistence and nothing else, and the rules cannot be half-applied by a
|
|
57
|
+
* second caller that forgot one.
|
|
58
|
+
*/
|
|
59
|
+
export interface DiscountWrite {
|
|
60
|
+
scalars: DiscountScalars;
|
|
61
|
+
targets: DiscountTargets;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The list query as the store receives it: the shared search engine's parsed
|
|
66
|
+
* input, widened so a host can carry pills of its OWN vocabulary through.
|
|
67
|
+
*
|
|
68
|
+
* The widening is the point. A promotions grid usually wants a validity pill
|
|
69
|
+
* — running / scheduled / ended — which compares two nullable columns against
|
|
70
|
+
* "now" and no `filterableField` can express; its VALUES are words a host
|
|
71
|
+
* chose, in a language this package does not have. So the host extends the
|
|
72
|
+
* advertised query schema (an `mcpOverrides` entry, the same object it hands
|
|
73
|
+
* `createApiDiscounts` as `listQuery`), and reads its own key back out here.
|
|
74
|
+
*/
|
|
75
|
+
export type DiscountListInput = ParsedSearchInput & Readonly<Record<string, unknown>>;
|
|
76
|
+
|
|
77
|
+
/** One backend-paginated page of the admin discounts grid. */
|
|
78
|
+
export type DiscountPage = PaginatedResult<DiscountRecord>;
|
|
79
|
+
|
|
80
|
+
export interface DiscountStore {
|
|
81
|
+
/** One page of this tenant's LIVE discounts, filtered/sorted/paginated. */
|
|
82
|
+
list(clientId: string, input: DiscountListInput): Promise<DiscountPage>;
|
|
83
|
+
/** One LIVE discount owned by `clientId`, or null (stale, foreign, archived). */
|
|
84
|
+
get(clientId: string, id: string): Promise<DiscountRecord | null>;
|
|
85
|
+
/**
|
|
86
|
+
* Persist a new discount and its targets. Implementations raise their own
|
|
87
|
+
* host error for a uniqueness clash (name or coupon code within the tenant)
|
|
88
|
+
* and for a target id that belongs to another tenant — both are database
|
|
89
|
+
* facts this package cannot see, and both must reach the operator naming the
|
|
90
|
+
* offending field.
|
|
91
|
+
*/
|
|
92
|
+
create(clientId: string, write: DiscountWrite): Promise<DiscountRecord>;
|
|
93
|
+
/** Re-state a discount whole. Raises the host's not-found for a stale id. */
|
|
94
|
+
update(clientId: string, id: string, write: DiscountWrite): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* SOFT-delete. The row survives: its redemption snapshots are order history
|
|
97
|
+
* and its counter is reporting history, so removing it would either orphan
|
|
98
|
+
* redemptions or reset a number the store may still be asked to explain.
|
|
99
|
+
*/
|
|
100
|
+
archive(clientId: string, id: string): Promise<void>;
|
|
101
|
+
}
|