@12-apps/discounts 1.2.0 → 1.4.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 +231 -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 +209 -0
- package/src/react/combo-slot-builder.tsx +213 -0
- package/src/react/copy.ts +296 -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 +245 -0
- package/src/react/discount-form.tsx +393 -0
- package/src/react/discount-list-card.tsx +199 -0
- package/src/react/discount-target-picker.tsx +114 -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 +157 -0
- package/src/react/row.ts +66 -0
- package/src/react/table-config.ts +238 -0
- package/src/react/target-picker-field.tsx +138 -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
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, type JSX } from "react";
|
|
4
|
+
|
|
5
|
+
import { Autocomplete } from "@12-apps/ui/form/Autocomplete";
|
|
6
|
+
import { CategorySelect } from "@12-apps/ui/form/CategorySelect";
|
|
7
|
+
import { FormControl, FormLabel, FormMessage } from "@12-apps/ui/form/Form";
|
|
8
|
+
|
|
9
|
+
import type { WireTarget, WireTargetGroup } from "./api";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One collection's picker, in the two shapes a collection can take.
|
|
13
|
+
*
|
|
14
|
+
* Its own module because BOTH callers need it and they are not each other's
|
|
15
|
+
* parent: the scope-driven block renders one per discount, and the combo
|
|
16
|
+
* builder renders one per collection per GROUP. Importing it from either would
|
|
17
|
+
* make the two files a cycle, and copying it is how a nesting collection ends
|
|
18
|
+
* up a flat combobox in one of the two places — the exact bug this seam exists
|
|
19
|
+
* to prevent.
|
|
20
|
+
*
|
|
21
|
+
* There is no shared multi-select in `@12-apps/ui`, so the flat case composes
|
|
22
|
+
* `Autocomplete` in multiple mode — chips plus type-to-filter, the same control
|
|
23
|
+
* `AddressAutocomplete` is built on. A collection that NESTS gets
|
|
24
|
+
* `CategorySelect` instead: a flat combobox would put a subcategory beside its
|
|
25
|
+
* parent with nothing to tell them apart.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** One labelled multi-select of targets, with its own input-text state. */
|
|
29
|
+
function TargetField({
|
|
30
|
+
label,
|
|
31
|
+
placeholder,
|
|
32
|
+
requiredMessage,
|
|
33
|
+
options,
|
|
34
|
+
ids,
|
|
35
|
+
onChange,
|
|
36
|
+
dataTestId,
|
|
37
|
+
}: {
|
|
38
|
+
label: string;
|
|
39
|
+
placeholder: string;
|
|
40
|
+
requiredMessage: string | undefined;
|
|
41
|
+
options: readonly WireTarget[];
|
|
42
|
+
ids: readonly string[];
|
|
43
|
+
onChange: (next: string[]) => void;
|
|
44
|
+
dataTestId: string;
|
|
45
|
+
}): JSX.Element {
|
|
46
|
+
const [query, setQuery] = useState("");
|
|
47
|
+
const byId = new Map(options.map((option) => [option.id, option]));
|
|
48
|
+
// An id whose option is missing — a target archived since the rule was saved
|
|
49
|
+
// — still renders as a chip, so saving cannot silently drop it.
|
|
50
|
+
const selected = ids.map((id) => byId.get(id) ?? { id, name: id });
|
|
51
|
+
|
|
52
|
+
const missing = requiredMessage !== undefined && ids.length === 0;
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<FormControl fullWidth>
|
|
56
|
+
<FormLabel error={missing}>{label}</FormLabel>
|
|
57
|
+
<div data-testid={dataTestId}>
|
|
58
|
+
<Autocomplete<WireTarget>
|
|
59
|
+
multiple
|
|
60
|
+
value={query}
|
|
61
|
+
onChange={setQuery}
|
|
62
|
+
suggestions={[...options]}
|
|
63
|
+
selectedItems={selected}
|
|
64
|
+
onSelectedItemsChange={(items) => onChange(items.map((item) => item.id))}
|
|
65
|
+
getKey={(option) => option.id}
|
|
66
|
+
getLabel={(option) => option.name}
|
|
67
|
+
placeholder={placeholder}
|
|
68
|
+
inputAriaLabel={label}
|
|
69
|
+
/>
|
|
70
|
+
</div>
|
|
71
|
+
{missing && (
|
|
72
|
+
<FormMessage error dataTestId={`${dataTestId}-message`}>
|
|
73
|
+
{requiredMessage}
|
|
74
|
+
</FormMessage>
|
|
75
|
+
)}
|
|
76
|
+
</FormControl>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* One collection's picker, for whoever is asking.
|
|
82
|
+
*
|
|
83
|
+
* Extracted because the COMBO builder needs the same control per GROUP, and a
|
|
84
|
+
* second copy is how a nesting collection ends up rendered as a flat combobox
|
|
85
|
+
* in one of the two places — the bug this seam exists to prevent. The caller
|
|
86
|
+
* supplies the label and the test id, because "Categorias com desconto" and
|
|
87
|
+
* "Categorias deste grupo" are different sentences about the same control.
|
|
88
|
+
*
|
|
89
|
+
* `requiredMessage` is OPTIONAL, and that is the difference between the two
|
|
90
|
+
* callers rather than an oversight. At CATEGORY/ITEM scope an empty picker IS
|
|
91
|
+
* the error, so the message hangs on it. Inside a combo group it is not: a
|
|
92
|
+
* group naming two categories and no items is complete, so the group as a whole
|
|
93
|
+
* carries the refusal and neither picker does.
|
|
94
|
+
*/
|
|
95
|
+
export function TargetPickerField({
|
|
96
|
+
group,
|
|
97
|
+
label,
|
|
98
|
+
placeholder,
|
|
99
|
+
requiredMessage,
|
|
100
|
+
ids,
|
|
101
|
+
onChange,
|
|
102
|
+
dataTestId,
|
|
103
|
+
}: {
|
|
104
|
+
group: WireTargetGroup;
|
|
105
|
+
label: string;
|
|
106
|
+
placeholder: string;
|
|
107
|
+
requiredMessage: string | undefined;
|
|
108
|
+
ids: readonly string[];
|
|
109
|
+
onChange: (next: string[]) => void;
|
|
110
|
+
dataTestId: string;
|
|
111
|
+
}): JSX.Element {
|
|
112
|
+
if (group.nests) {
|
|
113
|
+
return (
|
|
114
|
+
<CategorySelect
|
|
115
|
+
label={label}
|
|
116
|
+
fullWidth
|
|
117
|
+
allowParentSelection
|
|
118
|
+
options={[...group.targets]}
|
|
119
|
+
value={[...ids]}
|
|
120
|
+
onChange={onChange}
|
|
121
|
+
error={requiredMessage !== undefined && ids.length === 0 ? requiredMessage : undefined}
|
|
122
|
+
dataTestId={dataTestId}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return (
|
|
127
|
+
<TargetField
|
|
128
|
+
label={label}
|
|
129
|
+
placeholder={placeholder}
|
|
130
|
+
requiredMessage={requiredMessage}
|
|
131
|
+
options={group.targets}
|
|
132
|
+
ids={ids}
|
|
133
|
+
onChange={onChange}
|
|
134
|
+
dataTestId={dataTestId}
|
|
135
|
+
/>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
@@ -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
|
+
}
|
package/src/server/copy.ts
CHANGED
|
@@ -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. */
|