@sayren/store-sdk 0.1.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.
@@ -0,0 +1,272 @@
1
+ import { z } from "zod";
2
+ import type { PageParams } from "./common";
3
+
4
+ export const productStatusSchema = z.enum(["SALE", "SUSPENDED", "OUT_OF_STOCK", "CLOSED"]);
5
+
6
+ export type ProductStatus = z.infer<typeof productStatusSchema>;
7
+
8
+ export const deliveryTypeSchema = z.enum(["FREE", "PAID", "CONDITIONAL_FREE"]);
9
+
10
+ export type DeliveryType = z.infer<typeof deliveryTypeSchema>;
11
+
12
+ export const productSummarySchema = z.object({
13
+ productId: z.string(),
14
+ name: z.string(),
15
+ status: productStatusSchema,
16
+ salePrice: z.number().int(),
17
+ discountedPrice: z.number().int().nullable(),
18
+ /** usable variant 재고 합계 (서버 계산) */
19
+ stockQuantity: z.number().int(),
20
+ categoryId: z.string().nullable(),
21
+ thumbnailUrl: z.url().optional(),
22
+ /** optionGroups.length > 0 */
23
+ hasOptions: z.boolean(),
24
+ updatedAt: z.string(),
25
+ });
26
+
27
+ export type ProductSummary = z.infer<typeof productSummarySchema>;
28
+
29
+ /** 옵션 축(그룹)이 가지는 값 하나 — 예: 축 "색상"의 값 "블랙" */
30
+ export const optionValueSchema = z.object({
31
+ valueId: z.string(),
32
+ name: z.string(),
33
+ sortOrder: z.number().int(),
34
+ });
35
+
36
+ export type OptionValue = z.infer<typeof optionValueSchema>;
37
+
38
+ /** 옵션 축(그룹) — 예: "색상", "사이즈" */
39
+ export const optionGroupSchema = z.object({
40
+ groupId: z.string(),
41
+ name: z.string(),
42
+ sortOrder: z.number().int(),
43
+ values: z.array(optionValueSchema),
44
+ });
45
+
46
+ export type OptionGroup = z.infer<typeof optionGroupSchema>;
47
+
48
+ /**
49
+ * 판매 단위(SKU). variantId는 기존 optionId 값 공간을 승계하므로
50
+ * 카트·주문·리뷰·클레임의 optionId가 곧 variantId다.
51
+ */
52
+ export const productVariantSchema = z.object({
53
+ variantId: z.string(),
54
+ /** 축 sortOrder 순서와 1:1 대응. 옵션 없는 상품은 [] */
55
+ valueIds: z.array(z.string()),
56
+ /** 값 이름을 " / "로 이은 파생 표시명. 옵션 없으면 "" */
57
+ name: z.string(),
58
+ additionalPrice: z.number().int(),
59
+ stockQuantity: z.number().int(),
60
+ sku: z.string().nullable(),
61
+ barcode: z.string().nullable(),
62
+ costPrice: z.number().int().nullable(),
63
+ weightGram: z.number().int().nullable(),
64
+ sellerCode: z.string().nullable(),
65
+ usable: z.boolean(),
66
+ });
67
+
68
+ export type ProductVariant = z.infer<typeof productVariantSchema>;
69
+
70
+ export const productSchema = productSummarySchema.extend({
71
+ description: z.string().optional(),
72
+ /**
73
+ * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
74
+ * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
75
+ */
76
+ descriptionJson: z.string().nullable().optional(),
77
+ originalPrice: z.number().int().nullable().optional(),
78
+ images: z.array(z.url()).optional(),
79
+ attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
80
+ optionGroups: z.array(optionGroupSchema),
81
+ /** 모든 상품이 최소 1개 variant를 갖는다 (옵션 없으면 default variant 1개) */
82
+ variants: z.array(productVariantSchema),
83
+ deliveryType: deliveryTypeSchema.optional(),
84
+ deliveryFee: z.number().int().optional(),
85
+ conditionalFreeAmount: z.number().int().nullable().optional(),
86
+ returnFee: z.number().int().optional(),
87
+ exchangeFee: z.number().int().optional(),
88
+ shippingAddressId: z.string().nullable().optional(),
89
+ returnAddressId: z.string().nullable().optional(),
90
+ createdAt: z.string().optional(),
91
+ });
92
+
93
+ export type Product = z.infer<typeof productSchema>;
94
+
95
+ /** 옵션 축(그룹) 최대 개수 — 스키마 상한의 단일 원천 */
96
+ export const MAX_OPTION_GROUPS = 3;
97
+ /** 축 하나가 가질 수 있는 값의 최대 개수 */
98
+ export const MAX_VALUES_PER_GROUP = 100;
99
+ /** 상품 하나가 가질 수 있는 variant(SKU) 최대 개수 */
100
+ export const MAX_VARIANTS = 500;
101
+
102
+ /**
103
+ * 식별 코드 — 빈 문자열·공백만 있는 값은 거부한다.
104
+ * `""`를 통과시키면 서버 중복 사전 검사(falsy skip)와 DB partial unique
105
+ * (`WHERE ... is not null`)의 판정이 갈려 409 대신 500이 나간다.
106
+ */
107
+ const codeSchema = z.string().trim().min(1).max(64);
108
+
109
+ export const optionValueRequestSchema = z.object({
110
+ /** 전달 시 기존 값 행을 유지(UPDATE). 생략하면 신규 발급 */
111
+ valueId: z.string().optional(),
112
+ name: z.string().min(1).max(50),
113
+ });
114
+
115
+ export type OptionValueRequest = z.infer<typeof optionValueRequestSchema>;
116
+
117
+ export const optionGroupRequestSchema = z.object({
118
+ /** 전달 시 기존 축 행을 유지(UPDATE). 생략하면 신규 발급 */
119
+ groupId: z.string().optional(),
120
+ name: z.string().min(1).max(50),
121
+ values: z.array(optionValueRequestSchema).min(1).max(MAX_VALUES_PER_GROUP),
122
+ });
123
+
124
+ export type OptionGroupRequest = z.infer<typeof optionGroupRequestSchema>;
125
+
126
+ export const productVariantRequestSchema = z.object({
127
+ /** 기존 variant 유지 시 전달. 생략하면 신규 생성 */
128
+ variantId: z.string().optional(),
129
+ /** optionGroups 순서대로 각 축에서 고른 값 이름. 서버가 이름 → valueId를 해석한다 */
130
+ valueNames: z.array(z.string()).default([]),
131
+ additionalPrice: z.number().int().default(0),
132
+ /**
133
+ * 생략 = 기존 재고 유지 (신규 variant는 0).
134
+ * 구조 교체(PUT)가 폼 로드 시점의 재고를 되쓰지 않게 하는 옵트인 필드다 —
135
+ * 재고만 바꿀 때는 PATCH /products/{id}/variants/{variantId}/stock을 쓴다.
136
+ */
137
+ stockQuantity: z.number().int().min(0).optional(),
138
+ sku: codeSchema.optional(),
139
+ barcode: codeSchema.optional(),
140
+ costPrice: z.number().int().min(0).optional(),
141
+ weightGram: z.number().int().min(0).optional(),
142
+ sellerCode: codeSchema.optional(),
143
+ usable: z.boolean().default(true),
144
+ });
145
+
146
+ export type ProductVariantRequest = z.infer<typeof productVariantRequestSchema>;
147
+
148
+ /** 축 × 값 → variant 전체 교체 요청 (PUT /products/{productId}/options) */
149
+ export const optionModelRequestSchema = z.object({
150
+ optionGroups: z.array(optionGroupRequestSchema).max(MAX_OPTION_GROUPS).default([]),
151
+ variants: z.array(productVariantRequestSchema).min(1).max(MAX_VARIANTS),
152
+ });
153
+
154
+ export type OptionModelRequest = z.infer<typeof optionModelRequestSchema>;
155
+
156
+ /** GET /products/{productId}/options 응답 */
157
+ export const optionModelSchema = z.object({
158
+ optionGroups: z.array(optionGroupSchema),
159
+ variants: z.array(productVariantSchema),
160
+ });
161
+
162
+ export type OptionModel = z.infer<typeof optionModelSchema>;
163
+
164
+ /**
165
+ * 재고 변경 택일 위반 문구 — SDK `.refine`과 서버 DomainException이 공유하는 단일 원천.
166
+ * 경로에 따라 셀러가 다른 문구를 받지 않게 한다.
167
+ */
168
+ export const VARIANT_STOCK_EXCLUSIVE_MESSAGE =
169
+ "stockQuantity 또는 adjustment 중 하나만 전달해야 합니다";
170
+
171
+ /**
172
+ * variant 재고 변경 요청 — 절대값(`stockQuantity`) 또는 증감(`adjustment`) 택일.
173
+ * 서버도 같은 규칙을 400 VALIDATION_FAILED로 검증한다(이중 방어).
174
+ */
175
+ export const variantStockRequestSchema = z
176
+ .object({
177
+ stockQuantity: z.number().int().min(0).optional(),
178
+ adjustment: z.number().int().optional(),
179
+ })
180
+ .refine((v) => (v.stockQuantity !== undefined) !== (v.adjustment !== undefined), {
181
+ message: VARIANT_STOCK_EXCLUSIVE_MESSAGE,
182
+ });
183
+
184
+ export type VariantStockRequest = z.infer<typeof variantStockRequestSchema>;
185
+
186
+ /** PATCH /products/{productId}/variants/{variantId}/stock 응답 */
187
+ export const variantStockSchema = z.object({
188
+ variantId: z.string(),
189
+ stockQuantity: z.number().int(),
190
+ });
191
+
192
+ export type VariantStock = z.infer<typeof variantStockSchema>;
193
+
194
+ export const productCreateRequestSchema = z
195
+ .object({
196
+ name: z.string().min(1).max(100),
197
+ categoryId: z.string().min(1, "카테고리를 선택해주세요"),
198
+ salePrice: z
199
+ .number()
200
+ .int()
201
+ .min(10)
202
+ .multipleOf(10, "판매가는 10원 단위여야 합니다 (INVALID_PRICE_UNIT)"),
203
+ originalPrice: z.number().int().optional(),
204
+ description: z.string().optional(),
205
+ /**
206
+ * 상세설명 에디터(@avarlabs/editor)의 직렬화 상태. 재편집 때 이 값으로 문서를 복원한다.
207
+ * 표시는 `description`(HTML)이 맡는다 — 소비자는 이 필드를 몰라도 된다.
208
+ */
209
+ descriptionJson: z.string().nullable().optional(),
210
+ images: z.array(z.url()).min(1).max(10).optional(),
211
+ attributes: z.array(z.object({ attributeId: z.string(), value: z.string() })).optional(),
212
+ deliveryType: deliveryTypeSchema.default("PAID"),
213
+ deliveryFee: z.number().int().default(0),
214
+ conditionalFreeAmount: z.number().int().optional(),
215
+ returnFee: z.number().int().default(0),
216
+ exchangeFee: z.number().int().default(0),
217
+ shippingAddressId: z.string().optional(),
218
+ returnAddressId: z.string().optional(),
219
+ })
220
+ .extend(optionModelRequestSchema.shape);
221
+
222
+ export type ProductCreateRequest = z.infer<typeof productCreateRequestSchema>;
223
+
224
+ export const productPatchRequestSchema = z
225
+ .object({
226
+ name: z.string().max(100).optional(),
227
+ salePrice: z.number().int().multipleOf(10).optional(),
228
+ status: productStatusSchema.optional(),
229
+ deliveryFee: z.number().int().optional(),
230
+ })
231
+ .refine((value) => Object.values(value).some((v) => v !== undefined), {
232
+ message: "최소 1개 필드가 필요합니다",
233
+ });
234
+
235
+ export type ProductPatchRequest = z.infer<typeof productPatchRequestSchema>;
236
+
237
+ export type ProductSort =
238
+ | "createdAt.desc"
239
+ | "createdAt.asc"
240
+ | "salePrice.asc"
241
+ | "salePrice.desc"
242
+ | "updatedAt.desc";
243
+
244
+ export interface ProductListParams extends PageParams {
245
+ status?: ProductStatus;
246
+ categoryId?: string;
247
+ keyword?: string;
248
+ sellerCode?: string;
249
+ sort?: ProductSort;
250
+ }
251
+
252
+ export const discountSchema = z.object({
253
+ productId: z.string(),
254
+ discountType: z.enum(["RATE", "AMOUNT"]),
255
+ discountValue: z.number().int(),
256
+ discountedPrice: z.number().int(),
257
+ startAt: z.string().nullable(),
258
+ endAt: z.string().nullable(),
259
+ });
260
+
261
+ export type Discount = z.infer<typeof discountSchema>;
262
+
263
+ export const discountRequestSchema = z.object({
264
+ discountType: z.enum(["RATE", "AMOUNT"]),
265
+ discountValue: z.number().int().min(1),
266
+ startAt: z.string().optional(),
267
+ endAt: z.string().optional(),
268
+ });
269
+
270
+ export type DiscountRequest = z.infer<typeof discountRequestSchema>;
271
+
272
+ // 카테고리 스키마는 ./category 로 이관됨 (배럴에서 노출).
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import type { StoreRole } from "./auth";
3
+
4
+ /**
5
+ * 스토어 스코프 카탈로그 (auth-spec §7) — 형식 `{리소스}:{r|rw}`, rw는 r을 함의한다.
6
+ *
7
+ * 관리 API가 요구하는 스코프와 역할별 자동 부여 스코프의 **단일 원천**이다.
8
+ * 서버(apps/api)가 이 상수를 그대로 가져다 역할→스코프를 산출하므로, 콘솔 권한 매트릭스와
9
+ * 서버 인가가 갈라지지 않는다.
10
+ */
11
+
12
+ export const scopeAccessSchema = z
13
+ .enum(["r", "rw"])
14
+ .describe("접근 수준. `r`=읽기, `rw`=읽기+쓰기(r을 함의)");
15
+ export type ScopeAccess = z.infer<typeof scopeAccessSchema>;
16
+
17
+ /** 리소스별로 부여 가능한 접근 수준. settlement는 쓰기 API가 없어 `r`만 존재한다 */
18
+ export const STORE_SCOPE_RESOURCES = {
19
+ product: ["r", "rw"],
20
+ category: ["r", "rw"],
21
+ order: ["r", "rw"],
22
+ claim: ["r", "rw"],
23
+ inquiry: ["r", "rw"],
24
+ review: ["r", "rw"],
25
+ settlement: ["r"],
26
+ webhook: ["r", "rw"],
27
+ store: ["r", "rw"],
28
+ member: ["r", "rw"],
29
+ } as const satisfies Record<string, readonly ScopeAccess[]>;
30
+
31
+ export type StoreScopeResource = keyof typeof STORE_SCOPE_RESOURCES;
32
+
33
+ export const storeScopeResourceSchema = z
34
+ .enum(Object.keys(STORE_SCOPE_RESOURCES) as [StoreScopeResource, ...StoreScopeResource[]])
35
+ .describe("스코프 리소스");
36
+
37
+ /** 카탈로그상 존재하는 스코프 문자열인가 (예: `settlement:rw`는 false) */
38
+ export function isStoreScope(scope: string): boolean {
39
+ const [resource, access, ...rest] = scope.split(":");
40
+ if (rest.length > 0 || !resource || !access) return false;
41
+ const allowed = (STORE_SCOPE_RESOURCES as Record<string, readonly string[]>)[resource];
42
+ return allowed?.includes(access) ?? false;
43
+ }
44
+
45
+ /**
46
+ * 역할별 자동 부여 스코프 (auth-spec §7).
47
+ * storeToken(사람)의 스코프는 이 표로 결정된다. PAT·M2M 클라이언트에는 역할이 없고
48
+ * 발급 시 지정한 스코프만 갖는다(발급자 보유 스코프 이내).
49
+ */
50
+ export const STORE_ROLE_SCOPES = {
51
+ OWNER: [
52
+ "product:rw",
53
+ "category:rw",
54
+ "order:rw",
55
+ "claim:rw",
56
+ "inquiry:rw",
57
+ "review:rw",
58
+ "settlement:r",
59
+ "webhook:rw",
60
+ "store:rw",
61
+ "member:rw",
62
+ ],
63
+ ADMIN: [
64
+ "product:rw",
65
+ "category:rw",
66
+ "order:rw",
67
+ "claim:rw",
68
+ "inquiry:rw",
69
+ "review:rw",
70
+ "webhook:rw",
71
+ "store:rw",
72
+ "member:rw",
73
+ ],
74
+ STAFF: ["product:rw", "category:rw", "order:rw", "claim:rw", "inquiry:rw", "review:rw"],
75
+ } as const satisfies Record<StoreRole, readonly string[]>;
@@ -0,0 +1,143 @@
1
+ import { z } from "zod";
2
+ import { storeRoleSchema } from "./auth";
3
+
4
+ /**
5
+ * 스토어 코드 규칙 — 스토어프론트 API가 테넌트를 가르는 값이다(`X-Store-Code` 헤더).
6
+ * **개설할 때 서버가 랜덤으로 발급하고** 이후 바꿀 수 없다. 이미 나간 링크와 승인된 결제가
7
+ * 이 값에 묶여 있어서 바꾸면 함께 깨진다.
8
+ *
9
+ * 호스트 라벨로도 쓸 수 있게 DNS 라벨 규칙까지 좁힌다. 하이픈으로 시작하거나 끝나는 라벨은 호스트가 될 수 없다.
10
+ */
11
+ export const STORE_CODE_PATTERN = /^[a-z0-9](?:[a-z0-9-]{1,28}[a-z0-9])$/;
12
+
13
+ /** 플랫폼이 쓰거나 쓸 호스트 이름 — 스토어 코드로 내주지 않는다 */
14
+ export const RESERVED_STORE_CODES: readonly string[] = [
15
+ "www",
16
+ "api",
17
+ "store",
18
+ "stores",
19
+ "checkout",
20
+ "docs",
21
+ "admin",
22
+ "app",
23
+ "console",
24
+ "dashboard",
25
+ "platform",
26
+ "internal",
27
+ "auth",
28
+ "login",
29
+ "oauth",
30
+ "account",
31
+ "accounts",
32
+ "pay",
33
+ "payment",
34
+ "payments",
35
+ "webhook",
36
+ "webhooks",
37
+ "status",
38
+ "support",
39
+ "help",
40
+ "blog",
41
+ "cdn",
42
+ "static",
43
+ "assets",
44
+ "img",
45
+ "images",
46
+ "media",
47
+ "mail",
48
+ "smtp",
49
+ "ftp",
50
+ "ns",
51
+ "ns1",
52
+ "ns2",
53
+ "mx",
54
+ "dev",
55
+ "test",
56
+ "staging",
57
+ "stage",
58
+ "sandbox",
59
+ ];
60
+
61
+ export function isReservedStoreCode(code: string): boolean {
62
+ return RESERVED_STORE_CODES.includes(code.toLowerCase());
63
+ }
64
+
65
+ export const storeCodeSchema = z
66
+ .string()
67
+ .regex(
68
+ STORE_CODE_PATTERN,
69
+ "소문자·숫자·하이픈 3~30자여야 하고 하이픈으로 시작하거나 끝날 수 없어요",
70
+ )
71
+ .refine(
72
+ (code) => !isReservedStoreCode(code),
73
+ "플랫폼이 쓰는 이름이라 스토어 코드로 쓸 수 없어요",
74
+ );
75
+
76
+ export const storeSchema = z.object({
77
+ storeId: z.string(),
78
+ name: z.string(),
79
+ storeCode: z.string(),
80
+ storefrontUrl: z.url(),
81
+ status: z.enum(["ACTIVE", "SUSPENDED", "CLOSED"]),
82
+ logoUrl: z.url().nullable(),
83
+ businessNumber: z.string().nullable(),
84
+ customerCenterPhone: z.string().nullable(),
85
+ memberCount: z.number().int(),
86
+ createdAt: z.string(),
87
+ });
88
+
89
+ export type Store = z.infer<typeof storeSchema>;
90
+
91
+ export const storeMemberSchema = z.object({
92
+ userId: z.string(),
93
+ name: z.string(),
94
+ email: z.email(),
95
+ role: storeRoleSchema,
96
+ joinedAt: z.string(),
97
+ lastActiveAt: z.string().nullable(),
98
+ });
99
+
100
+ export type StoreMember = z.infer<typeof storeMemberSchema>;
101
+
102
+ export const invitationSchema = z.object({
103
+ invitationId: z.string(),
104
+ email: z.email(),
105
+ role: z.enum(["ADMIN", "STAFF"]),
106
+ status: z.enum(["PENDING", "ACCEPTED", "EXPIRED", "CANCELED"]),
107
+ invitedBy: z.string(),
108
+ expiresAt: z.string(),
109
+ createdAt: z.string(),
110
+ });
111
+
112
+ export type Invitation = z.infer<typeof invitationSchema>;
113
+
114
+ export const addressTypeSchema = z.enum(["SHIPPING", "RETURN"]);
115
+
116
+ export type AddressType = z.infer<typeof addressTypeSchema>;
117
+
118
+ export const sellerAddressSchema = z.object({
119
+ addressId: z.string(),
120
+ type: addressTypeSchema,
121
+ name: z.string(),
122
+ isDefault: z.boolean(),
123
+ contactName: z.string(),
124
+ phone: z.string(),
125
+ zipCode: z.string(),
126
+ address1: z.string(),
127
+ address2: z.string().optional(),
128
+ createdAt: z.string(),
129
+ });
130
+
131
+ export type SellerAddress = z.infer<typeof sellerAddressSchema>;
132
+
133
+ export const sellerAddressRequestSchema = z.object({
134
+ type: addressTypeSchema,
135
+ name: z.string().min(1).max(50),
136
+ contactName: z.string().min(1),
137
+ phone: z.string().regex(/^01[0-9]{8,9}$/),
138
+ zipCode: z.string().regex(/^[0-9]{5}$/),
139
+ address1: z.string().min(1),
140
+ address2: z.string().optional(),
141
+ });
142
+
143
+ export type SellerAddressRequest = z.infer<typeof sellerAddressRequestSchema>;
@@ -0,0 +1,23 @@
1
+ import { z } from "zod";
2
+
3
+ /**
4
+ * 미디어 업로드 자리 발급 — `POST /v1/uploads`.
5
+ *
6
+ * 파일 바이트는 api를 거치지 않는다. 응답의 `uploadUrl`로 클라이언트가 직접 PUT하고,
7
+ * 끝나면 `url`이 공개 주소가 된다. 서버가 객체 키를 정하므로 요청은 파일명만 보낸다
8
+ * (확장자만 쓰이고 이름 자체는 저장되지 않는다).
9
+ */
10
+ export const uploadRequestSchema = z.object({
11
+ filename: z.string().min(1).max(255),
12
+ });
13
+
14
+ export type UploadRequest = z.infer<typeof uploadRequestSchema>;
15
+
16
+ export const uploadTicketSchema = z.object({
17
+ /** 업로드 완료 후 공개될 주소 */
18
+ url: z.url(),
19
+ /** 파일 바이트를 PUT할 presigned URL. 1시간 유효 */
20
+ uploadUrl: z.url(),
21
+ });
22
+
23
+ export type UploadTicket = z.infer<typeof uploadTicketSchema>;
@@ -0,0 +1,145 @@
1
+ import { z } from "zod";
2
+ import { claimStatusSchema, claimTypeSchema } from "./claim";
3
+ import { orderItemStatusSchema } from "./common";
4
+
5
+ /**
6
+ * 웹훅 발송 형식 (공개 계약). 본문은 얇은 이벤트다. 개인정보를 싣지 않으며
7
+ * 수신자는 `data`의 id로 관리 API를 조회해 최신 상태를 얻는다.
8
+ *
9
+ * 서명 생성·검증은 시크릿을 다루므로 서버 엔트리포인트(`@sayren/store-sdk/server`)에 있다.
10
+ */
11
+
12
+ export {
13
+ WEBHOOK_DEFAULT_TOLERANCE_SEC,
14
+ WEBHOOK_DELIVERY_ID_HEADER,
15
+ WEBHOOK_EVENT_ID_HEADER,
16
+ WEBHOOK_EVENT_TYPE_HEADER,
17
+ WEBHOOK_HEADERS,
18
+ WEBHOOK_SIGNATURE_HEADER,
19
+ WEBHOOK_USER_AGENT,
20
+ } from "../webhook-headers";
21
+
22
+ const envelopeFields = {
23
+ id: z.string().describe("이벤트 ID (`evt_…`, 테스트 전송은 `evt_test_…`). 중복 제거 키"),
24
+ createdAt: z.string().describe("이벤트 발생 일시 (ISO 8601)"),
25
+ storeId: z.string().describe("이벤트가 발생한 스토어 ID"),
26
+ };
27
+
28
+ export const orderWebhookEventDataSchema = z.object({
29
+ orderId: z.string().describe("주문 ID"),
30
+ status: orderItemStatusSchema.describe("이벤트 발생 시점의 주문 대표 상태"),
31
+ testPayment: z
32
+ .boolean()
33
+ .optional()
34
+ .default(false)
35
+ .describe(
36
+ "테스트 결제(샌드박스 결제) 주문인가. true면 실제로 돈이 오가지 않았다. 이 필드가 없는 이전 이벤트는 false로 읽는다",
37
+ ),
38
+ });
39
+ export type OrderWebhookEventData = z.infer<typeof orderWebhookEventDataSchema>;
40
+
41
+ export const claimWebhookEventDataSchema = z.object({
42
+ claimId: z.string().describe("클레임 ID"),
43
+ orderId: z.string().describe("주문 ID"),
44
+ orderItemId: z.string().describe("클레임 대상 주문 상품 ID"),
45
+ claimType: claimTypeSchema.describe("클레임 유형"),
46
+ status: claimStatusSchema.describe("이벤트 발생 시점의 클레임 상태"),
47
+ testPayment: z
48
+ .boolean()
49
+ .optional()
50
+ .default(false)
51
+ .describe(
52
+ "테스트 결제(샌드박스 결제) 주문의 클레임인가. 이 필드가 없는 이전 이벤트는 false로 읽는다",
53
+ ),
54
+ });
55
+ export type ClaimWebhookEventData = z.infer<typeof claimWebhookEventDataSchema>;
56
+
57
+ export const inquiryWebhookEventDataSchema = z.object({
58
+ inquiryId: z.string().describe("상품 문의 ID"),
59
+ productId: z.string().describe("상품 ID"),
60
+ });
61
+ export type InquiryWebhookEventData = z.infer<typeof inquiryWebhookEventDataSchema>;
62
+
63
+ export const reviewWebhookEventDataSchema = z.object({
64
+ reviewId: z.string().describe("리뷰 ID"),
65
+ productId: z.string().describe("상품 ID"),
66
+ rating: z.number().int().min(1).max(5).describe("별점 (1~5)"),
67
+ });
68
+ export type ReviewWebhookEventData = z.infer<typeof reviewWebhookEventDataSchema>;
69
+
70
+ export const productOutOfStockWebhookEventDataSchema = z.object({
71
+ productId: z.string().describe("상품 ID"),
72
+ variantId: z
73
+ .string()
74
+ .describe("재고가 0이 된 판매 단위(variant) ID. 주문 상품의 `optionId`와 같은 값 공간이다"),
75
+ });
76
+ export type ProductOutOfStockWebhookEventData = z.infer<
77
+ typeof productOutOfStockWebhookEventDataSchema
78
+ >;
79
+
80
+ export const orderWebhookEventSchema = z.object({
81
+ ...envelopeFields,
82
+ type: z.enum([
83
+ "ORDER.PAID",
84
+ "ORDER.CONFIRMED",
85
+ "ORDER.DISPATCHED",
86
+ "ORDER.DELIVERED",
87
+ "ORDER.PURCHASE_DECIDED",
88
+ "ORDER.ADDRESS_CHANGE_REQUESTED",
89
+ ]),
90
+ data: orderWebhookEventDataSchema,
91
+ });
92
+
93
+ export const claimWebhookEventSchema = z.object({
94
+ ...envelopeFields,
95
+ type: z.enum([
96
+ "CLAIM.CANCEL_REQUESTED",
97
+ "CLAIM.RETURN_REQUESTED",
98
+ "CLAIM.EXCHANGE_REQUESTED",
99
+ "CLAIM.COMPLETED",
100
+ ]),
101
+ data: claimWebhookEventDataSchema,
102
+ });
103
+
104
+ export const inquiryWebhookEventSchema = z.object({
105
+ ...envelopeFields,
106
+ type: z.literal("INQUIRY.CREATED"),
107
+ data: inquiryWebhookEventDataSchema,
108
+ });
109
+
110
+ export const reviewWebhookEventSchema = z.object({
111
+ ...envelopeFields,
112
+ type: z.literal("REVIEW.CREATED"),
113
+ data: reviewWebhookEventDataSchema,
114
+ });
115
+
116
+ export const productOutOfStockWebhookEventSchema = z.object({
117
+ ...envelopeFields,
118
+ type: z.literal("PRODUCT.OUT_OF_STOCK"),
119
+ data: productOutOfStockWebhookEventDataSchema,
120
+ });
121
+
122
+ /** 웹훅 요청 본문. `type`으로 판별하며 `data` 형태가 타입별로 다르다 */
123
+ export const webhookEventSchema = z
124
+ .discriminatedUnion("type", [
125
+ orderWebhookEventSchema,
126
+ claimWebhookEventSchema,
127
+ inquiryWebhookEventSchema,
128
+ reviewWebhookEventSchema,
129
+ productOutOfStockWebhookEventSchema,
130
+ ])
131
+ .describe(
132
+ "웹훅 요청 본문 (얇은 이벤트). 개인정보를 싣지 않으며 `data`의 id로 관리 API를 조회한다",
133
+ );
134
+ export type WebhookEvent = z.infer<typeof webhookEventSchema>;
135
+
136
+ export type WebhookEventType = WebhookEvent["type"];
137
+
138
+ /** 이벤트 타입 하나로 좁힌 이벤트 (예: `WebhookEventOf<"ORDER.PAID">["data"]`) */
139
+ export type WebhookEventOf<T extends WebhookEventType> = WebhookEvent extends infer E
140
+ ? E extends { type: infer U }
141
+ ? T extends U
142
+ ? Omit<E, "type"> & { type: T }
143
+ : never
144
+ : never
145
+ : never;
package/src/server.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * 서버 엔트리포인트 (`@sayren/store-sdk/server`) — 시크릿을 다루는 헬퍼만 둔다.
3
+ * 브라우저 번들에서 import하지 않는다.
4
+ */
5
+
6
+ export {
7
+ WEBHOOK_DEFAULT_TOLERANCE_SEC,
8
+ WEBHOOK_DELIVERY_ID_HEADER,
9
+ WEBHOOK_EVENT_ID_HEADER,
10
+ WEBHOOK_EVENT_TYPE_HEADER,
11
+ WEBHOOK_HEADERS,
12
+ WEBHOOK_SIGNATURE_HEADER,
13
+ WEBHOOK_USER_AGENT,
14
+ } from "./webhook-headers";
15
+ export {
16
+ type ParsedWebhookSignature,
17
+ parseWebhookSignatureHeader,
18
+ type SignWebhookPayloadOptions,
19
+ signWebhookPayload,
20
+ type VerifyWebhookSignatureOptions,
21
+ verifyWebhookSignature,
22
+ type WebhookSignatureFailureReason,
23
+ type WebhookSignatureVerification,
24
+ } from "./webhook-signature";