@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.
- package/AGENTS.md +65 -0
- package/COMPONENTS.md +231 -0
- package/README.md +201 -0
- package/package.json +46 -0
- package/src/__tests__/controllers.test.ts +2227 -0
- package/src/__tests__/state.test.ts +138 -0
- package/src/admin/components/categories-admin.mdx +3 -0
- package/src/admin/components/categories-admin.tsx +449 -0
- package/src/admin/components/category-form.mdx +9 -0
- package/src/admin/components/category-form.tsx +490 -0
- package/src/admin/components/category-list.mdx +75 -0
- package/src/admin/components/category-list.tsx +168 -0
- package/src/admin/components/collections-admin.mdx +3 -0
- package/src/admin/components/collections-admin.tsx +771 -0
- package/src/admin/components/index.tsx +8 -0
- package/src/admin/components/product-detail.mdx +12 -0
- package/src/admin/components/product-detail.tsx +790 -0
- package/src/admin/components/product-edit.tsx +60 -0
- package/src/admin/components/product-form.tsx +793 -0
- package/src/admin/components/product-list.mdx +3 -0
- package/src/admin/components/product-list.tsx +1125 -0
- package/src/admin/components/product-new.tsx +38 -0
- package/src/admin/endpoints/add-collection-product.ts +17 -0
- package/src/admin/endpoints/bulk-action.ts +43 -0
- package/src/admin/endpoints/create-category.ts +52 -0
- package/src/admin/endpoints/create-collection.ts +35 -0
- package/src/admin/endpoints/create-product.ts +50 -0
- package/src/admin/endpoints/create-variant.ts +45 -0
- package/src/admin/endpoints/delete-category.ts +27 -0
- package/src/admin/endpoints/delete-collection.ts +12 -0
- package/src/admin/endpoints/delete-product.ts +27 -0
- package/src/admin/endpoints/delete-variant.ts +27 -0
- package/src/admin/endpoints/get-product.ts +23 -0
- package/src/admin/endpoints/import-products.ts +47 -0
- package/src/admin/endpoints/index.ts +43 -0
- package/src/admin/endpoints/list-categories.ts +21 -0
- package/src/admin/endpoints/list-collections.ts +20 -0
- package/src/admin/endpoints/list-products.ts +25 -0
- package/src/admin/endpoints/remove-collection-product.ts +15 -0
- package/src/admin/endpoints/update-category.ts +82 -0
- package/src/admin/endpoints/update-collection.ts +22 -0
- package/src/admin/endpoints/update-product.ts +67 -0
- package/src/admin/endpoints/update-variant.ts +41 -0
- package/src/controllers.ts +1410 -0
- package/src/index.ts +120 -0
- package/src/markdown.ts +150 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +352 -0
- package/src/state.ts +84 -0
- package/src/store/components/_hooks.ts +78 -0
- package/src/store/components/_types.ts +73 -0
- package/src/store/components/_utils.ts +14 -0
- package/src/store/components/back-in-stock-notify.tsx +97 -0
- package/src/store/components/collection-card.mdx +42 -0
- package/src/store/components/collection-card.tsx +12 -0
- package/src/store/components/collection-detail.mdx +12 -0
- package/src/store/components/collection-detail.tsx +149 -0
- package/src/store/components/collection-grid.mdx +9 -0
- package/src/store/components/collection-grid.tsx +80 -0
- package/src/store/components/featured-products.mdx +9 -0
- package/src/store/components/featured-products.tsx +75 -0
- package/src/store/components/filter-chip.mdx +25 -0
- package/src/store/components/filter-chip.tsx +12 -0
- package/src/store/components/index.tsx +39 -0
- package/src/store/components/product-card.mdx +69 -0
- package/src/store/components/product-card.tsx +71 -0
- package/src/store/components/product-detail.mdx +30 -0
- package/src/store/components/product-detail.tsx +488 -0
- package/src/store/components/product-listing.mdx +7 -0
- package/src/store/components/product-listing.tsx +423 -0
- package/src/store/components/product-reviews-section.mdx +21 -0
- package/src/store/components/product-reviews-section.tsx +372 -0
- package/src/store/components/recently-viewed.tsx +100 -0
- package/src/store/components/related-products.mdx +6 -0
- package/src/store/components/related-products.tsx +62 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +27 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +21 -0
- package/src/store/components/stock-badge.mdx +12 -0
- package/src/store/components/stock-badge.tsx +19 -0
- package/src/store/endpoints/get-category.ts +61 -0
- package/src/store/endpoints/get-collection.ts +46 -0
- package/src/store/endpoints/get-featured.ts +18 -0
- package/src/store/endpoints/get-product.ts +52 -0
- package/src/store/endpoints/get-related.ts +20 -0
- package/src/store/endpoints/index.ts +23 -0
- package/src/store/endpoints/list-categories.ts +13 -0
- package/src/store/endpoints/list-collections.ts +22 -0
- package/src/store/endpoints/list-products.ts +28 -0
- package/src/store/endpoints/search-products.ts +18 -0
- package/src/store/endpoints/store-search.ts +111 -0
- package/tsconfig.json +9 -0
- 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;
|