@sayren/store-sdk 0.40.0 → 0.42.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/src/schemas/ai.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { storefrontSlugSchema } from "./storefront-hosting";
3
+ import { strictRequest } from "./strict";
3
4
 
4
5
  /**
5
6
  * 상점 플랫폼 AI 채팅(이슈 #124) — 대화·메시지·실행과 실행 이벤트 스트림의 계약.
@@ -96,9 +97,15 @@ export const aiActivitySchema = z.enum([
96
97
  ]);
97
98
  export type AiActivity = z.infer<typeof aiActivitySchema>;
98
99
 
100
+ /** 바뀐 줄 미리보기 한 줄의 최대 길이 — 서버가 이 길이로 자르고 화면은 이보다 긴 줄을 앞부분으로 비교한다 */
101
+ export const AI_TOOL_CHANGE_LINE_MAX = 300;
102
+ /** 바뀐 줄 범위(`lines`) 개수 상한 */
103
+ export const AI_TOOL_CHANGE_RANGES_MAX = 200;
104
+
99
105
  /**
100
106
  * 도구가 바꾼 파일 하나(2026-10-13, AI 상점 도우미 작업 공간의 수정 중인 코드) — 서버가 도구 입력으로 계산한다.
101
- * `edit`는 바꾼 부분의 줄 비교, `write`는 새로 쓴 내용(이전 내용을 모르므로 모두 추가로 센다). `preview`는 바뀐 줄 앞부분 12줄까지다
107
+ * `edit`는 바꾼 부분의 줄 비교, `write`는 새로 쓴 내용(이전 내용을 모르므로 모두 추가로 센다). `preview`는 바뀐 줄 앞부분 12줄까지다.
108
+ * `lines`는 바꾼 뒤 파일에서 추가된 줄의 범위(1부터, 양끝 포함)다 — `edit`가 성공한 뒤에만 실행기가 채운다(옛 기록·`write`에는 없다)
102
109
  */
103
110
  export const aiToolChangeSchema = z.object({
104
111
  path: z.string().max(300),
@@ -106,8 +113,17 @@ export const aiToolChangeSchema = z.object({
106
113
  added: z.number().int().min(0),
107
114
  removed: z.number().int().min(0),
108
115
  preview: z
109
- .array(z.object({ kind: z.enum(["added", "removed"]), text: z.string().max(300) }))
116
+ .array(
117
+ z.object({
118
+ kind: z.enum(["added", "removed"]),
119
+ text: z.string().max(AI_TOOL_CHANGE_LINE_MAX),
120
+ }),
121
+ )
110
122
  .max(12),
123
+ lines: z
124
+ .array(z.tuple([z.number().int().min(1), z.number().int().min(1)]))
125
+ .max(AI_TOOL_CHANGE_RANGES_MAX)
126
+ .optional(),
111
127
  });
112
128
  export type AiToolChange = z.infer<typeof aiToolChangeSchema>;
113
129
 
@@ -127,28 +143,49 @@ export const aiAgentPhaseSchema = z.enum(AI_AGENT_PHASES);
127
143
  export type AiAgentPhase = z.infer<typeof aiAgentPhaseSchema>;
128
144
 
129
145
  /**
130
- * AI 상점 도우미의 작업 메모 폴더(2026-10-14) — 회차마다 읽는 숨은 규칙(`rules.md`)과 디자인 계획(`design.md`). 셀러 화면(타임라인·코드 탭·변경 기록)에
131
- * 보이지 않는다. 편집 Sandbox가 점(.)으로 시작하는 경로를 받지 않아 작업 트리의 `src` 아래에 둔다(앱이 import하지 않는 문서라 빌드에 들어가지 않는다)
146
+ * AI 상점 도우미의 작업 메모 폴더(2026-10-14) — 회차마다 읽는 숨은 규칙(`rules.md`)과 디자인 계획(`design.md`). 편집 Sandbox가 점(.)으로 시작하는
147
+ * 경로를 받지 않아 작업 트리의 `src` 아래에 둔다(앱이 import하지 않는 문서라 빌드에 들어가지 않는다). 숨은 규칙은 셀러 화면(타임라인·코드 탭·변경 기록)에
148
+ * 보이지 않는다. 디자인 계획은 2026-10-24부터 셀러 문서(작업 공간 문서 탭의 「디자인 가이드라인」)라 숨기지 않는다(`AI_DESIGN_GUIDE_PATH`)
132
149
  */
133
150
  export const AI_AGENT_NOTES_DIR = "src/site/agent";
151
+ /** 디자인 가이드라인(디자인 계획) — 작업 메모 폴더에 있지만 셀러가 읽는 문서다 */
152
+ export const AI_DESIGN_GUIDE_PATH = `${AI_AGENT_NOTES_DIR}/design.md`;
153
+ /** 사이트맵 — 에이전트가 쓰는 화면 구성 문서(`{version:1,pages:[…]}`) */
154
+ export const AI_SITEMAP_PATH = "src/site/sitemap.json";
155
+
156
+ /** 작업 공간 문서 탭의 문서 파일인가(사이트맵·디자인 가이드라인) — 앱 코드가 아니라서 코드 탭·미리보기 점검·코드 변경 판정에서 뺀다 */
157
+ export function isAiWorkspaceDocPath(path: string): boolean {
158
+ return path === AI_SITEMAP_PATH || path === AI_DESIGN_GUIDE_PATH;
159
+ }
160
+
161
+ /** 「이어서 진행」 버튼(실패한 실행 잇기)이 보내는 선택 값 — 선택지 답처럼 같은 할 일 목록을 잇는다 */
162
+ export const AI_RUN_RESUME_CHOICE = "run:resume";
134
163
 
135
164
  /**
136
165
  * 에이전트의 할 일 목록(2026-10-14, Claude Code의 TaskCreate·TaskUpdate·TaskList와 같다) — 에이전트가 패널 도구로 할 일을 만들고 상태를 바꾼다.
137
- * 이벤트·조각은 그 시점의 목록 전체이고 작업 공간 왼쪽 패널이 마지막 목록을 체크리스트로 그린다. 진행중은 한 번에 하나다
166
+ * 이벤트·조각은 그 시점의 목록 전체이고 작업 공간 로드맵 탭이 마지막 목록을 칸반으로 그린다. 진행중은 한 번에 하나다.
167
+ * 목록은 셀러 요청 하나(사이클)의 일이다 — 새 요청이면 빈 목록에서 시작하고 선택지 답이면 잇는다(2026-10-24). 개수 상한은 없다
168
+ * (옛 30개 상한은 대화 전체에 할 일이 쌓여 넘쳤다 — 크기는 사이클 범위가, 폭주는 턴 예산이 막는다).
169
+ * `blocked`는 같은 문제를 거듭 고쳐도 남아 멈춘 일이고 `note`가 그 사유다(이어 가기 리마인더가 남은 일로 세지 않는다)
138
170
  */
139
- export const AI_TODO_STATUSES = ["pending", "in_progress", "completed"] as const;
171
+ export const AI_TODO_STATUSES = ["pending", "in_progress", "completed", "blocked"] as const;
140
172
  export const aiTodoSchema = z.object({
141
- /** 할 일 번호(대화 안에서 1부터) — 옛 조각에는 없다 */
173
+ /** 할 일 번호(사이클 안에서 1부터) — 옛 조각에는 없다 */
142
174
  id: z.string().max(10).optional(),
143
175
  content: z.string().min(1).max(200),
144
176
  status: z.enum(AI_TODO_STATUSES),
145
177
  /** 이 할 일이 속한 작업 단계 — 진행중이 되면 실행기가 단계를 알린다(2026-10-14, 모델이 단계 알림을 빠뜨렸다) */
146
178
  phase: aiAgentPhaseSchema.optional(),
179
+ /** 막힌 사유(`blocked`일 때만) */
180
+ note: z.string().max(300).optional(),
147
181
  });
148
182
  export type AiTodo = z.infer<typeof aiTodoSchema>;
183
+ /** @deprecated 할 일 개수 상한은 없앴다(2026-10-24). 공개 export라 값만 남긴다 */
149
184
  export const AI_TODOS_MAX = 30;
150
185
 
186
+ /** 셀러에게 보이지 않는 작업 메모인가 — 작업 메모 폴더에서 디자인 가이드라인을 뺀 나머지다 */
151
187
  export function isAiAgentNotesPath(path: string): boolean {
188
+ if (path === AI_DESIGN_GUIDE_PATH) return false;
152
189
  return path === AI_AGENT_NOTES_DIR || path.startsWith(`${AI_AGENT_NOTES_DIR}/`);
153
190
  }
154
191
 
@@ -260,7 +297,12 @@ export const AI_DESIGN_CONFIRM_APPROVE = "design:approve";
260
297
  * - `todos`: 에이전트의 할 일 목록 전체(작업 공간의 체크리스트)
261
298
  */
262
299
  export const aiMessagePartSchema = z.discriminatedUnion("type", [
263
- z.object({ type: z.literal("text"), text: z.string() }),
300
+ z.object({
301
+ type: z.literal("text"),
302
+ text: z.string(),
303
+ /** 셀러가 선택지 버튼으로 보낸 답의 값(사용자 메시지만) — 할 일 목록(사이클)을 잇는 기준(`isAiChoiceAnswer`) */
304
+ choice: z.string().max(200).optional(),
305
+ }),
264
306
  z.object({
265
307
  type: z.literal("tool"),
266
308
  toolCallId: z.string(),
@@ -302,7 +344,7 @@ export const aiMessagePartSchema = z.discriminatedUnion("type", [
302
344
  z.object({ type: z.literal("reasoning"), text: z.string() }),
303
345
  z.object({ type: z.literal("phase"), phase: aiAgentPhaseSchema }),
304
346
  /** 에이전트의 할 일 목록 — 그 시점의 목록 전체다(가장 최근 것이 지금 목록) */
305
- z.object({ type: z.literal("todos"), todos: z.array(aiTodoSchema).max(AI_TODOS_MAX) }),
347
+ z.object({ type: z.literal("todos"), todos: z.array(aiTodoSchema) }),
306
348
  ]);
307
349
  export type AiMessagePart = z.infer<typeof aiMessagePartSchema>;
308
350
 
@@ -347,12 +389,14 @@ export const storeSetupBriefSchema = z.object({
347
389
  });
348
390
  export type StoreSetupBrief = z.infer<typeof storeSetupBriefSchema>;
349
391
 
350
- export const createAiConversationRequestSchema = z.object({
351
- /** 웹사이트에서 남긴 초안(#124 M2) — 연결한 사용자만 쓴다. 주면 첫 메시지로 실행을 시작한다 */
352
- draftId: z.string().max(64).optional(),
353
- /** 질문 화면의 답 — `draftId`와 함께만 쓴다. 초안 앞에 붙어 첫 메시지가 된다 */
354
- brief: storeSetupBriefSchema.optional(),
355
- });
392
+ export const createAiConversationRequestSchema = strictRequest(
393
+ z.object({
394
+ /** 웹사이트에서 남긴 초안(#124 M2) — 연결한 사용자만 쓴다. 주면 첫 메시지로 실행을 시작한다 */
395
+ draftId: z.string().max(64).optional(),
396
+ /** 질문 화면의 답 — `draftId`와 함께만 쓴다. 초안 앞에 붙어 첫 메시지가 된다 */
397
+ brief: storeSetupBriefSchema.optional(),
398
+ }),
399
+ );
356
400
  export type CreateAiConversationRequest = z.infer<typeof createAiConversationRequestSchema>;
357
401
 
358
402
  export const createAiConversationResponseSchema = z.object({
@@ -362,20 +406,22 @@ export const createAiConversationResponseSchema = z.object({
362
406
  });
363
407
  export type CreateAiConversationResponse = z.infer<typeof createAiConversationResponseSchema>;
364
408
 
365
- export const sendAiMessageRequestSchema = z
366
- .object({
367
- text: z.string().trim().max(AI_MESSAGE_MAX_CHARS),
368
- attachments: z.array(aiAttachmentSchema).max(AI_MESSAGE_MAX_ATTACHMENTS).default([]),
369
- /** 선택지 버튼으로 보낸 답이면 그 값(화면 표시는 `text`) */
370
- choice: z.string().max(200).optional(),
371
- /** 편집 미리보기에서 고른 요소(#65) — 모델은 이 요소를 기준으로 고친다 */
372
- selection: aiPreviewSelectionSchema.optional(),
373
- /** 디자인 탭에서 고른 아트보드(2026-10-14) — 모델은 그 아트보드를 기준으로 고친다 */
374
- designSelection: aiDesignSelectionSchema.optional(),
375
- })
376
- .refine((body) => body.text.length > 0 || body.attachments.length > 0, {
377
- message: "내용이나 첨부가 있어야 합니다",
378
- });
409
+ export const sendAiMessageRequestSchema = strictRequest(
410
+ z
411
+ .object({
412
+ text: z.string().trim().max(AI_MESSAGE_MAX_CHARS),
413
+ attachments: z.array(aiAttachmentSchema).max(AI_MESSAGE_MAX_ATTACHMENTS).default([]),
414
+ /** 선택지 버튼으로 보낸 답이면 그 값(화면 표시는 `text`) */
415
+ choice: z.string().max(200).optional(),
416
+ /** 편집 미리보기에서 고른 요소(#65) — 모델은 이 요소를 기준으로 고친다 */
417
+ selection: aiPreviewSelectionSchema.optional(),
418
+ /** 디자인 탭에서 고른 아트보드(2026-10-14) — 모델은 그 아트보드를 기준으로 고친다 */
419
+ designSelection: aiDesignSelectionSchema.optional(),
420
+ })
421
+ .refine((body) => body.text.length > 0 || body.attachments.length > 0, {
422
+ message: "내용이나 첨부가 있어야 합니다",
423
+ }),
424
+ );
379
425
  export type SendAiMessageRequest = z.input<typeof sendAiMessageRequestSchema>;
380
426
 
381
427
  export const sendAiMessageResponseSchema = aiStreamAccessSchema.extend({
@@ -506,7 +552,7 @@ export const aiStreamEventSchema = z.discriminatedUnion("type", [
506
552
  z.object({
507
553
  type: z.literal("todos"),
508
554
  messageId: z.string(),
509
- todos: z.array(aiTodoSchema).max(AI_TODOS_MAX),
555
+ todos: z.array(aiTodoSchema),
510
556
  }),
511
557
  z.object({
512
558
  type: z.literal("tool_start"),
@@ -527,6 +573,8 @@ export const aiStreamEventSchema = z.discriminatedUnion("type", [
527
573
  toolCallId: z.string(),
528
574
  status: z.enum(["ok", "error", "unknown"]),
529
575
  summary: z.string().nullable(),
576
+ /** 결과로 확정한 변경(바뀐 줄 범위 포함) — 있으면 `tool_start`의 `changes`를 바꾼다 */
577
+ changes: z.array(aiToolChangeSchema).max(10).optional(),
530
578
  }),
531
579
  z.object({
532
580
  type: z.literal("choices"),
@@ -579,18 +627,20 @@ export const STORE_DRAFT_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
579
627
  /** 연결하지 않은 초안을 지우기까지 */
580
628
  export const STORE_DRAFT_RETENTION_DAYS = 7;
581
629
 
582
- export const createStoreDraftRequestSchema = z.object({
583
- text: z.string().trim().min(1).max(AI_MESSAGE_MAX_CHARS),
584
- attachments: z
585
- .array(
586
- z.object({
587
- filename: z.string().trim().min(1).max(200),
588
- size: z.number().int().positive().max(STORE_DRAFT_ATTACHMENT_MAX_BYTES),
589
- }),
590
- )
591
- .max(STORE_DRAFT_MAX_ATTACHMENTS)
592
- .default([]),
593
- });
630
+ export const createStoreDraftRequestSchema = strictRequest(
631
+ z.object({
632
+ text: z.string().trim().min(1).max(AI_MESSAGE_MAX_CHARS),
633
+ attachments: z
634
+ .array(
635
+ z.object({
636
+ filename: z.string().trim().min(1).max(200),
637
+ size: z.number().int().positive().max(STORE_DRAFT_ATTACHMENT_MAX_BYTES),
638
+ }),
639
+ )
640
+ .max(STORE_DRAFT_MAX_ATTACHMENTS)
641
+ .default([]),
642
+ }),
643
+ );
594
644
  export type CreateStoreDraftRequest = z.input<typeof createStoreDraftRequestSchema>;
595
645
 
596
646
  export const createStoreDraftResponseSchema = z.object({
@@ -622,3 +672,12 @@ export const storeDraftPreviewSchema = z.object({
622
672
  used: z.boolean(),
623
673
  });
624
674
  export type StoreDraftPreview = z.infer<typeof storeDraftPreviewSchema>;
675
+
676
+ /**
677
+ * 셀러 메시지가 선택지에서 고른 답인가(조각에 `choice`가 있다) — 할 일 목록은 셀러 요청 하나(사이클)의 일이고, 고른 답(선택지·만들기 확인·계획 승인·발행 확인·
678
+ * 「이어서 진행」)은 같은 사이클을 잇고 새로 쓴 글은 새 사이클이다(2026-10-24). api(`currentCycleRows`)와 상점 플랫폼(로드맵)이 이 판정 하나를 쓴다.
679
+ * 직전 답에 선택지가 있었는지로 가르면 선택지를 무시하고 새 요청을 쓴 경우도 옛 사이클에 묶였다
680
+ */
681
+ export function isAiChoiceAnswer(parts: readonly { type: string; choice?: string }[]): boolean {
682
+ return parts.some((part) => part.type === "text" && Boolean(part.choice));
683
+ }
@@ -8,6 +8,7 @@ import {
8
8
  appSettingFieldSchema,
9
9
  } from "./app-manifest";
10
10
  import { webhookDeliveryErrorCodeSchema, webhookDeliveryStatusSchema } from "./misc";
11
+ import { strictRequest } from "./strict";
11
12
 
12
13
  /**
13
14
  * 설치 앱 관리 API 계약 (이슈 #22) — `/v1/apps/installations/**`(상점이 설치한 앱), `/v1/app/installation`(설치 토큰 자기 조회).
@@ -183,21 +184,23 @@ export type AppInstallationActivity = z.infer<typeof appInstallationActivitySche
183
184
  * - `{ result }`: accounts 설치 동의 결과(1회용 JWT). 개발자 포털 [내 상점에 설치]가 이 길로 온다
184
185
  * - `{ appId, settings }`: 상점 플랫폼 앱 목록 › 설치 화면에서 동의하고 설정까지 채워 바로 설치한다
185
186
  */
186
- export const appInstallRequestSchema = z.union([
187
- z.object({
188
- result: z
189
- .string()
190
- .min(1)
191
- .describe("accounts가 발급한 설치 결과 JWT(`typ: sayren-install+jwt`)"),
192
- }),
193
- z.object({
194
- appId: z.string().min(1),
195
- settings: z
196
- .record(z.string(), settingsValueSchema)
197
- .default({})
198
- .describe("설치와 함께 저장할 설정. 비밀 필드도 여기서 넣는다"),
199
- }),
200
- ]);
187
+ export const appInstallRequestSchema = strictRequest(
188
+ z.union([
189
+ z.object({
190
+ result: z
191
+ .string()
192
+ .min(1)
193
+ .describe("accounts가 발급한 설치 결과 JWT(`typ: sayren-install+jwt`)"),
194
+ }),
195
+ z.object({
196
+ appId: z.string().min(1),
197
+ settings: z
198
+ .record(z.string(), settingsValueSchema)
199
+ .default({})
200
+ .describe("설치와 함께 저장할 설정. 비밀 필드도 여기서 넣는다"),
201
+ }),
202
+ ]),
203
+ );
201
204
  export type AppInstallRequest = z.infer<typeof appInstallRequestSchema>;
202
205
 
203
206
  /** 앱 목록 정보 — 현재 버전이 올린 아이콘·README(없으면 매니페스트 `iconUrl`) */
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { strictRequest } from "./strict";
2
3
 
3
4
  /**
4
5
  * 감사 로그와 승인 요청 — `/v1/audit-logs`, `/v1/approval-requests` (이슈 #36).
@@ -261,7 +262,9 @@ export const approvalPageSchema = z.object({
261
262
  });
262
263
  export type ApprovalPage = z.infer<typeof approvalPageSchema>;
263
264
 
264
- export const approvalDecisionRequestSchema = z.object({
265
- note: z.string().trim().max(500).optional().describe("승인·거절 메모 (최대 500자)"),
266
- });
265
+ export const approvalDecisionRequestSchema = strictRequest(
266
+ z.object({
267
+ note: z.string().trim().max(500).optional().describe("승인·거절 메모 (최대 500자)"),
268
+ }),
269
+ );
267
270
  export type ApprovalDecisionRequest = z.infer<typeof approvalDecisionRequestSchema>;
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { strictRequest } from "./strict";
2
3
 
3
4
  export const categoryProductSortSchema = z.enum([
4
5
  "RECENT",
@@ -70,40 +71,44 @@ export const categoryAttributeSchema = z.object({
70
71
 
71
72
  export type CategoryAttribute = z.infer<typeof categoryAttributeSchema>;
72
73
 
73
- export const categoryCreateRequestSchema = z.object({
74
- name: z.string().min(1).max(100),
75
- parentId: z.string().nullable().optional(),
76
- sortOrder: z.number().int().optional(),
77
- visible: z.boolean().optional(),
78
- showPc: z.boolean().optional(),
79
- showMobile: z.boolean().optional(),
80
- productSort: categoryProductSortSchema.optional(),
81
- metaTitle: z.string().max(100).nullable().optional(),
82
- metaDescription: z.string().max(300).nullable().optional(),
83
- metaKeywords: z.string().max(200).nullable().optional(),
84
- });
74
+ export const categoryCreateRequestSchema = strictRequest(
75
+ z.object({
76
+ name: z.string().min(1).max(100),
77
+ parentId: z.string().nullable().optional(),
78
+ sortOrder: z.number().int().optional(),
79
+ visible: z.boolean().optional(),
80
+ showPc: z.boolean().optional(),
81
+ showMobile: z.boolean().optional(),
82
+ productSort: categoryProductSortSchema.optional(),
83
+ metaTitle: z.string().max(100).nullable().optional(),
84
+ metaDescription: z.string().max(300).nullable().optional(),
85
+ metaKeywords: z.string().max(200).nullable().optional(),
86
+ }),
87
+ );
85
88
 
86
89
  export type CategoryCreateRequest = z.infer<typeof categoryCreateRequestSchema>;
87
90
 
88
- export const categoryPatchRequestSchema = z.object({
89
- name: z.string().min(1).max(100).optional(),
90
- parentId: z.string().nullable().optional(),
91
- sortOrder: z.number().int().optional(),
92
- visible: z.boolean().optional(),
93
- showPc: z.boolean().optional(),
94
- showMobile: z.boolean().optional(),
95
- productSort: categoryProductSortSchema.optional(),
96
- metaTitle: z.string().max(100).nullable().optional(),
97
- metaDescription: z.string().max(300).nullable().optional(),
98
- metaKeywords: z.string().max(200).nullable().optional(),
99
- flowId: z
100
- .string()
101
- .nullable()
102
- .optional()
103
- .describe(
104
- "주문 흐름(이슈 #115) — 게시한 흐름 id, null이면 기본 흐름. 맨 아래 카테고리에만 지정한다(400 `CATEGORY_NOT_LEAF`). 게시하지 않은 흐름은 409 `FLOW_NOT_PUBLISHED`",
105
- ),
106
- });
91
+ export const categoryPatchRequestSchema = strictRequest(
92
+ z.object({
93
+ name: z.string().min(1).max(100).optional(),
94
+ parentId: z.string().nullable().optional(),
95
+ sortOrder: z.number().int().optional(),
96
+ visible: z.boolean().optional(),
97
+ showPc: z.boolean().optional(),
98
+ showMobile: z.boolean().optional(),
99
+ productSort: categoryProductSortSchema.optional(),
100
+ metaTitle: z.string().max(100).nullable().optional(),
101
+ metaDescription: z.string().max(300).nullable().optional(),
102
+ metaKeywords: z.string().max(200).nullable().optional(),
103
+ flowId: z
104
+ .string()
105
+ .nullable()
106
+ .optional()
107
+ .describe(
108
+ "주문 흐름(이슈 #115) — 게시한 흐름 id, null이면 기본 흐름. 맨 아래 카테고리에만 지정한다(400 `CATEGORY_NOT_LEAF`). 게시하지 않은 흐름은 409 `FLOW_NOT_PUBLISHED`",
109
+ ),
110
+ }),
111
+ );
107
112
 
108
113
  export type CategoryPatchRequest = z.infer<typeof categoryPatchRequestSchema>;
109
114
 
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { commerceNumberField } from "./commerce-number";
3
3
  import { type PageParams, pageQueryShape } from "./common";
4
+ import { strictRequest } from "./strict";
4
5
 
5
6
  export const claimTypeSchema = z.enum(["CANCEL", "RETURN", "EXCHANGE"]);
6
7
 
@@ -164,17 +165,19 @@ export const claimHoldResultSchema = z.object({ claimId: z.string(), holdExpires
164
165
 
165
166
  export type ClaimHoldResult = z.infer<typeof claimHoldResultSchema>;
166
167
 
167
- export const rejectClaimRequestSchema = z.object({
168
- reason: z.enum([
169
- "USED_PRODUCT",
170
- "MISSING_COMPONENT",
171
- "PERIOD_EXPIRED",
172
- "BUYER_FAULT_DAMAGE",
173
- "ETC",
174
- ]),
175
- detail: z.string().min(10).max(1000),
176
- evidenceImages: z.array(z.url()).max(5).optional(),
177
- });
168
+ export const rejectClaimRequestSchema = strictRequest(
169
+ z.object({
170
+ reason: z.enum([
171
+ "USED_PRODUCT",
172
+ "MISSING_COMPONENT",
173
+ "PERIOD_EXPIRED",
174
+ "BUYER_FAULT_DAMAGE",
175
+ "ETC",
176
+ ]),
177
+ detail: z.string().min(10).max(1000),
178
+ evidenceImages: z.array(z.url()).max(5).optional(),
179
+ }),
180
+ );
178
181
 
179
182
  export type RejectClaimRequest = z.infer<typeof rejectClaimRequestSchema>;
180
183
 
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { type PageParams, pageQueryShape, pageSchema } from "./common";
3
3
  import { productImageUrlSchema } from "./product";
4
+ import { strictRequest } from "./strict";
4
5
 
5
6
  /**
6
7
  * 컬렉션 (이슈 #83) — 상품을 어디에 보여 줄지 정하는 묶음이다. 카테고리는 상품이 무엇인지를 정하고 상품당 하나지만, 컬렉션은
@@ -432,33 +433,35 @@ const periodRefine = (
432
433
  * 직접 선택(`MANUAL`)은 `productIds`가 처음 담을 상품과 순서다. 조건 자동(`AUTOMATIC`)은 `rules`가 있어야 하고 `productIds`를 받지 않는다.
433
434
  * `productSort`를 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)다. 조건 자동은 `MANUAL`을 쓸 수 없다.
434
435
  */
435
- export const collectionCreateRequestSchema = z
436
- .object({
437
- title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).describe("제목"),
438
- slug: slugField.optional(),
439
- type: z
440
- .enum(COLLECTION_TYPES)
441
- .default("MANUAL")
442
- .describe("유형. `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동). 만든 뒤 바꿀 수 없다"),
443
- rules: collectionRulesSchema
444
- .optional()
445
- .describe("조건 자동 컬렉션의 조건(AND). 조건 자동이면 필수, 직접 선택이면 줄 수 없다"),
446
- description: commonFields.description.default(""),
447
- imageUrl: commonFields.imageUrl.optional(),
448
- productSort: commonFields.productSort
449
- .optional()
450
- .describe(
451
- "스토어프론트 상품 기본 정렬. 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)다. 조건 자동은 `MANUAL`을 쓸 수 없다",
452
- ),
453
- visible: commonFields.visible.default(true),
454
- startsAt: commonFields.startsAt,
455
- endsAt: commonFields.endsAt,
456
- sortOrder: commonFields.sortOrder.optional().describe("목록 순서. 생략하면 맨 뒤"),
457
- metaTitle: commonFields.metaTitle.optional(),
458
- metaDescription: commonFields.metaDescription.optional(),
459
- productIds: productIdsField.optional(),
460
- })
461
- .superRefine(periodRefine);
436
+ export const collectionCreateRequestSchema = strictRequest(
437
+ z
438
+ .object({
439
+ title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).describe("제목"),
440
+ slug: slugField.optional(),
441
+ type: z
442
+ .enum(COLLECTION_TYPES)
443
+ .default("MANUAL")
444
+ .describe("유형. `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동). 만든 뒤 바꿀 수 없다"),
445
+ rules: collectionRulesSchema
446
+ .optional()
447
+ .describe("조건 자동 컬렉션의 조건(AND). 조건 자동이면 필수, 직접 선택이면 줄 수 없다"),
448
+ description: commonFields.description.default(""),
449
+ imageUrl: commonFields.imageUrl.optional(),
450
+ productSort: commonFields.productSort
451
+ .optional()
452
+ .describe(
453
+ "스토어프론트 상품 기본 정렬. 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)다. 조건 자동은 `MANUAL`을 쓸 수 없다",
454
+ ),
455
+ visible: commonFields.visible.default(true),
456
+ startsAt: commonFields.startsAt,
457
+ endsAt: commonFields.endsAt,
458
+ sortOrder: commonFields.sortOrder.optional().describe("목록 순서. 생략하면 맨 뒤"),
459
+ metaTitle: commonFields.metaTitle.optional(),
460
+ metaDescription: commonFields.metaDescription.optional(),
461
+ productIds: productIdsField.optional(),
462
+ })
463
+ .superRefine(periodRefine),
464
+ );
462
465
 
463
466
  export type CollectionCreateRequest = z.infer<typeof collectionCreateRequestSchema>;
464
467
  export type CollectionCreateRequestInput = z.input<typeof collectionCreateRequestSchema>;
@@ -467,45 +470,51 @@ export type CollectionCreateRequestInput = z.input<typeof collectionCreateReques
467
470
  * `PATCH /v1/collections/{collectionId}` — 준 항목만 바뀐다. `type`을 주면 400 `COLLECTION_TYPE_IMMUTABLE`이다.
468
471
  * `revision`을 주면 그 번호일 때만 바뀌고, 그 사이 다른 수정이 있었으면 409 `COLLECTION_CHANGED`다.
469
472
  */
470
- export const collectionPatchRequestSchema = z
471
- .object({
472
- title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).optional(),
473
- slug: slugField.optional(),
474
- type: z.string().optional().describe("바꿀 수 없다. 주면 400 `COLLECTION_TYPE_IMMUTABLE`"),
475
- rules: collectionRulesSchema
476
- .optional()
477
- .describe(
478
- "조건 자동 컬렉션의 조건을 통째로 바꾼다. 직접 선택 컬렉션에 주면 400 `COLLECTION_RULES_NOT_ALLOWED`",
479
- ),
480
- description: commonFields.description.optional(),
481
- imageUrl: commonFields.imageUrl.optional(),
482
- productSort: commonFields.productSort.optional(),
483
- visible: commonFields.visible.optional(),
484
- startsAt: commonFields.startsAt,
485
- endsAt: commonFields.endsAt,
486
- sortOrder: commonFields.sortOrder.optional(),
487
- metaTitle: commonFields.metaTitle.optional(),
488
- metaDescription: commonFields.metaDescription.optional(),
489
- revision: z.number().int().min(0).optional().describe("읽은 수정 번호"),
490
- })
491
- .superRefine(periodRefine);
473
+ export const collectionPatchRequestSchema = strictRequest(
474
+ z
475
+ .object({
476
+ title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).optional(),
477
+ slug: slugField.optional(),
478
+ type: z.string().optional().describe("바꿀 수 없다. 주면 400 `COLLECTION_TYPE_IMMUTABLE`"),
479
+ rules: collectionRulesSchema
480
+ .optional()
481
+ .describe(
482
+ "조건 자동 컬렉션의 조건을 통째로 바꾼다. 직접 선택 컬렉션에 주면 400 `COLLECTION_RULES_NOT_ALLOWED`",
483
+ ),
484
+ description: commonFields.description.optional(),
485
+ imageUrl: commonFields.imageUrl.optional(),
486
+ productSort: commonFields.productSort.optional(),
487
+ visible: commonFields.visible.optional(),
488
+ startsAt: commonFields.startsAt,
489
+ endsAt: commonFields.endsAt,
490
+ sortOrder: commonFields.sortOrder.optional(),
491
+ metaTitle: commonFields.metaTitle.optional(),
492
+ metaDescription: commonFields.metaDescription.optional(),
493
+ revision: z.number().int().min(0).optional().describe("읽은 수정 번호"),
494
+ })
495
+ .superRefine(periodRefine),
496
+ );
492
497
 
493
498
  export type CollectionPatchRequest = z.infer<typeof collectionPatchRequestSchema>;
494
499
 
495
500
  /** `PUT /v1/collections/{collectionId}/products` — 담긴 상품을 통째로 바꾼다(순서 포함) */
496
- export const collectionProductsReplaceRequestSchema = z.object({
497
- productIds: productIdsField,
498
- revision: z.number().int().min(0).optional().describe("읽은 수정 번호"),
499
- });
501
+ export const collectionProductsReplaceRequestSchema = strictRequest(
502
+ z.object({
503
+ productIds: productIdsField,
504
+ revision: z.number().int().min(0).optional().describe("읽은 수정 번호"),
505
+ }),
506
+ );
500
507
 
501
508
  export type CollectionProductsReplaceRequest = z.infer<
502
509
  typeof collectionProductsReplaceRequestSchema
503
510
  >;
504
511
 
505
512
  /** `POST /v1/collections/{collectionId}/products` — 뒤에 붙인다. 이미 담긴 상품은 그 자리에 둔다 */
506
- export const collectionProductsAddRequestSchema = z.object({
507
- productIds: z.array(z.string().min(1)).min(1).max(MAX_COLLECTION_PRODUCTS_PER_ADD),
508
- });
513
+ export const collectionProductsAddRequestSchema = strictRequest(
514
+ z.object({
515
+ productIds: z.array(z.string().min(1)).min(1).max(MAX_COLLECTION_PRODUCTS_PER_ADD),
516
+ }),
517
+ );
509
518
 
510
519
  export type CollectionProductsAddRequest = z.infer<typeof collectionProductsAddRequestSchema>;
511
520
 
@@ -562,14 +571,16 @@ export const collectionProductListQuerySchema = z.object({
562
571
  });
563
572
 
564
573
  /** `POST /v1/collections/preview` — 저장하지 않고 조건으로 나오는 상품을 본다 */
565
- export const collectionPreviewRequestSchema = z.object({
566
- rules: collectionRulesSchema,
567
- productSort: collectionProductSortRequestSchema
568
- .optional()
569
- .describe(
570
- "미리보기 순서. 생략하면 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)이고, `MANUAL`이면 `LATEST`다",
571
- ),
572
- });
574
+ export const collectionPreviewRequestSchema = strictRequest(
575
+ z.object({
576
+ rules: collectionRulesSchema,
577
+ productSort: collectionProductSortRequestSchema
578
+ .optional()
579
+ .describe(
580
+ "미리보기 순서. 생략하면 `LATEST`(판매량 상위 조건이 있으면 `BEST_SELLING`)이고, `MANUAL`이면 `LATEST`다",
581
+ ),
582
+ }),
583
+ );
573
584
 
574
585
  export type CollectionPreviewRequest = z.infer<typeof collectionPreviewRequestSchema>;
575
586