@86d-app/products 0.0.3

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.
Files changed (94) hide show
  1. package/AGENTS.md +65 -0
  2. package/COMPONENTS.md +231 -0
  3. package/README.md +201 -0
  4. package/package.json +46 -0
  5. package/src/__tests__/controllers.test.ts +2227 -0
  6. package/src/__tests__/state.test.ts +138 -0
  7. package/src/admin/components/categories-admin.mdx +3 -0
  8. package/src/admin/components/categories-admin.tsx +449 -0
  9. package/src/admin/components/category-form.mdx +9 -0
  10. package/src/admin/components/category-form.tsx +490 -0
  11. package/src/admin/components/category-list.mdx +75 -0
  12. package/src/admin/components/category-list.tsx +168 -0
  13. package/src/admin/components/collections-admin.mdx +3 -0
  14. package/src/admin/components/collections-admin.tsx +771 -0
  15. package/src/admin/components/index.tsx +8 -0
  16. package/src/admin/components/product-detail.mdx +12 -0
  17. package/src/admin/components/product-detail.tsx +790 -0
  18. package/src/admin/components/product-edit.tsx +60 -0
  19. package/src/admin/components/product-form.tsx +793 -0
  20. package/src/admin/components/product-list.mdx +3 -0
  21. package/src/admin/components/product-list.tsx +1125 -0
  22. package/src/admin/components/product-new.tsx +38 -0
  23. package/src/admin/endpoints/add-collection-product.ts +17 -0
  24. package/src/admin/endpoints/bulk-action.ts +43 -0
  25. package/src/admin/endpoints/create-category.ts +52 -0
  26. package/src/admin/endpoints/create-collection.ts +35 -0
  27. package/src/admin/endpoints/create-product.ts +50 -0
  28. package/src/admin/endpoints/create-variant.ts +45 -0
  29. package/src/admin/endpoints/delete-category.ts +27 -0
  30. package/src/admin/endpoints/delete-collection.ts +12 -0
  31. package/src/admin/endpoints/delete-product.ts +27 -0
  32. package/src/admin/endpoints/delete-variant.ts +27 -0
  33. package/src/admin/endpoints/get-product.ts +23 -0
  34. package/src/admin/endpoints/import-products.ts +47 -0
  35. package/src/admin/endpoints/index.ts +43 -0
  36. package/src/admin/endpoints/list-categories.ts +21 -0
  37. package/src/admin/endpoints/list-collections.ts +20 -0
  38. package/src/admin/endpoints/list-products.ts +25 -0
  39. package/src/admin/endpoints/remove-collection-product.ts +15 -0
  40. package/src/admin/endpoints/update-category.ts +82 -0
  41. package/src/admin/endpoints/update-collection.ts +22 -0
  42. package/src/admin/endpoints/update-product.ts +67 -0
  43. package/src/admin/endpoints/update-variant.ts +41 -0
  44. package/src/controllers.ts +1410 -0
  45. package/src/index.ts +120 -0
  46. package/src/markdown.ts +150 -0
  47. package/src/mdx.d.ts +5 -0
  48. package/src/schema.ts +352 -0
  49. package/src/state.ts +84 -0
  50. package/src/store/components/_hooks.ts +78 -0
  51. package/src/store/components/_types.ts +73 -0
  52. package/src/store/components/_utils.ts +14 -0
  53. package/src/store/components/back-in-stock-notify.tsx +97 -0
  54. package/src/store/components/collection-card.mdx +42 -0
  55. package/src/store/components/collection-card.tsx +12 -0
  56. package/src/store/components/collection-detail.mdx +12 -0
  57. package/src/store/components/collection-detail.tsx +149 -0
  58. package/src/store/components/collection-grid.mdx +9 -0
  59. package/src/store/components/collection-grid.tsx +80 -0
  60. package/src/store/components/featured-products.mdx +9 -0
  61. package/src/store/components/featured-products.tsx +75 -0
  62. package/src/store/components/filter-chip.mdx +25 -0
  63. package/src/store/components/filter-chip.tsx +12 -0
  64. package/src/store/components/index.tsx +39 -0
  65. package/src/store/components/product-card.mdx +69 -0
  66. package/src/store/components/product-card.tsx +71 -0
  67. package/src/store/components/product-detail.mdx +30 -0
  68. package/src/store/components/product-detail.tsx +488 -0
  69. package/src/store/components/product-listing.mdx +7 -0
  70. package/src/store/components/product-listing.tsx +423 -0
  71. package/src/store/components/product-reviews-section.mdx +21 -0
  72. package/src/store/components/product-reviews-section.tsx +372 -0
  73. package/src/store/components/recently-viewed.tsx +100 -0
  74. package/src/store/components/related-products.mdx +6 -0
  75. package/src/store/components/related-products.tsx +62 -0
  76. package/src/store/components/star-display.mdx +18 -0
  77. package/src/store/components/star-display.tsx +27 -0
  78. package/src/store/components/star-picker.mdx +21 -0
  79. package/src/store/components/star-picker.tsx +21 -0
  80. package/src/store/components/stock-badge.mdx +12 -0
  81. package/src/store/components/stock-badge.tsx +19 -0
  82. package/src/store/endpoints/get-category.ts +61 -0
  83. package/src/store/endpoints/get-collection.ts +46 -0
  84. package/src/store/endpoints/get-featured.ts +18 -0
  85. package/src/store/endpoints/get-product.ts +52 -0
  86. package/src/store/endpoints/get-related.ts +20 -0
  87. package/src/store/endpoints/index.ts +23 -0
  88. package/src/store/endpoints/list-categories.ts +13 -0
  89. package/src/store/endpoints/list-collections.ts +22 -0
  90. package/src/store/endpoints/list-products.ts +28 -0
  91. package/src/store/endpoints/search-products.ts +18 -0
  92. package/src/store/endpoints/store-search.ts +111 -0
  93. package/tsconfig.json +9 -0
  94. package/vitest.config.ts +7 -0
@@ -0,0 +1,1410 @@
1
+ import type { ModuleControllers } from "@86d-app/core";
2
+
3
+ /**
4
+ * Product data types
5
+ */
6
+ export interface Product {
7
+ id: string;
8
+ name: string;
9
+ slug: string;
10
+ description?: string | undefined;
11
+ shortDescription?: string | undefined;
12
+ price: number;
13
+ compareAtPrice?: number | undefined;
14
+ costPrice?: number | undefined;
15
+ sku?: string | undefined;
16
+ barcode?: string | undefined;
17
+ inventory: number;
18
+ trackInventory: boolean;
19
+ allowBackorder: boolean;
20
+ status: "draft" | "active" | "archived";
21
+ categoryId?: string | undefined;
22
+ images: string[];
23
+ tags: string[];
24
+ metadata?: Record<string, unknown> | undefined;
25
+ weight?: number | undefined;
26
+ weightUnit?: "kg" | "lb" | "oz" | "g" | undefined;
27
+ isFeatured: boolean;
28
+ createdAt: Date;
29
+ updatedAt: Date;
30
+ }
31
+
32
+ export interface ProductVariant {
33
+ id: string;
34
+ productId: string;
35
+ name: string;
36
+ sku?: string | undefined;
37
+ barcode?: string | undefined;
38
+ price: number;
39
+ compareAtPrice?: number | undefined;
40
+ costPrice?: number | undefined;
41
+ inventory: number;
42
+ options: Record<string, string>;
43
+ images: string[];
44
+ weight?: number | undefined;
45
+ weightUnit?: "kg" | "lb" | "oz" | "g" | undefined;
46
+ position: number;
47
+ createdAt: Date;
48
+ updatedAt: Date;
49
+ }
50
+
51
+ export interface Category {
52
+ id: string;
53
+ name: string;
54
+ slug: string;
55
+ description?: string | undefined;
56
+ parentId?: string | undefined;
57
+ image?: string | undefined;
58
+ position: number;
59
+ isVisible: boolean;
60
+ metadata?: Record<string, unknown> | undefined;
61
+ createdAt: Date;
62
+ updatedAt: Date;
63
+ }
64
+
65
+ export interface ProductWithVariants extends Product {
66
+ variants: ProductVariant[];
67
+ category?: Category | undefined;
68
+ }
69
+
70
+ /**
71
+ * Collection data types
72
+ */
73
+ export interface Collection {
74
+ id: string;
75
+ name: string;
76
+ slug: string;
77
+ description?: string | undefined;
78
+ image?: string | undefined;
79
+ isFeatured: boolean;
80
+ isVisible: boolean;
81
+ position: number;
82
+ metadata?: Record<string, unknown> | undefined;
83
+ createdAt: Date;
84
+ updatedAt: Date;
85
+ }
86
+
87
+ export interface CollectionProduct {
88
+ id: string;
89
+ collectionId: string;
90
+ productId: string;
91
+ position: number;
92
+ createdAt: Date;
93
+ }
94
+
95
+ export interface CollectionWithProducts extends Collection {
96
+ products: Product[];
97
+ }
98
+
99
+ /**
100
+ * CSV Import types
101
+ */
102
+ export interface ImportProductRow {
103
+ name: string;
104
+ slug?: string | undefined;
105
+ price: number | string;
106
+ sku?: string | undefined;
107
+ barcode?: string | undefined;
108
+ description?: string | undefined;
109
+ shortDescription?: string | undefined;
110
+ compareAtPrice?: number | string | undefined;
111
+ costPrice?: number | string | undefined;
112
+ inventory?: number | string | undefined;
113
+ status?: string | undefined;
114
+ category?: string | undefined;
115
+ tags?: string[] | undefined;
116
+ weight?: number | string | undefined;
117
+ weightUnit?: string | undefined;
118
+ featured?: boolean | undefined;
119
+ trackInventory?: boolean | undefined;
120
+ allowBackorder?: boolean | undefined;
121
+ }
122
+
123
+ export interface ImportError {
124
+ row: number;
125
+ field: string;
126
+ message: string;
127
+ }
128
+
129
+ export interface ImportResult {
130
+ created: number;
131
+ updated: number;
132
+ errors: ImportError[];
133
+ }
134
+
135
+ function generateSlug(name: string): string {
136
+ return name
137
+ .toLowerCase()
138
+ .replace(/[^a-z0-9]+/g, "-")
139
+ .replace(/^-+|-+$/g, "");
140
+ }
141
+
142
+ /**
143
+ * Product controllers
144
+ * Access via: context.controllers.product.getById(ctx)
145
+ */
146
+ export const controllers: ModuleControllers = {
147
+ product: {
148
+ async getById(ctx) {
149
+ const { data } = ctx.context;
150
+ const { id } = ctx.params as { id: string };
151
+
152
+ return (await data.get("product", id)) as Product | null;
153
+ },
154
+
155
+ async getBySlug(ctx) {
156
+ const { data } = ctx.context;
157
+ const { slug } = ctx.query as { slug: string };
158
+ const products = (await data.findMany("product", {
159
+ where: { slug },
160
+ })) as Product[];
161
+ return products[0] || null;
162
+ },
163
+
164
+ async getWithVariants(ctx) {
165
+ const { data } = ctx.context;
166
+ const { id } = ctx.params as { id: string };
167
+
168
+ const product = (await data.get("product", id)) as Product | null;
169
+ if (!product) return null;
170
+
171
+ const variants = (await data.findMany("productVariant", {
172
+ where: { productId: id },
173
+ })) as ProductVariant[];
174
+
175
+ let category: Category | undefined;
176
+ if (product.categoryId) {
177
+ category = (await data.get("category", product.categoryId)) as
178
+ | Category
179
+ | undefined;
180
+ }
181
+
182
+ return { ...product, variants, category } as ProductWithVariants;
183
+ },
184
+
185
+ async list(ctx) {
186
+ const { data } = ctx.context;
187
+ const query = (ctx.query || {}) as {
188
+ page?: string;
189
+ limit?: string;
190
+ category?: string;
191
+ status?: string;
192
+ featured?: string;
193
+ search?: string;
194
+ sort?: string;
195
+ order?: string;
196
+ minPrice?: string;
197
+ maxPrice?: string;
198
+ inStock?: string;
199
+ tag?: string;
200
+ };
201
+
202
+ const page = query.page ? parseInt(query.page, 10) : 1;
203
+ const limit = query.limit ? parseInt(query.limit, 10) : 20;
204
+
205
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
206
+ const where: Record<string, any> = {};
207
+ if (query.category) where.categoryId = query.category;
208
+ if (query.status) where.status = query.status;
209
+ if (query.featured === "true") where.isFeatured = true;
210
+
211
+ // Fetch all matching products for client-side filters + total count
212
+ let allProducts = (await data.findMany("product", {
213
+ where,
214
+ orderBy: query.sort
215
+ ? { [query.sort]: query.order || "desc" }
216
+ : { createdAt: "desc" },
217
+ })) as Product[];
218
+
219
+ // Apply price range filter
220
+ const minPrice = query.minPrice
221
+ ? parseInt(query.minPrice, 10)
222
+ : undefined;
223
+ const maxPrice = query.maxPrice
224
+ ? parseInt(query.maxPrice, 10)
225
+ : undefined;
226
+ if (minPrice !== undefined) {
227
+ allProducts = allProducts.filter((p) => p.price >= minPrice);
228
+ }
229
+ if (maxPrice !== undefined) {
230
+ allProducts = allProducts.filter((p) => p.price <= maxPrice);
231
+ }
232
+
233
+ // Apply in-stock filter
234
+ if (query.inStock === "true") {
235
+ allProducts = allProducts.filter((p) => p.inventory > 0);
236
+ }
237
+
238
+ // Apply tag filter
239
+ if (query.tag) {
240
+ const tagLower = query.tag.toLowerCase();
241
+ allProducts = allProducts.filter((p) =>
242
+ p.tags.some((t) => t.toLowerCase() === tagLower),
243
+ );
244
+ }
245
+
246
+ // Apply search filter (name, description, tags)
247
+ if (query.search) {
248
+ const searchLower = query.search.toLowerCase();
249
+ allProducts = allProducts.filter(
250
+ (p) =>
251
+ p.name.toLowerCase().includes(searchLower) ||
252
+ p.description?.toLowerCase().includes(searchLower) ||
253
+ p.tags.some((t) => t.toLowerCase().includes(searchLower)),
254
+ );
255
+ }
256
+
257
+ const total = allProducts.length;
258
+
259
+ // Paginate
260
+ const paged = allProducts.slice((page - 1) * limit, page * limit);
261
+
262
+ // Get variants and categories for each product on this page
263
+ const productsWithVariants: ProductWithVariants[] = await Promise.all(
264
+ paged.map(async (product) => {
265
+ const variants = (await data.findMany("productVariant", {
266
+ where: { productId: product.id },
267
+ })) as ProductVariant[];
268
+
269
+ let category: Category | undefined;
270
+ if (product.categoryId) {
271
+ category = (await data.get("category", product.categoryId)) as
272
+ | Category
273
+ | undefined;
274
+ }
275
+
276
+ return { ...product, variants, category };
277
+ }),
278
+ );
279
+
280
+ return {
281
+ products: productsWithVariants,
282
+ total,
283
+ page,
284
+ limit,
285
+ };
286
+ },
287
+
288
+ async search(ctx) {
289
+ const { data } = ctx.context;
290
+ const { q, limit: limitStr } = ctx.query as { q: string; limit?: string };
291
+ const limit = limitStr ? parseInt(limitStr, 10) : 20;
292
+
293
+ const products = (await data.findMany("product", {
294
+ where: { status: "active" },
295
+ })) as Product[];
296
+
297
+ const queryLower = q.toLowerCase();
298
+ const results = products.filter(
299
+ (p) =>
300
+ p.name.toLowerCase().includes(queryLower) ||
301
+ p.description?.toLowerCase().includes(queryLower) ||
302
+ p.tags.some((t) => t.toLowerCase().includes(queryLower)),
303
+ );
304
+
305
+ return results.slice(0, limit);
306
+ },
307
+
308
+ async getFeatured(ctx) {
309
+ const { data } = ctx.context;
310
+ const { limit: limitStr } = (ctx.query || {}) as { limit?: string };
311
+ const limit = limitStr ? parseInt(limitStr, 10) : 10;
312
+
313
+ return (await data.findMany("product", {
314
+ where: { isFeatured: true, status: "active" },
315
+ take: limit,
316
+ })) as Product[];
317
+ },
318
+
319
+ async getByCategory(ctx) {
320
+ const { data } = ctx.context;
321
+ const { categoryId } = ctx.params as { categoryId: string };
322
+
323
+ return (await data.findMany("product", {
324
+ where: { categoryId, status: "active" },
325
+ })) as Product[];
326
+ },
327
+
328
+ async getRelated(ctx) {
329
+ const { data } = ctx.context;
330
+ const { id } = ctx.params as { id: string };
331
+ const { limit: limitStr } = (ctx.query || {}) as { limit?: string };
332
+ const limit = limitStr ? parseInt(limitStr, 10) : 4;
333
+
334
+ const product = (await data.get("product", id)) as Product | null;
335
+ if (!product) return { products: [] };
336
+
337
+ // Get all active products except the current one
338
+ const all = (
339
+ (await data.findMany("product", {
340
+ where: { status: "active" },
341
+ })) as Product[]
342
+ ).filter((p) => p.id !== id);
343
+
344
+ // Score by relevance: same category > shared tags > nothing
345
+ const scored = all.map((p) => {
346
+ let score = 0;
347
+ if (product.categoryId && p.categoryId === product.categoryId) {
348
+ score += 10;
349
+ }
350
+ const sharedTags = p.tags.filter((t) => product.tags.includes(t));
351
+ score += sharedTags.length;
352
+ return { product: p, score };
353
+ });
354
+
355
+ // Sort by score desc, take top N
356
+ scored.sort((a, b) => b.score - a.score);
357
+ const related = scored.slice(0, limit).map((s) => s.product);
358
+
359
+ return { products: related };
360
+ },
361
+
362
+ async create(ctx) {
363
+ const { data } = ctx.context;
364
+ const body = ctx.body as Partial<Product> & {
365
+ name: string;
366
+ slug: string;
367
+ price: number;
368
+ };
369
+
370
+ const now = new Date();
371
+ const id = `prod_${Date.now()}`;
372
+
373
+ const product: Product = {
374
+ id,
375
+ name: body.name,
376
+ slug: body.slug,
377
+ description: body.description,
378
+ shortDescription: body.shortDescription,
379
+ price: body.price,
380
+ compareAtPrice: body.compareAtPrice,
381
+ costPrice: body.costPrice,
382
+ sku: body.sku,
383
+ barcode: body.barcode,
384
+ inventory: body.inventory ?? 0,
385
+ trackInventory: body.trackInventory ?? true,
386
+ allowBackorder: body.allowBackorder ?? false,
387
+ status: body.status ?? "draft",
388
+ categoryId: body.categoryId,
389
+ images: body.images ?? [],
390
+ tags: body.tags ?? [],
391
+ metadata: body.metadata ?? {},
392
+ weight: body.weight,
393
+ weightUnit: body.weightUnit ?? "kg",
394
+ isFeatured: body.isFeatured ?? false,
395
+ createdAt: now,
396
+ updatedAt: now,
397
+ };
398
+
399
+ await data.upsert(
400
+ "product",
401
+ id,
402
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
403
+ product as unknown as Record<string, any>,
404
+ );
405
+ return product;
406
+ },
407
+
408
+ async update(ctx) {
409
+ const { data } = ctx.context;
410
+ const { id } = ctx.params as { id: string };
411
+ const body = ctx.body as Partial<Product>;
412
+
413
+ const existing = (await data.get("product", id)) as Product | null;
414
+ if (!existing) throw new Error(`Product ${id} not found`);
415
+
416
+ const updated: Product = { ...existing, ...body, updatedAt: new Date() };
417
+ await data.upsert(
418
+ "product",
419
+ id,
420
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
421
+ updated as unknown as Record<string, any>,
422
+ );
423
+ return updated;
424
+ },
425
+
426
+ async delete(ctx) {
427
+ const { data } = ctx.context;
428
+ const { id } = ctx.params as { id: string };
429
+
430
+ const variants = (await data.findMany("productVariant", {
431
+ where: { productId: id },
432
+ })) as ProductVariant[];
433
+
434
+ for (const variant of variants) {
435
+ await data.delete("productVariant", variant.id);
436
+ }
437
+
438
+ await data.delete("product", id);
439
+ return { success: true };
440
+ },
441
+
442
+ async checkAvailability(ctx) {
443
+ const { data } = ctx.context;
444
+ const {
445
+ productId,
446
+ variantId,
447
+ quantity: qtyStr,
448
+ } = ctx.query as {
449
+ productId: string;
450
+ variantId?: string;
451
+ quantity?: string;
452
+ };
453
+ const quantity = qtyStr ? parseInt(qtyStr, 10) : 1;
454
+
455
+ if (variantId) {
456
+ const variant = (await data.get(
457
+ "productVariant",
458
+ variantId,
459
+ )) as ProductVariant | null;
460
+ if (!variant)
461
+ return { available: false, inventory: 0, allowBackorder: false };
462
+
463
+ const product = (await data.get(
464
+ "product",
465
+ productId,
466
+ )) as Product | null;
467
+ const allowBackorder = product?.allowBackorder ?? false;
468
+
469
+ return {
470
+ available: variant.inventory >= quantity || allowBackorder,
471
+ inventory: variant.inventory,
472
+ allowBackorder,
473
+ };
474
+ }
475
+
476
+ const product = (await data.get("product", productId)) as Product | null;
477
+ if (!product)
478
+ return { available: false, inventory: 0, allowBackorder: false };
479
+
480
+ if (!product.trackInventory) {
481
+ return {
482
+ available: true,
483
+ inventory: product.inventory,
484
+ allowBackorder: product.allowBackorder,
485
+ };
486
+ }
487
+
488
+ return {
489
+ available: product.inventory >= quantity || product.allowBackorder,
490
+ inventory: product.inventory,
491
+ allowBackorder: product.allowBackorder,
492
+ };
493
+ },
494
+
495
+ async decrementInventory(ctx) {
496
+ const { data } = ctx.context;
497
+ const { productId, variantId } = ctx.params as {
498
+ productId: string;
499
+ variantId?: string;
500
+ };
501
+ const { quantity } = ctx.body as { quantity: number };
502
+
503
+ if (variantId) {
504
+ const variant = (await data.get(
505
+ "productVariant",
506
+ variantId,
507
+ )) as ProductVariant | null;
508
+ if (variant) {
509
+ await data.upsert("productVariant", variantId, {
510
+ ...variant,
511
+ inventory: variant.inventory - quantity,
512
+ updatedAt: new Date(),
513
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
514
+ } as Record<string, any>);
515
+ }
516
+ } else {
517
+ const product = (await data.get(
518
+ "product",
519
+ productId,
520
+ )) as Product | null;
521
+ if (product?.trackInventory) {
522
+ await data.upsert("product", productId, {
523
+ ...product,
524
+ inventory: product.inventory - quantity,
525
+ updatedAt: new Date(),
526
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
527
+ } as Record<string, any>);
528
+ }
529
+ }
530
+ return { success: true };
531
+ },
532
+
533
+ async incrementInventory(ctx) {
534
+ const { data } = ctx.context;
535
+ const { productId, variantId } = ctx.params as {
536
+ productId: string;
537
+ variantId?: string;
538
+ };
539
+ const { quantity } = ctx.body as { quantity: number };
540
+
541
+ if (variantId) {
542
+ const variant = (await data.get(
543
+ "productVariant",
544
+ variantId,
545
+ )) as ProductVariant | null;
546
+ if (variant) {
547
+ await data.upsert("productVariant", variantId, {
548
+ ...variant,
549
+ inventory: variant.inventory + quantity,
550
+ updatedAt: new Date(),
551
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
552
+ } as Record<string, any>);
553
+ }
554
+ } else {
555
+ const product = (await data.get(
556
+ "product",
557
+ productId,
558
+ )) as Product | null;
559
+ if (product?.trackInventory) {
560
+ await data.upsert("product", productId, {
561
+ ...product,
562
+ inventory: product.inventory + quantity,
563
+ updatedAt: new Date(),
564
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
565
+ } as Record<string, any>);
566
+ }
567
+ }
568
+ return { success: true };
569
+ },
570
+ },
571
+
572
+ variant: {
573
+ async getById(ctx) {
574
+ const { data } = ctx.context;
575
+ const { id } = ctx.params as { id: string };
576
+ return (await data.get("productVariant", id)) as ProductVariant | null;
577
+ },
578
+
579
+ async getByProduct(ctx) {
580
+ const { data } = ctx.context;
581
+ const { productId } = ctx.params as { productId: string };
582
+
583
+ const variants = (await data.findMany("productVariant", {
584
+ where: { productId },
585
+ })) as ProductVariant[];
586
+
587
+ return variants.sort((a, b) => a.position - b.position);
588
+ },
589
+
590
+ async create(ctx) {
591
+ const { data } = ctx.context;
592
+ const { productId } = ctx.params as { productId: string };
593
+ const body = ctx.body as Partial<ProductVariant> & {
594
+ name: string;
595
+ price: number;
596
+ options: Record<string, string>;
597
+ };
598
+
599
+ const now = new Date();
600
+ const id = `var_${Date.now()}`;
601
+
602
+ const variant: ProductVariant = {
603
+ id,
604
+ productId,
605
+ name: body.name,
606
+ sku: body.sku,
607
+ barcode: body.barcode,
608
+ price: body.price,
609
+ compareAtPrice: body.compareAtPrice,
610
+ costPrice: body.costPrice,
611
+ inventory: body.inventory ?? 0,
612
+ options: body.options,
613
+ images: body.images ?? [],
614
+ weight: body.weight,
615
+ weightUnit: body.weightUnit,
616
+ position: body.position ?? 0,
617
+ createdAt: now,
618
+ updatedAt: now,
619
+ };
620
+
621
+ await data.upsert(
622
+ "productVariant",
623
+ id,
624
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
625
+ variant as unknown as Record<string, any>,
626
+ );
627
+
628
+ // Update product timestamp
629
+ const product = (await data.get("product", productId)) as Product | null;
630
+ if (product) {
631
+ await data.upsert("product", productId, {
632
+ ...product,
633
+ updatedAt: now,
634
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
635
+ } as unknown as Record<string, any>);
636
+ }
637
+
638
+ return variant;
639
+ },
640
+
641
+ async update(ctx) {
642
+ const { data } = ctx.context;
643
+ const { id } = ctx.params as { id: string };
644
+ const body = ctx.body as Partial<ProductVariant>;
645
+
646
+ const existing = (await data.get(
647
+ "productVariant",
648
+ id,
649
+ )) as ProductVariant | null;
650
+ if (!existing) throw new Error(`Variant ${id} not found`);
651
+
652
+ const now = new Date();
653
+ const updated: ProductVariant = { ...existing, ...body, updatedAt: now };
654
+ await data.upsert(
655
+ "productVariant",
656
+ id,
657
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
658
+ updated as unknown as Record<string, any>,
659
+ );
660
+
661
+ // Update product timestamp
662
+ const product = (await data.get(
663
+ "product",
664
+ existing.productId,
665
+ )) as Product | null;
666
+ if (product) {
667
+ await data.upsert("product", existing.productId, {
668
+ ...product,
669
+ updatedAt: now,
670
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
671
+ } as unknown as Record<string, any>);
672
+ }
673
+
674
+ return updated;
675
+ },
676
+
677
+ async delete(ctx) {
678
+ const { data } = ctx.context;
679
+ const { id } = ctx.params as { id: string };
680
+
681
+ const variant = (await data.get(
682
+ "productVariant",
683
+ id,
684
+ )) as ProductVariant | null;
685
+ if (variant) {
686
+ await data.delete("productVariant", id);
687
+
688
+ const product = (await data.get(
689
+ "product",
690
+ variant.productId,
691
+ )) as Product | null;
692
+ if (product) {
693
+ await data.upsert("product", variant.productId, {
694
+ ...product,
695
+ updatedAt: new Date(),
696
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
697
+ } as unknown as Record<string, any>);
698
+ }
699
+ }
700
+
701
+ return { success: true };
702
+ },
703
+ },
704
+
705
+ category: {
706
+ async getById(ctx) {
707
+ const { data } = ctx.context;
708
+ const { id } = ctx.params as { id: string };
709
+ return (await data.get("category", id)) as Category | null;
710
+ },
711
+
712
+ async getBySlug(ctx) {
713
+ const { data } = ctx.context;
714
+ const { slug } = ctx.query as { slug: string };
715
+
716
+ const categories = (await data.findMany("category", {
717
+ where: { slug },
718
+ })) as Category[];
719
+
720
+ return categories[0] || null;
721
+ },
722
+
723
+ async list(ctx) {
724
+ const { data } = ctx.context;
725
+ const query = (ctx.query || {}) as {
726
+ page?: string;
727
+ limit?: string;
728
+ parentId?: string;
729
+ visible?: string;
730
+ };
731
+
732
+ const page = query.page ? parseInt(query.page, 10) : 1;
733
+ const limit = query.limit ? parseInt(query.limit, 10) : 50;
734
+
735
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
736
+ const where: Record<string, any> = {};
737
+ if (query.parentId) where.parentId = query.parentId;
738
+ if (query.visible === "true") where.isVisible = true;
739
+
740
+ const categories = (await data.findMany("category", {
741
+ where,
742
+ take: limit,
743
+ skip: (page - 1) * limit,
744
+ })) as Category[];
745
+
746
+ return {
747
+ categories: categories.sort((a, b) => a.position - b.position),
748
+ page,
749
+ limit,
750
+ };
751
+ },
752
+
753
+ async getTree(ctx) {
754
+ const { data } = ctx.context;
755
+
756
+ const allCategories = (await data.findMany("category", {
757
+ where: { isVisible: true },
758
+ })) as Category[];
759
+
760
+ const rootCategories: (Category & { children: Category[] })[] = [];
761
+ const categoryMap = new Map<
762
+ string,
763
+ Category & { children: Category[] }
764
+ >();
765
+
766
+ for (const cat of allCategories) {
767
+ categoryMap.set(cat.id, { ...cat, children: [] });
768
+ }
769
+
770
+ for (const cat of allCategories) {
771
+ // biome-ignore lint/style/noNonNullAssertion: categoryMap is populated from allCategories in same loop
772
+ const catWithChildren = categoryMap.get(cat.id)!;
773
+ if (cat.parentId) {
774
+ const parent = categoryMap.get(cat.parentId);
775
+ if (parent) {
776
+ parent.children.push(catWithChildren);
777
+ } else {
778
+ rootCategories.push(catWithChildren);
779
+ }
780
+ } else {
781
+ rootCategories.push(catWithChildren);
782
+ }
783
+ }
784
+
785
+ rootCategories.sort((a, b) => a.position - b.position);
786
+ for (const cat of categoryMap.values()) {
787
+ cat.children.sort((a, b) => a.position - b.position);
788
+ }
789
+
790
+ return rootCategories;
791
+ },
792
+
793
+ async create(ctx) {
794
+ const { data } = ctx.context;
795
+ const body = ctx.body as Partial<Category> & {
796
+ name: string;
797
+ slug: string;
798
+ };
799
+
800
+ const now = new Date();
801
+ const id = `cat_${Date.now()}`;
802
+
803
+ const category: Category = {
804
+ id,
805
+ name: body.name,
806
+ slug: body.slug,
807
+ description: body.description,
808
+ parentId: body.parentId,
809
+ image: body.image,
810
+ position: body.position ?? 0,
811
+ isVisible: body.isVisible ?? true,
812
+ metadata: body.metadata ?? {},
813
+ createdAt: now,
814
+ updatedAt: now,
815
+ };
816
+
817
+ await data.upsert(
818
+ "category",
819
+ id,
820
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
821
+ category as unknown as Record<string, any>,
822
+ );
823
+ return category;
824
+ },
825
+
826
+ async update(ctx) {
827
+ const { data } = ctx.context;
828
+ const { id } = ctx.params as { id: string };
829
+ const body = ctx.body as Partial<Category>;
830
+
831
+ const existing = (await data.get("category", id)) as Category | null;
832
+ if (!existing) throw new Error(`Category ${id} not found`);
833
+
834
+ const updated: Category = { ...existing, ...body, updatedAt: new Date() };
835
+ await data.upsert(
836
+ "category",
837
+ id,
838
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
839
+ updated as unknown as Record<string, any>,
840
+ );
841
+ return updated;
842
+ },
843
+
844
+ async delete(ctx) {
845
+ const { data } = ctx.context;
846
+ const { id } = ctx.params as { id: string };
847
+
848
+ // Remove category from products
849
+ const products = (await data.findMany("product", {
850
+ where: { categoryId: id },
851
+ })) as Product[];
852
+
853
+ for (const product of products) {
854
+ await data.upsert("product", product.id, {
855
+ ...product,
856
+ categoryId: undefined,
857
+ updatedAt: new Date(),
858
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
859
+ } as unknown as Record<string, any>);
860
+ }
861
+
862
+ // Remove from subcategories
863
+ const subcategories = (await data.findMany("category", {
864
+ where: { parentId: id },
865
+ })) as Category[];
866
+
867
+ for (const subcat of subcategories) {
868
+ await data.upsert("category", subcat.id, {
869
+ ...subcat,
870
+ parentId: undefined,
871
+ updatedAt: new Date(),
872
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
873
+ } as unknown as Record<string, any>);
874
+ }
875
+
876
+ await data.delete("category", id);
877
+ return { success: true };
878
+ },
879
+ },
880
+
881
+ bulk: {
882
+ async updateStatus(ctx) {
883
+ const { data } = ctx.context;
884
+ const { ids, status } = ctx.body as {
885
+ ids: string[];
886
+ status: "draft" | "active" | "archived";
887
+ };
888
+
889
+ if (!ids.length) return { updated: 0 };
890
+
891
+ const now = new Date();
892
+ let updated = 0;
893
+
894
+ for (const id of ids) {
895
+ const product = (await data.get("product", id)) as Product | null;
896
+ if (product) {
897
+ await data.upsert("product", id, {
898
+ ...product,
899
+ status,
900
+ updatedAt: now,
901
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
902
+ } as unknown as Record<string, any>);
903
+ updated++;
904
+ }
905
+ }
906
+
907
+ return { updated };
908
+ },
909
+
910
+ async deleteMany(ctx) {
911
+ const { data } = ctx.context;
912
+ const { ids } = ctx.body as { ids: string[] };
913
+
914
+ if (!ids.length) return { deleted: 0 };
915
+
916
+ let deleted = 0;
917
+
918
+ for (const id of ids) {
919
+ const product = (await data.get("product", id)) as Product | null;
920
+ if (!product) continue;
921
+
922
+ // Delete associated variants
923
+ const variants = (await data.findMany("productVariant", {
924
+ where: { productId: id },
925
+ })) as ProductVariant[];
926
+
927
+ for (const variant of variants) {
928
+ await data.delete("productVariant", variant.id);
929
+ }
930
+
931
+ await data.delete("product", id);
932
+ deleted++;
933
+ }
934
+
935
+ return { deleted };
936
+ },
937
+ },
938
+
939
+ import: {
940
+ async importProducts(ctx) {
941
+ const { data } = ctx.context;
942
+ const { products: rows } = ctx.body as {
943
+ products: ImportProductRow[];
944
+ };
945
+
946
+ const created: string[] = [];
947
+ const updated: string[] = [];
948
+ const errors: ImportError[] = [];
949
+
950
+ // Pre-fetch all categories for name→id resolution
951
+ const allCategories = (await data.findMany("category", {
952
+ where: {},
953
+ })) as Category[];
954
+ const categoryByName = new Map<string, string>();
955
+ for (const cat of allCategories) {
956
+ categoryByName.set(cat.name.toLowerCase(), cat.id);
957
+ }
958
+
959
+ // Pre-fetch existing SKUs for update-by-SKU matching
960
+ const allProducts = (await data.findMany("product", {
961
+ where: {},
962
+ })) as Product[];
963
+ const productBySku = new Map<string, Product>();
964
+ const slugSet = new Set<string>();
965
+ for (const p of allProducts) {
966
+ if (p.sku) productBySku.set(p.sku, p);
967
+ slugSet.add(p.slug);
968
+ }
969
+
970
+ for (let i = 0; i < rows.length; i++) {
971
+ const row = rows[i];
972
+ try {
973
+ // Validate required fields
974
+ if (!row.name || row.name.trim() === "") {
975
+ errors.push({
976
+ row: i + 1,
977
+ field: "name",
978
+ message: "Name is required",
979
+ });
980
+ continue;
981
+ }
982
+ if (row.price === undefined || row.price === null) {
983
+ errors.push({
984
+ row: i + 1,
985
+ field: "price",
986
+ message: "Price is required",
987
+ });
988
+ continue;
989
+ }
990
+ const price = Math.round(Number(row.price) * 100);
991
+ if (Number.isNaN(price) || price <= 0) {
992
+ errors.push({
993
+ row: i + 1,
994
+ field: "price",
995
+ message: "Price must be a positive number",
996
+ });
997
+ continue;
998
+ }
999
+
1000
+ // Check if updating existing product by SKU
1001
+ const existingBySku = row.sku ? productBySku.get(row.sku) : undefined;
1002
+ if (existingBySku) {
1003
+ // Update existing product
1004
+ const updateFields: Partial<Product> = {
1005
+ name: row.name,
1006
+ price,
1007
+ updatedAt: new Date(),
1008
+ };
1009
+ if (row.description !== undefined)
1010
+ updateFields.description = row.description;
1011
+ if (row.shortDescription !== undefined)
1012
+ updateFields.shortDescription = row.shortDescription;
1013
+ if (row.compareAtPrice !== undefined)
1014
+ updateFields.compareAtPrice = Math.round(
1015
+ Number(row.compareAtPrice) * 100,
1016
+ );
1017
+ if (row.costPrice !== undefined)
1018
+ updateFields.costPrice = Math.round(Number(row.costPrice) * 100);
1019
+ if (row.inventory !== undefined)
1020
+ updateFields.inventory = Number(row.inventory);
1021
+ if (row.status !== undefined)
1022
+ updateFields.status = row.status as
1023
+ | "draft"
1024
+ | "active"
1025
+ | "archived";
1026
+ if (row.category) {
1027
+ const catId = categoryByName.get(row.category.toLowerCase());
1028
+ if (catId) updateFields.categoryId = catId;
1029
+ }
1030
+ if (row.tags !== undefined) updateFields.tags = row.tags;
1031
+ if (row.weight !== undefined)
1032
+ updateFields.weight = Number(row.weight);
1033
+ if (row.weightUnit !== undefined)
1034
+ updateFields.weightUnit = row.weightUnit as
1035
+ | "kg"
1036
+ | "lb"
1037
+ | "oz"
1038
+ | "g";
1039
+ if (row.featured !== undefined)
1040
+ updateFields.isFeatured = row.featured;
1041
+ if (row.trackInventory !== undefined)
1042
+ updateFields.trackInventory = row.trackInventory;
1043
+ if (row.allowBackorder !== undefined)
1044
+ updateFields.allowBackorder = row.allowBackorder;
1045
+
1046
+ const updatedProduct = {
1047
+ ...existingBySku,
1048
+ ...updateFields,
1049
+ };
1050
+ await data.upsert(
1051
+ "product",
1052
+ existingBySku.id,
1053
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1054
+ updatedProduct as unknown as Record<string, any>,
1055
+ );
1056
+ updated.push(existingBySku.id);
1057
+ continue;
1058
+ }
1059
+
1060
+ // Generate slug if not provided
1061
+ let slug = row.slug || generateSlug(row.name);
1062
+ // Ensure slug uniqueness
1063
+ let slugAttempt = 0;
1064
+ const baseSlug = slug;
1065
+ while (slugSet.has(slug)) {
1066
+ slugAttempt++;
1067
+ slug = `${baseSlug}-${slugAttempt}`;
1068
+ }
1069
+ slugSet.add(slug);
1070
+
1071
+ // Resolve category name to ID
1072
+ let categoryId: string | undefined;
1073
+ if (row.category) {
1074
+ categoryId = categoryByName.get(row.category.toLowerCase());
1075
+ }
1076
+
1077
+ const now = new Date();
1078
+ const id = `prod_${Date.now()}_${i}`;
1079
+
1080
+ const product: Product = {
1081
+ id,
1082
+ name: row.name.trim(),
1083
+ slug,
1084
+ description: row.description,
1085
+ shortDescription: row.shortDescription,
1086
+ price,
1087
+ compareAtPrice: row.compareAtPrice
1088
+ ? Math.round(Number(row.compareAtPrice) * 100)
1089
+ : undefined,
1090
+ costPrice: row.costPrice
1091
+ ? Math.round(Number(row.costPrice) * 100)
1092
+ : undefined,
1093
+ sku: row.sku,
1094
+ barcode: row.barcode,
1095
+ inventory: row.inventory !== undefined ? Number(row.inventory) : 0,
1096
+ trackInventory: row.trackInventory ?? true,
1097
+ allowBackorder: row.allowBackorder ?? false,
1098
+ status: (row.status as "draft" | "active" | "archived") || "draft",
1099
+ categoryId,
1100
+ images: [],
1101
+ tags: row.tags ?? [],
1102
+ metadata: {},
1103
+ weight: row.weight !== undefined ? Number(row.weight) : undefined,
1104
+ weightUnit: (row.weightUnit as "kg" | "lb" | "oz" | "g") || "kg",
1105
+ isFeatured: row.featured ?? false,
1106
+ createdAt: now,
1107
+ updatedAt: now,
1108
+ };
1109
+
1110
+ await data.upsert(
1111
+ "product",
1112
+ id,
1113
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1114
+ product as unknown as Record<string, any>,
1115
+ );
1116
+ created.push(id);
1117
+ } catch (err) {
1118
+ errors.push({
1119
+ row: i + 1,
1120
+ field: "unknown",
1121
+ message: err instanceof Error ? err.message : "Unknown error",
1122
+ });
1123
+ }
1124
+ }
1125
+
1126
+ return { created: created.length, updated: updated.length, errors };
1127
+ },
1128
+ },
1129
+
1130
+ collection: {
1131
+ async getById(ctx) {
1132
+ const { data } = ctx.context;
1133
+ const { id } = ctx.params as { id: string };
1134
+ return (await data.get("collection", id)) as Collection | null;
1135
+ },
1136
+
1137
+ async getBySlug(ctx) {
1138
+ const { data } = ctx.context;
1139
+ const { slug } = ctx.query as { slug: string };
1140
+ const collections = (await data.findMany("collection", {
1141
+ where: { slug },
1142
+ })) as Collection[];
1143
+ return collections[0] || null;
1144
+ },
1145
+
1146
+ async list(ctx) {
1147
+ const { data } = ctx.context;
1148
+ const query = (ctx.query || {}) as {
1149
+ page?: string;
1150
+ limit?: string;
1151
+ featured?: string;
1152
+ visible?: string;
1153
+ };
1154
+
1155
+ const page = query.page ? parseInt(query.page, 10) : 1;
1156
+ const limit = query.limit ? parseInt(query.limit, 10) : 50;
1157
+
1158
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1159
+ const where: Record<string, any> = {};
1160
+ if (query.featured === "true") where.isFeatured = true;
1161
+ if (query.visible === "true") where.isVisible = true;
1162
+
1163
+ const collections = (await data.findMany("collection", {
1164
+ where,
1165
+ take: limit,
1166
+ skip: (page - 1) * limit,
1167
+ })) as Collection[];
1168
+
1169
+ return {
1170
+ collections: collections.sort((a, b) => a.position - b.position),
1171
+ page,
1172
+ limit,
1173
+ };
1174
+ },
1175
+
1176
+ async search(ctx) {
1177
+ const { data } = ctx.context;
1178
+ const { q, limit: limitStr } = ctx.query as { q: string; limit?: string };
1179
+ const limit = limitStr ? parseInt(limitStr, 10) : 10;
1180
+
1181
+ const collections = (await data.findMany("collection", {
1182
+ where: { isVisible: true },
1183
+ })) as Collection[];
1184
+
1185
+ const queryLower = q.toLowerCase();
1186
+ const results = collections.filter(
1187
+ (c) =>
1188
+ c.name.toLowerCase().includes(queryLower) ||
1189
+ c.slug.toLowerCase().includes(queryLower) ||
1190
+ c.description?.toLowerCase().includes(queryLower),
1191
+ );
1192
+
1193
+ return results.sort((a, b) => a.position - b.position).slice(0, limit);
1194
+ },
1195
+
1196
+ async getWithProducts(ctx) {
1197
+ const { data } = ctx.context;
1198
+ const { id } = ctx.params as { id: string };
1199
+
1200
+ const collection = (await data.get(
1201
+ "collection",
1202
+ id,
1203
+ )) as Collection | null;
1204
+ if (!collection) return null;
1205
+
1206
+ const links = (await data.findMany("collectionProduct", {
1207
+ where: { collectionId: id },
1208
+ })) as CollectionProduct[];
1209
+
1210
+ links.sort((a, b) => a.position - b.position);
1211
+
1212
+ const products: Product[] = [];
1213
+ for (const link of links) {
1214
+ const product = (await data.get(
1215
+ "product",
1216
+ link.productId,
1217
+ )) as Product | null;
1218
+ if (product && product.status === "active") {
1219
+ products.push(product);
1220
+ }
1221
+ }
1222
+
1223
+ return { ...collection, products } as CollectionWithProducts;
1224
+ },
1225
+
1226
+ async create(ctx) {
1227
+ const { data } = ctx.context;
1228
+ const body = ctx.body as Partial<Collection> & {
1229
+ name: string;
1230
+ slug: string;
1231
+ };
1232
+
1233
+ const now = new Date();
1234
+ const id = `col_${Date.now()}`;
1235
+
1236
+ const collection: Collection = {
1237
+ id,
1238
+ name: body.name,
1239
+ slug: body.slug,
1240
+ description: body.description,
1241
+ image: body.image,
1242
+ isFeatured: body.isFeatured ?? false,
1243
+ isVisible: body.isVisible ?? true,
1244
+ position: body.position ?? 0,
1245
+ metadata: body.metadata ?? {},
1246
+ createdAt: now,
1247
+ updatedAt: now,
1248
+ };
1249
+
1250
+ await data.upsert(
1251
+ "collection",
1252
+ id,
1253
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1254
+ collection as unknown as Record<string, any>,
1255
+ );
1256
+ return collection;
1257
+ },
1258
+
1259
+ async update(ctx) {
1260
+ const { data } = ctx.context;
1261
+ const { id } = ctx.params as { id: string };
1262
+ const body = ctx.body as Partial<Collection>;
1263
+
1264
+ const existing = (await data.get("collection", id)) as Collection | null;
1265
+ if (!existing) throw new Error(`Collection ${id} not found`);
1266
+
1267
+ const updated: Collection = {
1268
+ ...existing,
1269
+ ...body,
1270
+ updatedAt: new Date(),
1271
+ };
1272
+ await data.upsert(
1273
+ "collection",
1274
+ id,
1275
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1276
+ updated as unknown as Record<string, any>,
1277
+ );
1278
+ return updated;
1279
+ },
1280
+
1281
+ async delete(ctx) {
1282
+ const { data } = ctx.context;
1283
+ const { id } = ctx.params as { id: string };
1284
+
1285
+ // Remove all collection-product links
1286
+ const links = (await data.findMany("collectionProduct", {
1287
+ where: { collectionId: id },
1288
+ })) as CollectionProduct[];
1289
+
1290
+ for (const link of links) {
1291
+ await data.delete("collectionProduct", link.id);
1292
+ }
1293
+
1294
+ await data.delete("collection", id);
1295
+ return { success: true };
1296
+ },
1297
+
1298
+ async addProduct(ctx) {
1299
+ const { data } = ctx.context;
1300
+ const { id: collectionId } = ctx.params as { id: string };
1301
+ const { productId, position } = ctx.body as {
1302
+ productId: string;
1303
+ position?: number | undefined;
1304
+ };
1305
+
1306
+ // Check collection exists
1307
+ const collection = (await data.get(
1308
+ "collection",
1309
+ collectionId,
1310
+ )) as Collection | null;
1311
+ if (!collection) throw new Error(`Collection ${collectionId} not found`);
1312
+
1313
+ // Check if product already in collection
1314
+ const existing = (await data.findMany("collectionProduct", {
1315
+ where: { collectionId, productId },
1316
+ })) as CollectionProduct[];
1317
+ if (existing.length > 0) {
1318
+ return existing[0];
1319
+ }
1320
+
1321
+ const linkId = `cp_${Date.now()}`;
1322
+ const link: CollectionProduct = {
1323
+ id: linkId,
1324
+ collectionId,
1325
+ productId,
1326
+ position: position ?? 0,
1327
+ createdAt: new Date(),
1328
+ };
1329
+
1330
+ await data.upsert(
1331
+ "collectionProduct",
1332
+ linkId,
1333
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1334
+ link as unknown as Record<string, any>,
1335
+ );
1336
+
1337
+ // Update collection timestamp
1338
+ await data.upsert("collection", collectionId, {
1339
+ ...collection,
1340
+ updatedAt: new Date(),
1341
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1342
+ } as unknown as Record<string, any>);
1343
+
1344
+ return link;
1345
+ },
1346
+
1347
+ async removeProduct(ctx) {
1348
+ const { data } = ctx.context;
1349
+ const { id: collectionId, productId } = ctx.params as {
1350
+ id: string;
1351
+ productId: string;
1352
+ };
1353
+
1354
+ const links = (await data.findMany("collectionProduct", {
1355
+ where: { collectionId, productId },
1356
+ })) as CollectionProduct[];
1357
+
1358
+ for (const link of links) {
1359
+ await data.delete("collectionProduct", link.id);
1360
+ }
1361
+
1362
+ // Update collection timestamp
1363
+ const collection = (await data.get(
1364
+ "collection",
1365
+ collectionId,
1366
+ )) as Collection | null;
1367
+ if (collection) {
1368
+ await data.upsert("collection", collectionId, {
1369
+ ...collection,
1370
+ updatedAt: new Date(),
1371
+ // biome-ignore lint/suspicious/noExplicitAny: data service requires Record<string, any>
1372
+ } as unknown as Record<string, any>);
1373
+ }
1374
+
1375
+ return { success: true };
1376
+ },
1377
+
1378
+ async listProducts(ctx) {
1379
+ const { data } = ctx.context;
1380
+ const { id: collectionId } = ctx.params as { id: string };
1381
+
1382
+ const collection = (await data.get(
1383
+ "collection",
1384
+ collectionId,
1385
+ )) as Collection | null;
1386
+ if (!collection) return { products: [] };
1387
+
1388
+ const links = (await data.findMany("collectionProduct", {
1389
+ where: { collectionId },
1390
+ })) as CollectionProduct[];
1391
+
1392
+ links.sort((a, b) => a.position - b.position);
1393
+
1394
+ const products: Product[] = [];
1395
+ for (const link of links) {
1396
+ const product = (await data.get(
1397
+ "product",
1398
+ link.productId,
1399
+ )) as Product | null;
1400
+ if (product) {
1401
+ products.push(product);
1402
+ }
1403
+ }
1404
+
1405
+ return { products };
1406
+ },
1407
+ },
1408
+ };
1409
+
1410
+ export type ProductsControllers = typeof controllers;