@sayren/store-sdk 0.47.0 → 0.49.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/dist/index.d.mts +13232 -10717
- package/dist/index.mjs +5060 -3816
- package/dist/server.mjs +1 -1
- package/dist/{webhook-headers-CTez3xDY.mjs → webhook-headers-COVjv1yo.mjs} +113 -113
- package/package.json +1 -1
- package/src/client.ts +38 -0
- package/src/index.ts +4 -0
- package/src/schemas/condition-tree.ts +358 -0
- package/src/schemas/coupon-rules.ts +849 -0
- package/src/schemas/coupon-selection.ts +266 -0
- package/src/schemas/coupon.ts +176 -1
- package/src/schemas/order.ts +68 -1
- package/src/schemas/product-import.ts +134 -0
- package/src/schemas/promotion.ts +191 -53
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 조건 트리 (이슈 #222) — 규칙(필드 · 연산자 · 값)을 `and`·`or` 그룹으로 묶은 JSON이다. **도메인을 모른다.**
|
|
5
|
+
* 쓰는 쪽(쿠폰 등)이 필드 카탈로그(`ConditionFieldDef`)와 평가 컨텍스트를 주고, 이 파일은 형식 검증·정규화·평가만 한다.
|
|
6
|
+
*
|
|
7
|
+
* - 형식은 react-querybuilder와 같은 `{ combinator, rules: [{ field, operator, value }] }`다. CEL 같은 식 문자열은 쓰지 않는다.
|
|
8
|
+
* - 평가는 fail-closed다 — 카탈로그에 없는 필드, 컨텍스트에 없는 값(`undefined`), 읽지 못하는 값은 그 규칙을 거짓으로 둔다.
|
|
9
|
+
* - 정규형(`canonicalizeConditionGroup`)은 저장·비교의 기준이다. 화면 키(`id`)를 벗기고 키 순서를 고정하고 집합 값을 정렬한다.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type ConditionCombinator = "and" | "or";
|
|
13
|
+
|
|
14
|
+
/** 규칙 값 — JSON 스칼라나 스칼라 목록(`in` 목록, `between`의 `[from, to]`, 비운 끝은 null). 공개 응답에 그대로 실리므로 직렬화되는 값만 둔다 */
|
|
15
|
+
export type ConditionValue = number | boolean | string | null | (number | string | null)[];
|
|
16
|
+
|
|
17
|
+
export interface ConditionRule {
|
|
18
|
+
field: string;
|
|
19
|
+
operator: string;
|
|
20
|
+
value: ConditionValue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ConditionGroup {
|
|
24
|
+
combinator: ConditionCombinator;
|
|
25
|
+
rules: ConditionNode[];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type ConditionNode = ConditionRule | ConditionGroup;
|
|
29
|
+
|
|
30
|
+
export function isConditionGroup(node: ConditionNode): node is ConditionGroup {
|
|
31
|
+
return typeof node === "object" && node !== null && "combinator" in node;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 값 형식 — 연산자와 값의 모양을 함께 정한다.
|
|
36
|
+
* - `integer`: 정수(`=`·`>=`·`<=`, `between`은 `[이상, 이하]`)
|
|
37
|
+
* - `boolean`: 참·거짓(`=`)
|
|
38
|
+
* - `idSet`: 문자열 id(`in` — 값 목록 중 하나. 사실 값이 목록이면 하나라도 겹치면 참)
|
|
39
|
+
* - `enumSet`: 정해진 선택지(`in`)
|
|
40
|
+
* - `timeRange`: `HH:MM` 구간(`between` — `[시작, 끝]`, 끝 제외. 시작이 끝보다 크면 자정을 넘는 구간)
|
|
41
|
+
*/
|
|
42
|
+
export type ConditionValueType = "integer" | "boolean" | "idSet" | "enumSet" | "timeRange";
|
|
43
|
+
|
|
44
|
+
export type ConditionOperator = "=" | ">=" | "<=" | "between" | "in";
|
|
45
|
+
|
|
46
|
+
export const CONDITION_OPERATORS_BY_TYPE: Readonly<
|
|
47
|
+
Record<ConditionValueType, readonly ConditionOperator[]>
|
|
48
|
+
> = {
|
|
49
|
+
integer: ["=", ">=", "<=", "between"],
|
|
50
|
+
boolean: ["="],
|
|
51
|
+
idSet: ["in"],
|
|
52
|
+
enumSet: ["in"],
|
|
53
|
+
timeRange: ["between"],
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** 필드 정의 — `Ctx`는 쓰는 쪽의 평가 컨텍스트다 */
|
|
57
|
+
export interface ConditionFieldDef<Ctx> {
|
|
58
|
+
name: string;
|
|
59
|
+
type: ConditionValueType;
|
|
60
|
+
/** 허용 연산자 — 생략하면 값 형식의 연산자 전부 */
|
|
61
|
+
operators?: readonly ConditionOperator[];
|
|
62
|
+
/** `enumSet`의 선택지 */
|
|
63
|
+
options?: readonly string[];
|
|
64
|
+
/** `integer`의 범위(포함) */
|
|
65
|
+
min?: number;
|
|
66
|
+
max?: number;
|
|
67
|
+
/** 컨텍스트에서 사실 값을 읽는다. 모르면 `undefined`(규칙은 거짓) */
|
|
68
|
+
resolve: (ctx: Ctx) => unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type ConditionCatalog<Ctx> = ReadonlyMap<string, ConditionFieldDef<Ctx>>;
|
|
72
|
+
|
|
73
|
+
export function conditionCatalog<Ctx>(
|
|
74
|
+
fields: readonly ConditionFieldDef<Ctx>[],
|
|
75
|
+
): ConditionCatalog<Ctx> {
|
|
76
|
+
return new Map(fields.map((field) => [field.name, field]));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 형식 한도 — 정상 입력은 닿지 않는 폭주 방어선이다 */
|
|
80
|
+
export interface ConditionLimits {
|
|
81
|
+
/** 그룹 깊이(최상위 = 1) */
|
|
82
|
+
maxDepth: number;
|
|
83
|
+
/** 트리 전체의 규칙 수 */
|
|
84
|
+
maxRules: number;
|
|
85
|
+
/** 집합 값 하나의 원소 수 */
|
|
86
|
+
maxSetSize: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const DEFAULT_CONDITION_LIMITS: ConditionLimits = {
|
|
90
|
+
maxDepth: 3,
|
|
91
|
+
maxRules: 50,
|
|
92
|
+
maxSetSize: 500,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// ─── zod 형식 ─────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
/** 규칙 형식 — 값 모양은 카탈로그 검증(`conditionIssues`)이 본다 */
|
|
98
|
+
/** 값의 형식만 본다. 필드 타입에 맞는지(정수·목록 길이 등)는 `conditionIssues`가 본다 */
|
|
99
|
+
export const conditionValueSchema: z.ZodType<ConditionValue> = z.union([
|
|
100
|
+
z.number(),
|
|
101
|
+
z.boolean(),
|
|
102
|
+
z.string().max(200),
|
|
103
|
+
z.null(),
|
|
104
|
+
z
|
|
105
|
+
.array(z.union([z.number(), z.string().max(200), z.null()]))
|
|
106
|
+
.max(DEFAULT_CONDITION_LIMITS.maxSetSize),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
export const conditionRuleSchema = z.strictObject({
|
|
110
|
+
field: z.string().min(1).max(100),
|
|
111
|
+
operator: z.string().min(1).max(20),
|
|
112
|
+
value: conditionValueSchema,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 그룹 형식 — 깊이 3(`DEFAULT_CONDITION_LIMITS.maxDepth`)까지만 받는 고정 단계 스키마다. 재귀(`z.lazy`)로 두면 수천 단계로
|
|
117
|
+
* 중첩한 본문이 파싱 중에 스택을 넘는다. 더 깊으면 형식 오류(400)다
|
|
118
|
+
*/
|
|
119
|
+
const groupOf = (child: z.ZodType) =>
|
|
120
|
+
z.strictObject({
|
|
121
|
+
combinator: z.enum(["and", "or"]),
|
|
122
|
+
rules: z.array(child).max(100),
|
|
123
|
+
});
|
|
124
|
+
const level3 = groupOf(conditionRuleSchema);
|
|
125
|
+
const level2 = groupOf(z.union([conditionRuleSchema, level3]));
|
|
126
|
+
export const conditionGroupSchema = groupOf(
|
|
127
|
+
z.union([conditionRuleSchema, level2]),
|
|
128
|
+
) as unknown as z.ZodType<ConditionGroup>;
|
|
129
|
+
|
|
130
|
+
// ─── 검증 ─────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export interface ConditionIssue {
|
|
133
|
+
/** 트리 안 경로(`rules.0.value`) */
|
|
134
|
+
path: (string | number)[];
|
|
135
|
+
code: "DEPTH" | "TOO_MANY_RULES" | "UNKNOWN_FIELD" | "FIELD_NOT_ALLOWED" | "OPERATOR" | "VALUE";
|
|
136
|
+
field?: string;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
140
|
+
|
|
141
|
+
function isInteger(value: unknown, def: { min?: number; max?: number }): value is number {
|
|
142
|
+
return (
|
|
143
|
+
typeof value === "number" &&
|
|
144
|
+
Number.isInteger(value) &&
|
|
145
|
+
(def.min === undefined || value >= def.min) &&
|
|
146
|
+
(def.max === undefined || value <= def.max)
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function valueValid<Ctx>(
|
|
151
|
+
def: ConditionFieldDef<Ctx>,
|
|
152
|
+
operator: string,
|
|
153
|
+
value: unknown,
|
|
154
|
+
limits: ConditionLimits,
|
|
155
|
+
): boolean {
|
|
156
|
+
switch (def.type) {
|
|
157
|
+
case "integer":
|
|
158
|
+
if (operator === "between") {
|
|
159
|
+
return (
|
|
160
|
+
Array.isArray(value) &&
|
|
161
|
+
value.length === 2 &&
|
|
162
|
+
isInteger(value[0], def) &&
|
|
163
|
+
isInteger(value[1], def) &&
|
|
164
|
+
value[0] <= value[1]
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return isInteger(value, def);
|
|
168
|
+
case "boolean":
|
|
169
|
+
return typeof value === "boolean";
|
|
170
|
+
case "idSet":
|
|
171
|
+
case "enumSet":
|
|
172
|
+
return (
|
|
173
|
+
Array.isArray(value) &&
|
|
174
|
+
value.length > 0 &&
|
|
175
|
+
value.length <= limits.maxSetSize &&
|
|
176
|
+
value.every((v) => typeof v === "string" && v.length > 0 && v.length <= 200) &&
|
|
177
|
+
(def.type === "idSet" || value.every((v) => def.options?.includes(v as string)))
|
|
178
|
+
);
|
|
179
|
+
case "timeRange":
|
|
180
|
+
return (
|
|
181
|
+
Array.isArray(value) &&
|
|
182
|
+
value.length === 2 &&
|
|
183
|
+
value.every((v) => typeof v === "string" && TIME_PATTERN.test(v)) &&
|
|
184
|
+
value[0] !== value[1]
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 카탈로그로 트리를 검증한다 — 문제가 없으면 빈 배열. `allowed`를 주면 그 필드만 쓸 수 있다(쓰는 자리별 제한).
|
|
191
|
+
* zod `superRefine`에서 이 결과를 그대로 이슈로 옮긴다.
|
|
192
|
+
*/
|
|
193
|
+
export function conditionIssues<Ctx>(
|
|
194
|
+
group: ConditionGroup,
|
|
195
|
+
catalog: ConditionCatalog<Ctx>,
|
|
196
|
+
options: { allowed?: (field: string) => boolean; limits?: ConditionLimits } = {},
|
|
197
|
+
): ConditionIssue[] {
|
|
198
|
+
const limits = options.limits ?? DEFAULT_CONDITION_LIMITS;
|
|
199
|
+
const issues: ConditionIssue[] = [];
|
|
200
|
+
let ruleCount = 0;
|
|
201
|
+
const walk = (node: ConditionGroup, depth: number, path: (string | number)[]) => {
|
|
202
|
+
if (depth > limits.maxDepth) {
|
|
203
|
+
issues.push({ path, code: "DEPTH" });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
node.rules.forEach((child, index) => {
|
|
207
|
+
const at = [...path, "rules", index];
|
|
208
|
+
if (isConditionGroup(child)) {
|
|
209
|
+
walk(child, depth + 1, at);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
ruleCount += 1;
|
|
213
|
+
const def = catalog.get(child.field);
|
|
214
|
+
if (!def) {
|
|
215
|
+
issues.push({ path: [...at, "field"], code: "UNKNOWN_FIELD", field: child.field });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (options.allowed && !options.allowed(child.field)) {
|
|
219
|
+
issues.push({ path: [...at, "field"], code: "FIELD_NOT_ALLOWED", field: child.field });
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const operators = def.operators ?? CONDITION_OPERATORS_BY_TYPE[def.type];
|
|
223
|
+
if (!operators.includes(child.operator as ConditionOperator)) {
|
|
224
|
+
issues.push({ path: [...at, "operator"], code: "OPERATOR", field: child.field });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
if (!valueValid(def, child.operator, child.value, limits)) {
|
|
228
|
+
issues.push({ path: [...at, "value"], code: "VALUE", field: child.field });
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
};
|
|
232
|
+
walk(group, 1, []);
|
|
233
|
+
if (ruleCount > limits.maxRules) issues.push({ path: [], code: "TOO_MANY_RULES" });
|
|
234
|
+
return issues;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── 정규형 ───────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/** 정규형 — 화면 키 등 모르는 키를 버리고 키 순서를 고정하고 `in` 값의 중복을 없애 정렬한다. 저장·잠금 비교·스냅샷이 같은 함수다 */
|
|
240
|
+
export function canonicalizeConditionGroup(group: ConditionGroup): ConditionGroup {
|
|
241
|
+
return {
|
|
242
|
+
combinator: group.combinator,
|
|
243
|
+
rules: group.rules.map((node) =>
|
|
244
|
+
isConditionGroup(node) ? canonicalizeConditionGroup(node) : canonicalizeConditionRule(node),
|
|
245
|
+
),
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function canonicalizeConditionRule(rule: ConditionRule): ConditionRule {
|
|
250
|
+
const value =
|
|
251
|
+
rule.operator === "in" && Array.isArray(rule.value)
|
|
252
|
+
? [...new Set(rule.value as string[])].sort()
|
|
253
|
+
: rule.value;
|
|
254
|
+
return { field: rule.field, operator: rule.operator, value };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** 트리에 나온 필드 이름 — 쓰는 쪽이 필요한 사실 값만 읽는 데 쓴다 */
|
|
258
|
+
export function conditionFields(group: ConditionGroup): Set<string> {
|
|
259
|
+
const fields = new Set<string>();
|
|
260
|
+
const walk = (node: ConditionGroup) => {
|
|
261
|
+
for (const child of node.rules) {
|
|
262
|
+
if (isConditionGroup(child)) walk(child);
|
|
263
|
+
else fields.add(child.field);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
walk(group);
|
|
267
|
+
return fields;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── 평가 ─────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
function minutesOf(time: string): number | null {
|
|
273
|
+
if (!TIME_PATTERN.test(time)) return null;
|
|
274
|
+
const [h, m] = time.split(":").map(Number) as [number, number];
|
|
275
|
+
return h * 60 + m;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function ruleHolds<Ctx>(rule: ConditionRule, catalog: ConditionCatalog<Ctx>, ctx: Ctx): boolean {
|
|
279
|
+
const def = catalog.get(rule.field);
|
|
280
|
+
if (!def) return false;
|
|
281
|
+
const fact = def.resolve(ctx);
|
|
282
|
+
if (fact === undefined || fact === null) return false;
|
|
283
|
+
const { operator, value } = rule;
|
|
284
|
+
switch (def.type) {
|
|
285
|
+
case "integer": {
|
|
286
|
+
if (typeof fact !== "number" || Number.isNaN(fact)) return false;
|
|
287
|
+
if (operator === "=") return typeof value === "number" && fact === value;
|
|
288
|
+
if (operator === ">=") return typeof value === "number" && fact >= value;
|
|
289
|
+
if (operator === "<=") return typeof value === "number" && fact <= value;
|
|
290
|
+
if (operator === "between" && Array.isArray(value)) {
|
|
291
|
+
const [from, to] = value as [unknown, unknown];
|
|
292
|
+
return typeof from === "number" && typeof to === "number" && fact >= from && fact <= to;
|
|
293
|
+
}
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
case "boolean":
|
|
297
|
+
return operator === "=" && typeof value === "boolean" && fact === value;
|
|
298
|
+
case "idSet":
|
|
299
|
+
case "enumSet": {
|
|
300
|
+
if (operator !== "in" || !Array.isArray(value)) return false;
|
|
301
|
+
const wanted = value as unknown[];
|
|
302
|
+
const facts = Array.isArray(fact) ? fact : [fact];
|
|
303
|
+
return facts.some((f) => wanted.includes(f));
|
|
304
|
+
}
|
|
305
|
+
case "timeRange": {
|
|
306
|
+
if (operator !== "between" || !Array.isArray(value) || typeof fact !== "string") return false;
|
|
307
|
+
const at = minutesOf(fact);
|
|
308
|
+
const from = typeof value[0] === "string" ? minutesOf(value[0]) : null;
|
|
309
|
+
const to = typeof value[1] === "string" ? minutesOf(value[1]) : null;
|
|
310
|
+
if (at === null || from === null || to === null) return false;
|
|
311
|
+
// 끝 제외. 시작 > 끝이면 자정을 넘는 구간(22:00~02:00)
|
|
312
|
+
return from < to ? at >= from && at < to : at >= from || at < to;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** 노드 하나를 평가한다. 빈 그룹은 참(조건 없음)이다 */
|
|
318
|
+
export function evaluateConditionNode<Ctx>(
|
|
319
|
+
node: ConditionNode,
|
|
320
|
+
catalog: ConditionCatalog<Ctx>,
|
|
321
|
+
ctx: Ctx,
|
|
322
|
+
): boolean {
|
|
323
|
+
if (!isConditionGroup(node)) return ruleHolds(node, catalog, ctx);
|
|
324
|
+
if (node.rules.length === 0) return true;
|
|
325
|
+
return node.combinator === "and"
|
|
326
|
+
? node.rules.every((child) => evaluateConditionNode(child, catalog, ctx))
|
|
327
|
+
: node.rules.some((child) => evaluateConditionNode(child, catalog, ctx));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface ConditionEvaluation {
|
|
331
|
+
passed: boolean;
|
|
332
|
+
/**
|
|
333
|
+
* 최상위가 `and`일 때 실패한 최상위 노드(앞에서부터). 쓰는 쪽이 사유 코드를 고르는 데 쓴다.
|
|
334
|
+
* 최상위가 `or`이면 어느 하나를 탓할 수 없어 비어 있다.
|
|
335
|
+
*/
|
|
336
|
+
failed: ConditionNode[];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function evaluateCondition<Ctx>(
|
|
340
|
+
group: ConditionGroup,
|
|
341
|
+
catalog: ConditionCatalog<Ctx>,
|
|
342
|
+
ctx: Ctx,
|
|
343
|
+
): ConditionEvaluation {
|
|
344
|
+
if (group.combinator === "and" || group.rules.length <= 1) {
|
|
345
|
+
const failed = group.rules.filter((child) => !evaluateConditionNode(child, catalog, ctx));
|
|
346
|
+
return { passed: failed.length === 0, failed };
|
|
347
|
+
}
|
|
348
|
+
return { passed: evaluateConditionNode(group, catalog, ctx), failed: [] };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** 하나라도 맞는가 — 평평한 규칙 목록(포함·제외 대상)용. 빈 목록은 거짓이다 */
|
|
352
|
+
export function anyConditionHolds<Ctx>(
|
|
353
|
+
rules: readonly ConditionRule[],
|
|
354
|
+
catalog: ConditionCatalog<Ctx>,
|
|
355
|
+
ctx: Ctx,
|
|
356
|
+
): boolean {
|
|
357
|
+
return rules.some((rule) => ruleHolds(rule, catalog, ctx));
|
|
358
|
+
}
|