@sayren/store-sdk 0.29.0 → 0.29.2

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/client.ts CHANGED
@@ -56,11 +56,14 @@ import {
56
56
  type CollectionCreateRequestInput,
57
57
  type CollectionListParams,
58
58
  type CollectionPatchRequest,
59
+ type CollectionPreviewRequest,
59
60
  type CollectionProductsAddRequest,
60
61
  type CollectionProductsReplaceRequest,
61
62
  collectionCreateRequestSchema,
62
63
  collectionPageSchema,
63
64
  collectionPatchRequestSchema,
65
+ collectionPreviewRequestSchema,
66
+ collectionPreviewSchema,
64
67
  collectionProductSchema,
65
68
  collectionProductsAddRequestSchema,
66
69
  collectionProductsReplaceRequestSchema,
@@ -170,6 +173,17 @@ import {
170
173
  type VerifyPaymentProviderRequest,
171
174
  verifyPaymentProviderRequestSchema,
172
175
  } from "./schemas/payment";
176
+ import {
177
+ customerPointsSchema,
178
+ type PointAdjustmentRequest,
179
+ type PointHistoryQuery,
180
+ pointAdjustmentRequestSchema,
181
+ pointAdjustmentResultSchema,
182
+ pointEntrySchema,
183
+ storePointPolicySchema,
184
+ type UpdatePointPolicyRequest,
185
+ updatePointPolicyRequestSchema,
186
+ } from "./schemas/points";
173
187
  import {
174
188
  type DiscountRequest,
175
189
  discountRequestSchema,
@@ -549,7 +563,7 @@ export function createStoreClient(options: StoreClientOptions) {
549
563
  }),
550
564
  remove: (collectionId: string) =>
551
565
  storeHttp.requestVoid("DELETE", path`/collections/${collectionId}`),
552
- /** 담긴 상품 — 직접 정한 순서 */
566
+ /** 담긴 상품 — 직접 선택은 정한 순서, 조건 자동은 지금 조건을 만족하는 판매 중 상품(기본 정렬 순) */
553
567
  listProducts: (collectionId: string, params?: { page?: number; size?: number }) =>
554
568
  storeHttp.request(
555
569
  "GET",
@@ -569,6 +583,11 @@ export function createStoreClient(options: StoreClientOptions) {
569
583
  }),
570
584
  removeProduct: (collectionId: string, productId: string) =>
571
585
  storeHttp.requestVoid("DELETE", path`/collections/${collectionId}/products/${productId}`),
586
+ /** 조건 자동 미리보기 — 저장하지 않고 조건을 만족하는 판매 중 상품 수와 앞 20개를 본다 */
587
+ preview: (body: CollectionPreviewRequest) =>
588
+ storeHttp.request("POST", "/collections/preview", collectionPreviewSchema, {
589
+ body: collectionPreviewRequestSchema.parse(body),
590
+ }),
572
591
  },
573
592
 
574
593
  categories: {
@@ -800,7 +819,41 @@ export function createStoreClient(options: StoreClientOptions) {
800
819
  },
801
820
 
802
821
  /** 구매자 관리 — 조회 `customer:r`, 탈퇴 처리 `customer:rw`(상점 플랫폼 OWNER·ADMIN) */
822
+ /** 적립금 — 상점 전체 원장(#93). 스코프 `promotion:r` */
823
+ points: {
824
+ /** `GET /v1/points/history` — 회원(`memberId`)·유형(`type`)·기간(`from`·`to`)으로 거른다 */
825
+ listHistory: (params?: PointHistoryQuery) =>
826
+ storeHttp.request("GET", "/points/history", pageSchema(pointEntrySchema), {
827
+ query: params as Query,
828
+ }),
829
+ },
830
+
803
831
  customers: {
832
+ /** `GET /v1/customers/{customerId}/points` — 잔액·30일 안 소멸 예정·남은 적립 묶음(#93) */
833
+ getPoints: (customerId: string) =>
834
+ storeHttp.request("GET", path`/customers/${customerId}/points`, customerPointsSchema),
835
+ /** `GET /v1/customers/{customerId}/points/history` — 적립금 내역(최신순, 셀러 사유 포함) */
836
+ listPointHistory: (customerId: string, params?: Omit<PointHistoryQuery, "memberId">) =>
837
+ storeHttp.request(
838
+ "GET",
839
+ path`/customers/${customerId}/points/history`,
840
+ pageSchema(pointEntrySchema),
841
+ { query: params as Query },
842
+ ),
843
+ /**
844
+ * `POST /v1/customers/{customerId}/points/adjustments` (201) — 스코프 `promotion:rw`. 양수 지급, 음수 차감. `idempotencyKey`가 필수다.
845
+ * 에러: 409 `POINT_BALANCE_INSUFFICIENT`·`POINTS_UNAVAILABLE`, 400 `POINT_EXPIRY_REQUIRED`. 에이전트 요청은 승인 대상이다
846
+ */
847
+ adjustPoints: (customerId: string, body: PointAdjustmentRequest, idempotencyKey: string) =>
848
+ storeHttp.request(
849
+ "POST",
850
+ path`/customers/${customerId}/points/adjustments`,
851
+ pointAdjustmentResultSchema,
852
+ {
853
+ body: pointAdjustmentRequestSchema.parse(body),
854
+ headers: idempotency(idempotencyKey),
855
+ },
856
+ ),
804
857
  list: (
805
858
  params?: PageParams & Omit<z.input<typeof customerListQuerySchema>, "page" | "size">,
806
859
  ) =>
@@ -1553,6 +1606,19 @@ export function createStoreClient(options: StoreClientOptions) {
1553
1606
  storeHttp.request("PATCH", "/store", storeSchema, {
1554
1607
  body: updateStoreRequestSchema.parse(body),
1555
1608
  }),
1609
+ /**
1610
+ * `GET /v1/store/point-policy` — 스코프 `promotion:r`. 적립금 정책(리뷰 적립·유효 기간)과 준비 여부(`available`), 상점에 보일 안내(`notice`).
1611
+ * 정책을 정하지 않았으면 모두 끔이다(#93)
1612
+ */
1613
+ getPointPolicy: () => storeHttp.request("GET", "/store/point-policy", storePointPolicySchema),
1614
+ /**
1615
+ * `PUT /v1/store/point-policy` — 스코프 `promotion:rw`. 준 항목만 바꾼다. 에이전트 요청은 승인 대상이다.
1616
+ * 에러: 409 `POINTS_UNAVAILABLE`(준비 중) · 400 `POINT_EXPIRY_REQUIRED`(리뷰 적립을 켜는데 유효 기간이 없음)
1617
+ */
1618
+ updatePointPolicy: (body: UpdatePointPolicyRequest) =>
1619
+ storeHttp.request("PUT", "/store/point-policy", storePointPolicySchema, {
1620
+ body: updatePointPolicyRequestSchema.parse(body),
1621
+ }),
1556
1622
  listMembers: () => storeHttp.request("GET", "/store/members", z.array(storeMemberSchema)),
1557
1623
  /** `PATCH /v1/store/members/{userId}` — 스코프 `member:rw`. OWNER는 바꿀 수 없고 ADMIN 지정·ADMIN 변경은 OWNER만 한다 */
1558
1624
  updateMemberRole: (userId: string, body: UpdateMemberRoleRequest) =>
package/src/index.ts CHANGED
@@ -32,6 +32,7 @@ export * from "./schemas/login";
32
32
  export * from "./schemas/misc";
33
33
  export * from "./schemas/order";
34
34
  export * from "./schemas/payment";
35
+ export * from "./schemas/points";
35
36
  export * from "./schemas/product";
36
37
  export * from "./schemas/promotion";
37
38
  export * from "./schemas/refund-policy";
@@ -6,8 +6,8 @@ import { productImageUrlSchema } from "./product";
6
6
  * 컬렉션 (이슈 #83) — 상품을 어디에 보여 줄지 정하는 묶음이다. 카테고리는 상품이 무엇인지를 정하고 상품당 하나지만, 컬렉션은
7
7
  * 다대다다. 이벤트·기획전은 기간이 있는 직접 선택 컬렉션이다. 권한은 카테고리와 같다(`category:r`·`category:rw`).
8
8
  *
9
- * 유형은 만들 때 정하고 바꿀 수 없다. 지금은 직접 선택(`MANUAL`)만 만들 수 있다. 조건 자동(`AUTOMATIC`)은 다음 단계에서 열리고,
10
- * 응답의 `type`은 값이 늘 수 있어 문자열이다.
9
+ * 유형은 만들 때 정하고 바꿀 수 없다. 직접 선택(`MANUAL`)은 셀러가 상품을 담고 순서를 정하고, 조건 자동(`AUTOMATIC`)은 조건(`rules`)을
10
+ * 모두 만족하는 판매 중 상품을 조회할 때마다 계산한다(결과를 저장하지 않는다). 응답의 `type`은 값이 늘 수 있어 문자열이다.
11
11
  */
12
12
 
13
13
  /** 상점당 컬렉션 최대 개수 — 넘으면 409 `COLLECTION_LIMIT_EXCEEDED` */
@@ -23,7 +23,7 @@ export const MAX_COLLECTION_DESCRIPTION = 500;
23
23
  /** 주소(slug) 형식 — 영문 소문자·숫자·하이픈, 첫 글자는 영문 소문자나 숫자, 64자까지 */
24
24
  export const COLLECTION_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
25
25
 
26
- /** 유형 — `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동, 아직 만들 수 없다) */
26
+ /** 유형 — `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동) */
27
27
  export const COLLECTION_TYPES = ["MANUAL", "AUTOMATIC"] as const;
28
28
  export type CollectionType = (typeof COLLECTION_TYPES)[number];
29
29
 
@@ -65,6 +65,210 @@ export function collectionStateOf(
65
65
  return "ACTIVE";
66
66
  }
67
67
 
68
+ /** 조건 자동 컬렉션의 조건 최대 개수 */
69
+ export const MAX_COLLECTION_RULES = 10;
70
+ /** 카테고리 조건 하나에 넣는 카테고리 최대 개수 */
71
+ export const MAX_COLLECTION_RULE_CATEGORIES = 20;
72
+ /** 등록일 조건의 최대 일수 */
73
+ export const MAX_COLLECTION_CREATED_WITHIN_DAYS = 365;
74
+ /** 미리보기(`POST /v1/collections/preview`)가 싣는 상품 최대 개수 */
75
+ export const COLLECTION_PREVIEW_SIZE = 20;
76
+
77
+ const createdWithinDaysRuleSchema = z.object({
78
+ field: z.literal("createdWithinDays"),
79
+ value: z
80
+ .number()
81
+ .int()
82
+ .min(1)
83
+ .max(MAX_COLLECTION_CREATED_WITHIN_DAYS)
84
+ .describe("등록한 지 N일 안의 상품(1~365). 조회 시각에서 N일을 뺀 시각 이후에 등록한 상품이다"),
85
+ });
86
+
87
+ const categoryRuleSchema = z.object({
88
+ field: z.literal("category"),
89
+ categoryIds: z
90
+ .array(z.string().min(1))
91
+ .min(1)
92
+ .max(MAX_COLLECTION_RULE_CATEGORIES)
93
+ .describe(
94
+ "카테고리 id(1~20개). 하위 카테고리의 상품도 포함한다. 상점에 없는 id는 400 `COLLECTION_CATEGORY_NOT_FOUND`다",
95
+ ),
96
+ });
97
+
98
+ const priceRuleSchema = z.object({
99
+ field: z.literal("price"),
100
+ min: z.number().int().min(0).optional().describe("최저가(포함, 원)"),
101
+ max: z.number().int().min(0).optional().describe("최고가(포함, 원)"),
102
+ });
103
+
104
+ const discountedRuleSchema = z.object({
105
+ field: z.literal("discounted"),
106
+ value: z.literal(true).describe("즉시할인이 지금 적용되는 상품"),
107
+ });
108
+
109
+ /**
110
+ * 조건 하나 — `field`로 가른다. 가격은 즉시할인을 반영한 판매가(`discountedPrice ?? salePrice`)다.
111
+ * 판매량 상위(`bestSelling`)는 판매량 집계가 열릴 때 더한다.
112
+ */
113
+ export const collectionRuleSchema = z
114
+ .discriminatedUnion("field", [
115
+ createdWithinDaysRuleSchema,
116
+ categoryRuleSchema,
117
+ priceRuleSchema,
118
+ discountedRuleSchema,
119
+ ])
120
+ .superRefine((rule, ctx) => {
121
+ if (rule.field !== "price") return;
122
+ if (rule.min === undefined && rule.max === undefined) {
123
+ ctx.addIssue({
124
+ code: "custom",
125
+ path: ["min"],
126
+ message: "최저가나 최고가 중 하나는 있어야 합니다",
127
+ });
128
+ } else if (rule.min !== undefined && rule.max !== undefined && rule.min > rule.max) {
129
+ ctx.addIssue({ code: "custom", path: ["max"], message: "최고가는 최저가 이상이어야 합니다" });
130
+ }
131
+ });
132
+
133
+ export type CollectionRule = z.infer<typeof collectionRuleSchema>;
134
+ export type CollectionRuleField = CollectionRule["field"];
135
+
136
+ /** 조건 목록 — 모두 만족해야 한다(AND). 같은 `field`는 한 번만 쓴다 */
137
+ export const collectionRulesSchema = z
138
+ .array(collectionRuleSchema)
139
+ .min(1)
140
+ .max(MAX_COLLECTION_RULES)
141
+ .superRefine((rules, ctx) => {
142
+ const seen = new Set<string>();
143
+ rules.forEach((rule, index) => {
144
+ if (seen.has(rule.field)) {
145
+ ctx.addIssue({
146
+ code: "custom",
147
+ path: [index, "field"],
148
+ message: "같은 조건을 두 번 쓸 수 없습니다",
149
+ });
150
+ }
151
+ seen.add(rule.field);
152
+ });
153
+ })
154
+ .describe(
155
+ "조건 목록. 모두 만족하는 판매 중 상품이 컬렉션 상품이다(AND). 같은 조건(`field`)은 한 번만 쓴다",
156
+ );
157
+
158
+ /**
159
+ * 응답의 조건 — 이 SDK가 아는 조건이거나 뒤에 더해질 조건(`field`만 확인한다)이다. 새 조건이 생겨도 옛 SDK의 응답 검증이 깨지지 않는다.
160
+ */
161
+ export const collectionRuleViewSchema = z.union([
162
+ createdWithinDaysRuleSchema,
163
+ categoryRuleSchema,
164
+ priceRuleSchema,
165
+ discountedRuleSchema,
166
+ z.looseObject({ field: z.string() }),
167
+ ]);
168
+
169
+ export type CollectionRuleView = z.infer<typeof collectionRuleViewSchema>;
170
+
171
+ /** 조건 판정에 쓰는 상품 값 — 즉시할인가는 호출부가 계산해 넘긴다 */
172
+ export interface CollectionRuleProduct {
173
+ createdAt: string;
174
+ categoryId: string | null;
175
+ salePrice: number;
176
+ /** 지금 적용되는 즉시할인가. 할인이 없으면 null */
177
+ discountedPrice: number | null;
178
+ }
179
+
180
+ export interface CollectionRuleContext {
181
+ /** 판정 시각 — 등록일 조건의 기준이다 */
182
+ now: Date;
183
+ /** 상점 카테고리(부모 관계) — 카테고리 조건의 하위 포함 판정에 쓴다 */
184
+ categories: ReadonlyArray<{ categoryId: string; parentId: string | null }>;
185
+ }
186
+
187
+ const DAY_MS = 86_400_000;
188
+
189
+ /** 카테고리 자신 + 모든 하위 카테고리 id */
190
+ export function descendantCategoryIdsOf(
191
+ roots: readonly string[],
192
+ categories: CollectionRuleContext["categories"],
193
+ ): Set<string> {
194
+ const ids = new Set(roots);
195
+ const children = new Map<string, string[]>();
196
+ for (const category of categories) {
197
+ if (!category.parentId) continue;
198
+ const list = children.get(category.parentId);
199
+ if (list) list.push(category.categoryId);
200
+ else children.set(category.parentId, [category.categoryId]);
201
+ }
202
+ const queue = [...roots];
203
+ for (let next = queue.shift(); next !== undefined; next = queue.shift()) {
204
+ for (const child of children.get(next) ?? []) {
205
+ if (ids.has(child)) continue;
206
+ ids.add(child);
207
+ queue.push(child);
208
+ }
209
+ }
210
+ return ids;
211
+ }
212
+
213
+ /**
214
+ * 조건 판정 함수를 만든다 — 모든 조건을 만족해야 true다(AND). 카테고리 트리는 여기서 한 번만 푼다.
215
+ * 알 수 없는 조건(`field`)은 만족하지 않는 것으로 본다. 모르는 조건을 건너뛰면 상품이 예고 없이 늘어난다.
216
+ * 판매 상태는 보지 않는다. 판매 중만 거르는 것은 호출부다.
217
+ */
218
+ export function collectionRulesPredicate(
219
+ rules: readonly CollectionRuleView[],
220
+ context: CollectionRuleContext,
221
+ ): (product: CollectionRuleProduct) => boolean {
222
+ const checks = rules.map((rule): ((product: CollectionRuleProduct) => boolean) => {
223
+ const known = collectionRuleSchema.safeParse(rule);
224
+ if (!known.success) return () => false;
225
+ const parsed = known.data;
226
+ switch (parsed.field) {
227
+ case "createdWithinDays": {
228
+ const since = context.now.getTime() - parsed.value * DAY_MS;
229
+ return (product) => Date.parse(product.createdAt) >= since;
230
+ }
231
+ case "category": {
232
+ const ids = descendantCategoryIdsOf(parsed.categoryIds, context.categories);
233
+ return (product) => product.categoryId !== null && ids.has(product.categoryId);
234
+ }
235
+ case "price":
236
+ return (product) => {
237
+ const price = product.discountedPrice ?? product.salePrice;
238
+ return (
239
+ (parsed.min === undefined || price >= parsed.min) &&
240
+ (parsed.max === undefined || price <= parsed.max)
241
+ );
242
+ };
243
+ case "discounted":
244
+ return (product) =>
245
+ product.discountedPrice !== null && product.discountedPrice < product.salePrice;
246
+ default:
247
+ return () => false;
248
+ }
249
+ });
250
+ return (product) => checks.length > 0 && checks.every((check) => check(product));
251
+ }
252
+
253
+ /** 상품 하나가 조건을 모두 만족하는가 — 여러 상품을 판정할 때는 `collectionRulesPredicate`를 한 번 만들어 쓴다 */
254
+ export function matchesCollectionRules(
255
+ product: CollectionRuleProduct,
256
+ rules: readonly CollectionRuleView[],
257
+ context: CollectionRuleContext,
258
+ ): boolean {
259
+ return collectionRulesPredicate(rules, context)(product);
260
+ }
261
+
262
+ /** 조건이 참조하는 카테고리 id — 카테고리 삭제의 참조 검사에 쓴다 */
263
+ export function collectionRuleCategoryIds(rules: readonly CollectionRuleView[]): string[] {
264
+ const ids: string[] = [];
265
+ for (const rule of rules) {
266
+ const parsed = categoryRuleSchema.safeParse(rule);
267
+ if (parsed.success) ids.push(...parsed.data.categoryIds);
268
+ }
269
+ return ids;
270
+ }
271
+
68
272
  export const collectionSchema = z.object({
69
273
  collectionId: z.string(),
70
274
  type: z.string().describe("유형. 현재 값은 `MANUAL`·`AUTOMATIC`이고 값이 늘 수 있어 문자열이다"),
@@ -73,6 +277,10 @@ export const collectionSchema = z.object({
73
277
  description: z.string(),
74
278
  imageUrl: z.string().nullable().describe("대표 이미지 주소"),
75
279
  productSort: z.string().describe("스토어프론트 상품 기본 정렬"),
280
+ rules: z
281
+ .array(collectionRuleViewSchema)
282
+ .nullable()
283
+ .describe("조건 자동 컬렉션의 조건(AND). 직접 선택 컬렉션은 null"),
76
284
  visible: z.boolean().describe("노출 여부. false면 기간과 무관하게 스토어프론트에 보이지 않는다"),
77
285
  startsAt: z.string().nullable().describe("노출 시작 시각. null이면 바로 노출한다"),
78
286
  endsAt: z.string().nullable().describe("노출 종료 시각. null이면 끝나지 않는다"),
@@ -80,7 +288,12 @@ export const collectionSchema = z.object({
80
288
  metaTitle: z.string().nullable(),
81
289
  metaDescription: z.string().nullable(),
82
290
  state: collectionStateSchema.describe("지금 노출 상태"),
83
- productCount: z.number().int().describe("담긴 상품 수(판매 상태 무관)"),
291
+ productCount: z
292
+ .number()
293
+ .int()
294
+ .describe(
295
+ "상품 수. 직접 선택은 담긴 상품 수(판매 상태 무관), 조건 자동은 지금 조건을 만족하는 판매 중 상품 수",
296
+ ),
84
297
  revision: z
85
298
  .number()
86
299
  .int()
@@ -132,20 +345,28 @@ const periodRefine = (
132
345
  };
133
346
 
134
347
  /**
135
- * `POST /v1/collections` — 직접 선택 컬렉션을 만든다. `slug`를 생략하면 `c-{id 뒤 8자}`로 만든다.
136
- * `productIds`는 처음 담을 상품과 순서다.
348
+ * `POST /v1/collections` — 컬렉션을 만든다. `slug`를 생략하면 `c-{id 뒤 8자}`로 만든다.
349
+ * 직접 선택(`MANUAL`)은 `productIds`가 처음 담을 상품과 순서다. 조건 자동(`AUTOMATIC`)은 `rules`가 있어야 하고 `productIds`를 받지 않는다.
350
+ * `productSort`를 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`다. 조건 자동은 `MANUAL`을 쓸 수 없다.
137
351
  */
138
352
  export const collectionCreateRequestSchema = z
139
353
  .object({
140
354
  title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).describe("제목"),
141
355
  slug: slugField.optional(),
142
356
  type: z
143
- .enum(["MANUAL"])
357
+ .enum(COLLECTION_TYPES)
144
358
  .default("MANUAL")
145
- .describe("유형. 지금은 `MANUAL`(직접 선택)만 받는다. 만든 뒤 바꿀 수 없다"),
359
+ .describe("유형. `MANUAL`(직접 선택)·`AUTOMATIC`(조건 자동). 만든 뒤 바꿀 수 없다"),
360
+ rules: collectionRulesSchema
361
+ .optional()
362
+ .describe("조건 자동 컬렉션의 조건(AND). 조건 자동이면 필수, 직접 선택이면 줄 수 없다"),
146
363
  description: commonFields.description.default(""),
147
364
  imageUrl: commonFields.imageUrl.optional(),
148
- productSort: commonFields.productSort.default("MANUAL"),
365
+ productSort: commonFields.productSort
366
+ .optional()
367
+ .describe(
368
+ "스토어프론트 상품 기본 정렬. 생략하면 직접 선택은 `MANUAL`, 조건 자동은 `LATEST`다. 조건 자동은 `MANUAL`을 쓸 수 없다",
369
+ ),
149
370
  visible: commonFields.visible.default(true),
150
371
  startsAt: commonFields.startsAt,
151
372
  endsAt: commonFields.endsAt,
@@ -168,6 +389,11 @@ export const collectionPatchRequestSchema = z
168
389
  title: z.string().trim().min(1).max(MAX_COLLECTION_TITLE).optional(),
169
390
  slug: slugField.optional(),
170
391
  type: z.string().optional().describe("바꿀 수 없다. 주면 400 `COLLECTION_TYPE_IMMUTABLE`"),
392
+ rules: collectionRulesSchema
393
+ .optional()
394
+ .describe(
395
+ "조건 자동 컬렉션의 조건을 통째로 바꾼다. 직접 선택 컬렉션에 주면 400 `COLLECTION_RULES_NOT_ALLOWED`",
396
+ ),
171
397
  description: commonFields.description.optional(),
172
398
  imageUrl: commonFields.imageUrl.optional(),
173
399
  productSort: commonFields.productSort.optional(),
@@ -207,8 +433,11 @@ export const collectionProductSchema = z.object({
207
433
  thumbnailUrl: z.string().nullable(),
208
434
  status: z.string().describe("판매 상태. 스토어프론트에는 `SALE`만 보인다"),
209
435
  salePrice: z.number().int(),
210
- position: z.number().int().describe("직접 정한 순서(1부터)"),
211
- addedAt: z.string(),
436
+ position: z
437
+ .number()
438
+ .int()
439
+ .describe("순서(1부터). 직접 선택은 셀러가 정한 순서, 조건 자동은 컬렉션 기본 정렬의 순위"),
440
+ addedAt: z.string().describe("담은 시각. 조건 자동은 상품 등록 시각"),
212
441
  });
213
442
 
214
443
  export type CollectionProduct = z.infer<typeof collectionProductSchema>;
@@ -248,3 +477,21 @@ export const collectionListQuerySchema = z.object({
248
477
  export const collectionProductListQuerySchema = z.object({
249
478
  ...pageQueryShape({ defaultSize: 100, maxSize: MAX_COLLECTION_PRODUCTS }),
250
479
  });
480
+
481
+ /** `POST /v1/collections/preview` — 저장하지 않고 조건으로 나오는 상품을 본다 */
482
+ export const collectionPreviewRequestSchema = z.object({
483
+ rules: collectionRulesSchema,
484
+ productSort: collectionProductSortRequestSchema
485
+ .optional()
486
+ .describe("미리보기 순서. 생략하거나 `MANUAL`이면 `LATEST`다"),
487
+ });
488
+
489
+ export type CollectionPreviewRequest = z.infer<typeof collectionPreviewRequestSchema>;
490
+
491
+ /** 미리보기 응답 — 조건을 만족하는 판매 중 상품 수와 앞 20개 */
492
+ export const collectionPreviewSchema = z.object({
493
+ count: z.number().int().describe("조건을 만족하는 판매 중 상품 수(조회 시각 기준)"),
494
+ products: z.array(collectionProductSchema).describe("앞 20개(정렬 순)"),
495
+ });
496
+
497
+ export type CollectionPreview = z.infer<typeof collectionPreviewSchema>;