@voyantjs/products 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1501 @@
1
+ import { and, asc, desc, eq, ilike, or, sql } from "drizzle-orm";
2
+ import { optionUnits, optionUnitTranslations, productActivationSettings, productCapabilities, productCategories, productCategoryProducts, productDayServices, productDays, productDeliveryFormats, productFaqs, productFeatures, productLocations, productMedia, productNotes, productOptions, productOptionTranslations, products, productTagProducts, productTags, productTicketSettings, productTranslations, productTypes, productVersions, productVisibilitySettings, } from "./schema.js";
3
+ async function recalculateProductCost(db, productId) {
4
+ const [result] = await db
5
+ .select({
6
+ totalCost: sql `coalesce(sum(${productDayServices.costAmountCents} * ${productDayServices.quantity}), 0)::int`,
7
+ })
8
+ .from(productDayServices)
9
+ .innerJoin(productDays, eq(productDayServices.dayId, productDays.id))
10
+ .where(eq(productDays.productId, productId));
11
+ const costAmountCents = result?.totalCost ?? 0;
12
+ const [product] = await db
13
+ .select({ sellAmountCents: products.sellAmountCents })
14
+ .from(products)
15
+ .where(eq(products.id, productId))
16
+ .limit(1);
17
+ const sellAmountCents = product?.sellAmountCents ?? 0;
18
+ const marginPercent = sellAmountCents > 0
19
+ ? Math.round(((sellAmountCents - costAmountCents) / sellAmountCents) * 100)
20
+ : 0;
21
+ await db
22
+ .update(products)
23
+ .set({ costAmountCents, marginPercent, updatedAt: new Date() })
24
+ .where(eq(products.id, productId));
25
+ return { costAmountCents, marginPercent };
26
+ }
27
+ async function ensureProductExists(db, productId) {
28
+ const [product] = await db
29
+ .select({ id: products.id })
30
+ .from(products)
31
+ .where(eq(products.id, productId))
32
+ .limit(1);
33
+ return product ?? null;
34
+ }
35
+ export const productsService = {
36
+ async listProducts(db, query) {
37
+ const conditions = [];
38
+ if (query.status) {
39
+ conditions.push(eq(products.status, query.status));
40
+ }
41
+ if (query.bookingMode) {
42
+ conditions.push(eq(products.bookingMode, query.bookingMode));
43
+ }
44
+ if (query.visibility) {
45
+ conditions.push(eq(products.visibility, query.visibility));
46
+ }
47
+ if (query.activated !== undefined) {
48
+ conditions.push(eq(products.activated, query.activated));
49
+ }
50
+ if (query.facilityId) {
51
+ conditions.push(eq(products.facilityId, query.facilityId));
52
+ }
53
+ if (query.search) {
54
+ const term = `%${query.search}%`;
55
+ conditions.push(or(ilike(products.name, term), ilike(products.description, term)));
56
+ }
57
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
58
+ const [rows, countResult] = await Promise.all([
59
+ db
60
+ .select()
61
+ .from(products)
62
+ .where(where)
63
+ .limit(query.limit)
64
+ .offset(query.offset)
65
+ .orderBy(products.createdAt),
66
+ db.select({ count: sql `count(*)::int` }).from(products).where(where),
67
+ ]);
68
+ return {
69
+ data: rows,
70
+ total: countResult[0]?.count ?? 0,
71
+ limit: query.limit,
72
+ offset: query.offset,
73
+ };
74
+ },
75
+ async getProductById(db, id) {
76
+ const [row] = await db.select().from(products).where(eq(products.id, id)).limit(1);
77
+ return row ?? null;
78
+ },
79
+ async createProduct(db, data) {
80
+ const [row] = await db.insert(products).values(data).returning();
81
+ return row;
82
+ },
83
+ async updateProduct(db, id, data) {
84
+ const [row] = await db
85
+ .update(products)
86
+ .set({ ...data, updatedAt: new Date() })
87
+ .where(eq(products.id, id))
88
+ .returning();
89
+ return row ?? null;
90
+ },
91
+ async deleteProduct(db, id) {
92
+ const [row] = await db
93
+ .delete(products)
94
+ .where(eq(products.id, id))
95
+ .returning({ id: products.id });
96
+ return row ?? null;
97
+ },
98
+ async listActivationSettings(db, query) {
99
+ const conditions = [];
100
+ if (query.productId) {
101
+ conditions.push(eq(productActivationSettings.productId, query.productId));
102
+ }
103
+ if (query.activationMode) {
104
+ conditions.push(eq(productActivationSettings.activationMode, query.activationMode));
105
+ }
106
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
107
+ const [rows, countResult] = await Promise.all([
108
+ db
109
+ .select()
110
+ .from(productActivationSettings)
111
+ .where(where)
112
+ .limit(query.limit)
113
+ .offset(query.offset)
114
+ .orderBy(asc(productActivationSettings.createdAt)),
115
+ db.select({ count: sql `count(*)::int` }).from(productActivationSettings).where(where),
116
+ ]);
117
+ return {
118
+ data: rows,
119
+ total: countResult[0]?.count ?? 0,
120
+ limit: query.limit,
121
+ offset: query.offset,
122
+ };
123
+ },
124
+ async getActivationSettingById(db, id) {
125
+ const [row] = await db
126
+ .select()
127
+ .from(productActivationSettings)
128
+ .where(eq(productActivationSettings.id, id))
129
+ .limit(1);
130
+ return row ?? null;
131
+ },
132
+ async upsertActivationSetting(db, productId, data) {
133
+ const product = await ensureProductExists(db, productId);
134
+ if (!product) {
135
+ return null;
136
+ }
137
+ const [row] = await db
138
+ .insert(productActivationSettings)
139
+ .values({
140
+ productId,
141
+ ...data,
142
+ activateAt: data.activateAt ? new Date(data.activateAt) : null,
143
+ deactivateAt: data.deactivateAt ? new Date(data.deactivateAt) : null,
144
+ sellAt: data.sellAt ? new Date(data.sellAt) : null,
145
+ stopSellAt: data.stopSellAt ? new Date(data.stopSellAt) : null,
146
+ })
147
+ .onConflictDoUpdate({
148
+ target: productActivationSettings.productId,
149
+ set: {
150
+ ...data,
151
+ activateAt: data.activateAt ? new Date(data.activateAt) : null,
152
+ deactivateAt: data.deactivateAt ? new Date(data.deactivateAt) : null,
153
+ sellAt: data.sellAt ? new Date(data.sellAt) : null,
154
+ stopSellAt: data.stopSellAt ? new Date(data.stopSellAt) : null,
155
+ updatedAt: new Date(),
156
+ },
157
+ })
158
+ .returning();
159
+ return row ?? null;
160
+ },
161
+ async updateActivationSetting(db, id, data) {
162
+ const [row] = await db
163
+ .update(productActivationSettings)
164
+ .set({
165
+ ...data,
166
+ activateAt: data.activateAt === undefined
167
+ ? undefined
168
+ : data.activateAt
169
+ ? new Date(data.activateAt)
170
+ : null,
171
+ deactivateAt: data.deactivateAt === undefined
172
+ ? undefined
173
+ : data.deactivateAt
174
+ ? new Date(data.deactivateAt)
175
+ : null,
176
+ sellAt: data.sellAt === undefined ? undefined : data.sellAt ? new Date(data.sellAt) : null,
177
+ stopSellAt: data.stopSellAt === undefined
178
+ ? undefined
179
+ : data.stopSellAt
180
+ ? new Date(data.stopSellAt)
181
+ : null,
182
+ updatedAt: new Date(),
183
+ })
184
+ .where(eq(productActivationSettings.id, id))
185
+ .returning();
186
+ return row ?? null;
187
+ },
188
+ async deleteActivationSetting(db, id) {
189
+ const [row] = await db
190
+ .delete(productActivationSettings)
191
+ .where(eq(productActivationSettings.id, id))
192
+ .returning({ id: productActivationSettings.id });
193
+ return row ?? null;
194
+ },
195
+ async listTicketSettings(db, query) {
196
+ const conditions = [];
197
+ if (query.productId) {
198
+ conditions.push(eq(productTicketSettings.productId, query.productId));
199
+ }
200
+ if (query.fulfillmentMode) {
201
+ conditions.push(eq(productTicketSettings.fulfillmentMode, query.fulfillmentMode));
202
+ }
203
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
204
+ const [rows, countResult] = await Promise.all([
205
+ db
206
+ .select()
207
+ .from(productTicketSettings)
208
+ .where(where)
209
+ .limit(query.limit)
210
+ .offset(query.offset)
211
+ .orderBy(asc(productTicketSettings.createdAt)),
212
+ db.select({ count: sql `count(*)::int` }).from(productTicketSettings).where(where),
213
+ ]);
214
+ return {
215
+ data: rows,
216
+ total: countResult[0]?.count ?? 0,
217
+ limit: query.limit,
218
+ offset: query.offset,
219
+ };
220
+ },
221
+ async getTicketSettingById(db, id) {
222
+ const [row] = await db
223
+ .select()
224
+ .from(productTicketSettings)
225
+ .where(eq(productTicketSettings.id, id))
226
+ .limit(1);
227
+ return row ?? null;
228
+ },
229
+ async upsertTicketSetting(db, productId, data) {
230
+ const product = await ensureProductExists(db, productId);
231
+ if (!product) {
232
+ return null;
233
+ }
234
+ const [row] = await db
235
+ .insert(productTicketSettings)
236
+ .values({ productId, ...data })
237
+ .onConflictDoUpdate({
238
+ target: productTicketSettings.productId,
239
+ set: { ...data, updatedAt: new Date() },
240
+ })
241
+ .returning();
242
+ return row ?? null;
243
+ },
244
+ async updateTicketSetting(db, id, data) {
245
+ const [row] = await db
246
+ .update(productTicketSettings)
247
+ .set({ ...data, updatedAt: new Date() })
248
+ .where(eq(productTicketSettings.id, id))
249
+ .returning();
250
+ return row ?? null;
251
+ },
252
+ async deleteTicketSetting(db, id) {
253
+ const [row] = await db
254
+ .delete(productTicketSettings)
255
+ .where(eq(productTicketSettings.id, id))
256
+ .returning({ id: productTicketSettings.id });
257
+ return row ?? null;
258
+ },
259
+ async listVisibilitySettings(db, query) {
260
+ const conditions = [];
261
+ if (query.productId) {
262
+ conditions.push(eq(productVisibilitySettings.productId, query.productId));
263
+ }
264
+ if (query.isSearchable !== undefined) {
265
+ conditions.push(eq(productVisibilitySettings.isSearchable, query.isSearchable));
266
+ }
267
+ if (query.isBookable !== undefined) {
268
+ conditions.push(eq(productVisibilitySettings.isBookable, query.isBookable));
269
+ }
270
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
271
+ const [rows, countResult] = await Promise.all([
272
+ db
273
+ .select()
274
+ .from(productVisibilitySettings)
275
+ .where(where)
276
+ .limit(query.limit)
277
+ .offset(query.offset)
278
+ .orderBy(asc(productVisibilitySettings.createdAt)),
279
+ db.select({ count: sql `count(*)::int` }).from(productVisibilitySettings).where(where),
280
+ ]);
281
+ return {
282
+ data: rows,
283
+ total: countResult[0]?.count ?? 0,
284
+ limit: query.limit,
285
+ offset: query.offset,
286
+ };
287
+ },
288
+ async getVisibilitySettingById(db, id) {
289
+ const [row] = await db
290
+ .select()
291
+ .from(productVisibilitySettings)
292
+ .where(eq(productVisibilitySettings.id, id))
293
+ .limit(1);
294
+ return row ?? null;
295
+ },
296
+ async upsertVisibilitySetting(db, productId, data) {
297
+ const product = await ensureProductExists(db, productId);
298
+ if (!product) {
299
+ return null;
300
+ }
301
+ const [row] = await db
302
+ .insert(productVisibilitySettings)
303
+ .values({ productId, ...data })
304
+ .onConflictDoUpdate({
305
+ target: productVisibilitySettings.productId,
306
+ set: { ...data, updatedAt: new Date() },
307
+ })
308
+ .returning();
309
+ return row ?? null;
310
+ },
311
+ async updateVisibilitySetting(db, id, data) {
312
+ const [row] = await db
313
+ .update(productVisibilitySettings)
314
+ .set({ ...data, updatedAt: new Date() })
315
+ .where(eq(productVisibilitySettings.id, id))
316
+ .returning();
317
+ return row ?? null;
318
+ },
319
+ async deleteVisibilitySetting(db, id) {
320
+ const [row] = await db
321
+ .delete(productVisibilitySettings)
322
+ .where(eq(productVisibilitySettings.id, id))
323
+ .returning({ id: productVisibilitySettings.id });
324
+ return row ?? null;
325
+ },
326
+ async listCapabilities(db, query) {
327
+ const conditions = [];
328
+ if (query.productId) {
329
+ conditions.push(eq(productCapabilities.productId, query.productId));
330
+ }
331
+ if (query.capability) {
332
+ conditions.push(eq(productCapabilities.capability, query.capability));
333
+ }
334
+ if (query.enabled !== undefined) {
335
+ conditions.push(eq(productCapabilities.enabled, query.enabled));
336
+ }
337
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
338
+ const [rows, countResult] = await Promise.all([
339
+ db
340
+ .select()
341
+ .from(productCapabilities)
342
+ .where(where)
343
+ .limit(query.limit)
344
+ .offset(query.offset)
345
+ .orderBy(asc(productCapabilities.capability), asc(productCapabilities.createdAt)),
346
+ db.select({ count: sql `count(*)::int` }).from(productCapabilities).where(where),
347
+ ]);
348
+ return {
349
+ data: rows,
350
+ total: countResult[0]?.count ?? 0,
351
+ limit: query.limit,
352
+ offset: query.offset,
353
+ };
354
+ },
355
+ async getCapabilityById(db, id) {
356
+ const [row] = await db
357
+ .select()
358
+ .from(productCapabilities)
359
+ .where(eq(productCapabilities.id, id))
360
+ .limit(1);
361
+ return row ?? null;
362
+ },
363
+ async createCapability(db, productId, data) {
364
+ const product = await ensureProductExists(db, productId);
365
+ if (!product) {
366
+ return null;
367
+ }
368
+ const [row] = await db
369
+ .insert(productCapabilities)
370
+ .values({ productId, ...data })
371
+ .onConflictDoUpdate({
372
+ target: [productCapabilities.productId, productCapabilities.capability],
373
+ set: {
374
+ enabled: data.enabled,
375
+ notes: data.notes ?? null,
376
+ updatedAt: new Date(),
377
+ },
378
+ })
379
+ .returning();
380
+ return row ?? null;
381
+ },
382
+ async updateCapability(db, id, data) {
383
+ const [row] = await db
384
+ .update(productCapabilities)
385
+ .set({ ...data, updatedAt: new Date() })
386
+ .where(eq(productCapabilities.id, id))
387
+ .returning();
388
+ return row ?? null;
389
+ },
390
+ async deleteCapability(db, id) {
391
+ const [row] = await db
392
+ .delete(productCapabilities)
393
+ .where(eq(productCapabilities.id, id))
394
+ .returning({ id: productCapabilities.id });
395
+ return row ?? null;
396
+ },
397
+ async listDeliveryFormats(db, query) {
398
+ const conditions = [];
399
+ if (query.productId) {
400
+ conditions.push(eq(productDeliveryFormats.productId, query.productId));
401
+ }
402
+ if (query.format) {
403
+ conditions.push(eq(productDeliveryFormats.format, query.format));
404
+ }
405
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
406
+ const [rows, countResult] = await Promise.all([
407
+ db
408
+ .select()
409
+ .from(productDeliveryFormats)
410
+ .where(where)
411
+ .limit(query.limit)
412
+ .offset(query.offset)
413
+ .orderBy(desc(productDeliveryFormats.isDefault), asc(productDeliveryFormats.createdAt)),
414
+ db.select({ count: sql `count(*)::int` }).from(productDeliveryFormats).where(where),
415
+ ]);
416
+ return {
417
+ data: rows,
418
+ total: countResult[0]?.count ?? 0,
419
+ limit: query.limit,
420
+ offset: query.offset,
421
+ };
422
+ },
423
+ async getDeliveryFormatById(db, id) {
424
+ const [row] = await db
425
+ .select()
426
+ .from(productDeliveryFormats)
427
+ .where(eq(productDeliveryFormats.id, id))
428
+ .limit(1);
429
+ return row ?? null;
430
+ },
431
+ async createDeliveryFormat(db, productId, data) {
432
+ const product = await ensureProductExists(db, productId);
433
+ if (!product) {
434
+ return null;
435
+ }
436
+ if (data.isDefault) {
437
+ await db
438
+ .update(productDeliveryFormats)
439
+ .set({ isDefault: false, updatedAt: new Date() })
440
+ .where(eq(productDeliveryFormats.productId, productId));
441
+ }
442
+ const [row] = await db
443
+ .insert(productDeliveryFormats)
444
+ .values({ productId, ...data })
445
+ .onConflictDoUpdate({
446
+ target: [productDeliveryFormats.productId, productDeliveryFormats.format],
447
+ set: {
448
+ isDefault: data.isDefault ?? false,
449
+ updatedAt: new Date(),
450
+ },
451
+ })
452
+ .returning();
453
+ return row ?? null;
454
+ },
455
+ async updateDeliveryFormat(db, id, data) {
456
+ const [current] = await db
457
+ .select({ id: productDeliveryFormats.id, productId: productDeliveryFormats.productId })
458
+ .from(productDeliveryFormats)
459
+ .where(eq(productDeliveryFormats.id, id))
460
+ .limit(1);
461
+ if (!current) {
462
+ return null;
463
+ }
464
+ if (data.isDefault) {
465
+ await db
466
+ .update(productDeliveryFormats)
467
+ .set({ isDefault: false, updatedAt: new Date() })
468
+ .where(eq(productDeliveryFormats.productId, current.productId));
469
+ }
470
+ const [row] = await db
471
+ .update(productDeliveryFormats)
472
+ .set({ ...data, updatedAt: new Date() })
473
+ .where(eq(productDeliveryFormats.id, id))
474
+ .returning();
475
+ return row ?? null;
476
+ },
477
+ async deleteDeliveryFormat(db, id) {
478
+ const [row] = await db
479
+ .delete(productDeliveryFormats)
480
+ .where(eq(productDeliveryFormats.id, id))
481
+ .returning({ id: productDeliveryFormats.id });
482
+ return row ?? null;
483
+ },
484
+ async listFeatures(db, query) {
485
+ const conditions = [];
486
+ if (query.productId) {
487
+ conditions.push(eq(productFeatures.productId, query.productId));
488
+ }
489
+ if (query.featureType) {
490
+ conditions.push(eq(productFeatures.featureType, query.featureType));
491
+ }
492
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
493
+ const [rows, countResult] = await Promise.all([
494
+ db
495
+ .select()
496
+ .from(productFeatures)
497
+ .where(where)
498
+ .limit(query.limit)
499
+ .offset(query.offset)
500
+ .orderBy(asc(productFeatures.sortOrder), asc(productFeatures.createdAt)),
501
+ db.select({ count: sql `count(*)::int` }).from(productFeatures).where(where),
502
+ ]);
503
+ return {
504
+ data: rows,
505
+ total: countResult[0]?.count ?? 0,
506
+ limit: query.limit,
507
+ offset: query.offset,
508
+ };
509
+ },
510
+ async getFeatureById(db, id) {
511
+ const [row] = await db.select().from(productFeatures).where(eq(productFeatures.id, id)).limit(1);
512
+ return row ?? null;
513
+ },
514
+ async createFeature(db, productId, data) {
515
+ const product = await ensureProductExists(db, productId);
516
+ if (!product) {
517
+ return null;
518
+ }
519
+ const [row] = await db.insert(productFeatures).values({ productId, ...data }).returning();
520
+ return row ?? null;
521
+ },
522
+ async updateFeature(db, id, data) {
523
+ const [row] = await db
524
+ .update(productFeatures)
525
+ .set({ ...data, updatedAt: new Date() })
526
+ .where(eq(productFeatures.id, id))
527
+ .returning();
528
+ return row ?? null;
529
+ },
530
+ async deleteFeature(db, id) {
531
+ const [row] = await db
532
+ .delete(productFeatures)
533
+ .where(eq(productFeatures.id, id))
534
+ .returning({ id: productFeatures.id });
535
+ return row ?? null;
536
+ },
537
+ async listFaqs(db, query) {
538
+ const conditions = [];
539
+ if (query.productId) {
540
+ conditions.push(eq(productFaqs.productId, query.productId));
541
+ }
542
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
543
+ const [rows, countResult] = await Promise.all([
544
+ db
545
+ .select()
546
+ .from(productFaqs)
547
+ .where(where)
548
+ .limit(query.limit)
549
+ .offset(query.offset)
550
+ .orderBy(asc(productFaqs.sortOrder), asc(productFaqs.createdAt)),
551
+ db.select({ count: sql `count(*)::int` }).from(productFaqs).where(where),
552
+ ]);
553
+ return {
554
+ data: rows,
555
+ total: countResult[0]?.count ?? 0,
556
+ limit: query.limit,
557
+ offset: query.offset,
558
+ };
559
+ },
560
+ async getFaqById(db, id) {
561
+ const [row] = await db.select().from(productFaqs).where(eq(productFaqs.id, id)).limit(1);
562
+ return row ?? null;
563
+ },
564
+ async createFaq(db, productId, data) {
565
+ const product = await ensureProductExists(db, productId);
566
+ if (!product) {
567
+ return null;
568
+ }
569
+ const [row] = await db.insert(productFaqs).values({ productId, ...data }).returning();
570
+ return row ?? null;
571
+ },
572
+ async updateFaq(db, id, data) {
573
+ const [row] = await db
574
+ .update(productFaqs)
575
+ .set({ ...data, updatedAt: new Date() })
576
+ .where(eq(productFaqs.id, id))
577
+ .returning();
578
+ return row ?? null;
579
+ },
580
+ async deleteFaq(db, id) {
581
+ const [row] = await db
582
+ .delete(productFaqs)
583
+ .where(eq(productFaqs.id, id))
584
+ .returning({ id: productFaqs.id });
585
+ return row ?? null;
586
+ },
587
+ async listLocations(db, query) {
588
+ const conditions = [];
589
+ if (query.productId) {
590
+ conditions.push(eq(productLocations.productId, query.productId));
591
+ }
592
+ if (query.locationType) {
593
+ conditions.push(eq(productLocations.locationType, query.locationType));
594
+ }
595
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
596
+ const [rows, countResult] = await Promise.all([
597
+ db
598
+ .select()
599
+ .from(productLocations)
600
+ .where(where)
601
+ .limit(query.limit)
602
+ .offset(query.offset)
603
+ .orderBy(asc(productLocations.sortOrder), asc(productLocations.createdAt)),
604
+ db.select({ count: sql `count(*)::int` }).from(productLocations).where(where),
605
+ ]);
606
+ return {
607
+ data: rows,
608
+ total: countResult[0]?.count ?? 0,
609
+ limit: query.limit,
610
+ offset: query.offset,
611
+ };
612
+ },
613
+ async getLocationById(db, id) {
614
+ const [row] = await db.select().from(productLocations).where(eq(productLocations.id, id)).limit(1);
615
+ return row ?? null;
616
+ },
617
+ async createLocation(db, productId, data) {
618
+ const product = await ensureProductExists(db, productId);
619
+ if (!product) {
620
+ return null;
621
+ }
622
+ const [row] = await db.insert(productLocations).values({ productId, ...data }).returning();
623
+ return row ?? null;
624
+ },
625
+ async updateLocation(db, id, data) {
626
+ const [row] = await db
627
+ .update(productLocations)
628
+ .set({ ...data, updatedAt: new Date() })
629
+ .where(eq(productLocations.id, id))
630
+ .returning();
631
+ return row ?? null;
632
+ },
633
+ async deleteLocation(db, id) {
634
+ const [row] = await db
635
+ .delete(productLocations)
636
+ .where(eq(productLocations.id, id))
637
+ .returning({ id: productLocations.id });
638
+ return row ?? null;
639
+ },
640
+ async listOptions(db, query) {
641
+ const conditions = [];
642
+ if (query.productId) {
643
+ conditions.push(eq(productOptions.productId, query.productId));
644
+ }
645
+ if (query.status) {
646
+ conditions.push(eq(productOptions.status, query.status));
647
+ }
648
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
649
+ const [rows, countResult] = await Promise.all([
650
+ db
651
+ .select()
652
+ .from(productOptions)
653
+ .where(where)
654
+ .limit(query.limit)
655
+ .offset(query.offset)
656
+ .orderBy(asc(productOptions.sortOrder), asc(productOptions.createdAt)),
657
+ db.select({ count: sql `count(*)::int` }).from(productOptions).where(where),
658
+ ]);
659
+ return {
660
+ data: rows,
661
+ total: countResult[0]?.count ?? 0,
662
+ limit: query.limit,
663
+ offset: query.offset,
664
+ };
665
+ },
666
+ async getOptionById(db, id) {
667
+ const [row] = await db.select().from(productOptions).where(eq(productOptions.id, id)).limit(1);
668
+ return row ?? null;
669
+ },
670
+ async createOption(db, productId, data) {
671
+ const [product] = await db
672
+ .select({ id: products.id })
673
+ .from(products)
674
+ .where(eq(products.id, productId))
675
+ .limit(1);
676
+ if (!product) {
677
+ return null;
678
+ }
679
+ if (data.isDefault) {
680
+ await db
681
+ .update(productOptions)
682
+ .set({ isDefault: false, updatedAt: new Date() })
683
+ .where(eq(productOptions.productId, productId));
684
+ }
685
+ const [row] = await db
686
+ .insert(productOptions)
687
+ .values({ ...data, productId })
688
+ .returning();
689
+ return row;
690
+ },
691
+ async updateOption(db, id, data) {
692
+ const [current] = await db
693
+ .select({ id: productOptions.id, productId: productOptions.productId })
694
+ .from(productOptions)
695
+ .where(eq(productOptions.id, id))
696
+ .limit(1);
697
+ if (!current) {
698
+ return null;
699
+ }
700
+ if (data.isDefault) {
701
+ await db
702
+ .update(productOptions)
703
+ .set({ isDefault: false, updatedAt: new Date() })
704
+ .where(eq(productOptions.productId, current.productId));
705
+ }
706
+ const [row] = await db
707
+ .update(productOptions)
708
+ .set({ ...data, updatedAt: new Date() })
709
+ .where(eq(productOptions.id, id))
710
+ .returning();
711
+ return row ?? null;
712
+ },
713
+ async deleteOption(db, id) {
714
+ const [row] = await db
715
+ .delete(productOptions)
716
+ .where(eq(productOptions.id, id))
717
+ .returning({ id: productOptions.id });
718
+ return row ?? null;
719
+ },
720
+ async listUnits(db, query) {
721
+ const conditions = [];
722
+ if (query.optionId) {
723
+ conditions.push(eq(optionUnits.optionId, query.optionId));
724
+ }
725
+ if (query.unitType) {
726
+ conditions.push(eq(optionUnits.unitType, query.unitType));
727
+ }
728
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
729
+ const [rows, countResult] = await Promise.all([
730
+ db
731
+ .select()
732
+ .from(optionUnits)
733
+ .where(where)
734
+ .limit(query.limit)
735
+ .offset(query.offset)
736
+ .orderBy(asc(optionUnits.sortOrder), asc(optionUnits.createdAt)),
737
+ db.select({ count: sql `count(*)::int` }).from(optionUnits).where(where),
738
+ ]);
739
+ return {
740
+ data: rows,
741
+ total: countResult[0]?.count ?? 0,
742
+ limit: query.limit,
743
+ offset: query.offset,
744
+ };
745
+ },
746
+ async getUnitById(db, id) {
747
+ const [row] = await db.select().from(optionUnits).where(eq(optionUnits.id, id)).limit(1);
748
+ return row ?? null;
749
+ },
750
+ async createUnit(db, optionId, data) {
751
+ const [option] = await db
752
+ .select({ id: productOptions.id })
753
+ .from(productOptions)
754
+ .where(eq(productOptions.id, optionId))
755
+ .limit(1);
756
+ if (!option) {
757
+ return null;
758
+ }
759
+ const [row] = await db
760
+ .insert(optionUnits)
761
+ .values({ ...data, optionId })
762
+ .returning();
763
+ return row;
764
+ },
765
+ async updateUnit(db, id, data) {
766
+ const [row] = await db
767
+ .update(optionUnits)
768
+ .set({ ...data, updatedAt: new Date() })
769
+ .where(eq(optionUnits.id, id))
770
+ .returning();
771
+ return row ?? null;
772
+ },
773
+ async deleteUnit(db, id) {
774
+ const [row] = await db
775
+ .delete(optionUnits)
776
+ .where(eq(optionUnits.id, id))
777
+ .returning({ id: optionUnits.id });
778
+ return row ?? null;
779
+ },
780
+ async listProductTranslations(db, query) {
781
+ const conditions = [];
782
+ if (query.productId) {
783
+ conditions.push(eq(productTranslations.productId, query.productId));
784
+ }
785
+ if (query.languageTag) {
786
+ conditions.push(eq(productTranslations.languageTag, query.languageTag));
787
+ }
788
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
789
+ const [rows, countResult] = await Promise.all([
790
+ db
791
+ .select()
792
+ .from(productTranslations)
793
+ .where(where)
794
+ .limit(query.limit)
795
+ .offset(query.offset)
796
+ .orderBy(asc(productTranslations.languageTag), asc(productTranslations.createdAt)),
797
+ db.select({ count: sql `count(*)::int` }).from(productTranslations).where(where),
798
+ ]);
799
+ return {
800
+ data: rows,
801
+ total: countResult[0]?.count ?? 0,
802
+ limit: query.limit,
803
+ offset: query.offset,
804
+ };
805
+ },
806
+ async getProductTranslationById(db, id) {
807
+ const [row] = await db
808
+ .select()
809
+ .from(productTranslations)
810
+ .where(eq(productTranslations.id, id))
811
+ .limit(1);
812
+ return row ?? null;
813
+ },
814
+ async createProductTranslation(db, productId, data) {
815
+ const [product] = await db
816
+ .select({ id: products.id })
817
+ .from(products)
818
+ .where(eq(products.id, productId))
819
+ .limit(1);
820
+ if (!product) {
821
+ return null;
822
+ }
823
+ const [row] = await db
824
+ .insert(productTranslations)
825
+ .values({ ...data, productId })
826
+ .returning();
827
+ return row ?? null;
828
+ },
829
+ async updateProductTranslation(db, id, data) {
830
+ const [row] = await db
831
+ .update(productTranslations)
832
+ .set({ ...data, updatedAt: new Date() })
833
+ .where(eq(productTranslations.id, id))
834
+ .returning();
835
+ return row ?? null;
836
+ },
837
+ async deleteProductTranslation(db, id) {
838
+ const [row] = await db
839
+ .delete(productTranslations)
840
+ .where(eq(productTranslations.id, id))
841
+ .returning({ id: productTranslations.id });
842
+ return row ?? null;
843
+ },
844
+ async listOptionTranslations(db, query) {
845
+ const conditions = [];
846
+ if (query.optionId) {
847
+ conditions.push(eq(productOptionTranslations.optionId, query.optionId));
848
+ }
849
+ if (query.languageTag) {
850
+ conditions.push(eq(productOptionTranslations.languageTag, query.languageTag));
851
+ }
852
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
853
+ const [rows, countResult] = await Promise.all([
854
+ db
855
+ .select()
856
+ .from(productOptionTranslations)
857
+ .where(where)
858
+ .limit(query.limit)
859
+ .offset(query.offset)
860
+ .orderBy(asc(productOptionTranslations.languageTag), asc(productOptionTranslations.createdAt)),
861
+ db.select({ count: sql `count(*)::int` }).from(productOptionTranslations).where(where),
862
+ ]);
863
+ return {
864
+ data: rows,
865
+ total: countResult[0]?.count ?? 0,
866
+ limit: query.limit,
867
+ offset: query.offset,
868
+ };
869
+ },
870
+ async getOptionTranslationById(db, id) {
871
+ const [row] = await db
872
+ .select()
873
+ .from(productOptionTranslations)
874
+ .where(eq(productOptionTranslations.id, id))
875
+ .limit(1);
876
+ return row ?? null;
877
+ },
878
+ async createOptionTranslation(db, optionId, data) {
879
+ const [option] = await db
880
+ .select({ id: productOptions.id })
881
+ .from(productOptions)
882
+ .where(eq(productOptions.id, optionId))
883
+ .limit(1);
884
+ if (!option) {
885
+ return null;
886
+ }
887
+ const [row] = await db
888
+ .insert(productOptionTranslations)
889
+ .values({ ...data, optionId })
890
+ .returning();
891
+ return row ?? null;
892
+ },
893
+ async updateOptionTranslation(db, id, data) {
894
+ const [row] = await db
895
+ .update(productOptionTranslations)
896
+ .set({ ...data, updatedAt: new Date() })
897
+ .where(eq(productOptionTranslations.id, id))
898
+ .returning();
899
+ return row ?? null;
900
+ },
901
+ async deleteOptionTranslation(db, id) {
902
+ const [row] = await db
903
+ .delete(productOptionTranslations)
904
+ .where(eq(productOptionTranslations.id, id))
905
+ .returning({ id: productOptionTranslations.id });
906
+ return row ?? null;
907
+ },
908
+ async listUnitTranslations(db, query) {
909
+ const conditions = [];
910
+ if (query.unitId) {
911
+ conditions.push(eq(optionUnitTranslations.unitId, query.unitId));
912
+ }
913
+ if (query.languageTag) {
914
+ conditions.push(eq(optionUnitTranslations.languageTag, query.languageTag));
915
+ }
916
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
917
+ const [rows, countResult] = await Promise.all([
918
+ db
919
+ .select()
920
+ .from(optionUnitTranslations)
921
+ .where(where)
922
+ .limit(query.limit)
923
+ .offset(query.offset)
924
+ .orderBy(asc(optionUnitTranslations.languageTag), asc(optionUnitTranslations.createdAt)),
925
+ db.select({ count: sql `count(*)::int` }).from(optionUnitTranslations).where(where),
926
+ ]);
927
+ return {
928
+ data: rows,
929
+ total: countResult[0]?.count ?? 0,
930
+ limit: query.limit,
931
+ offset: query.offset,
932
+ };
933
+ },
934
+ async getUnitTranslationById(db, id) {
935
+ const [row] = await db
936
+ .select()
937
+ .from(optionUnitTranslations)
938
+ .where(eq(optionUnitTranslations.id, id))
939
+ .limit(1);
940
+ return row ?? null;
941
+ },
942
+ async createUnitTranslation(db, unitId, data) {
943
+ const [unit] = await db
944
+ .select({ id: optionUnits.id })
945
+ .from(optionUnits)
946
+ .where(eq(optionUnits.id, unitId))
947
+ .limit(1);
948
+ if (!unit) {
949
+ return null;
950
+ }
951
+ const [row] = await db
952
+ .insert(optionUnitTranslations)
953
+ .values({ ...data, unitId })
954
+ .returning();
955
+ return row ?? null;
956
+ },
957
+ async updateUnitTranslation(db, id, data) {
958
+ const [row] = await db
959
+ .update(optionUnitTranslations)
960
+ .set({ ...data, updatedAt: new Date() })
961
+ .where(eq(optionUnitTranslations.id, id))
962
+ .returning();
963
+ return row ?? null;
964
+ },
965
+ async deleteUnitTranslation(db, id) {
966
+ const [row] = await db
967
+ .delete(optionUnitTranslations)
968
+ .where(eq(optionUnitTranslations.id, id))
969
+ .returning({ id: optionUnitTranslations.id });
970
+ return row ?? null;
971
+ },
972
+ listDays(db, productId) {
973
+ return db
974
+ .select()
975
+ .from(productDays)
976
+ .where(eq(productDays.productId, productId))
977
+ .orderBy(asc(productDays.dayNumber));
978
+ },
979
+ async createDay(db, productId, data) {
980
+ const [product] = await db
981
+ .select({ id: products.id })
982
+ .from(products)
983
+ .where(eq(products.id, productId))
984
+ .limit(1);
985
+ if (!product) {
986
+ return null;
987
+ }
988
+ const [row] = await db
989
+ .insert(productDays)
990
+ .values({ ...data, productId })
991
+ .returning();
992
+ return row;
993
+ },
994
+ async updateDay(db, dayId, data) {
995
+ const [row] = await db
996
+ .update(productDays)
997
+ .set({ ...data, updatedAt: new Date() })
998
+ .where(eq(productDays.id, dayId))
999
+ .returning();
1000
+ return row ?? null;
1001
+ },
1002
+ async deleteDay(db, dayId) {
1003
+ const [row] = await db
1004
+ .delete(productDays)
1005
+ .where(eq(productDays.id, dayId))
1006
+ .returning({ id: productDays.id });
1007
+ return row ?? null;
1008
+ },
1009
+ listDayServices(db, dayId) {
1010
+ return db
1011
+ .select()
1012
+ .from(productDayServices)
1013
+ .where(eq(productDayServices.dayId, dayId))
1014
+ .orderBy(asc(productDayServices.sortOrder));
1015
+ },
1016
+ async createDayService(db, productId, dayId, data) {
1017
+ const [day] = await db
1018
+ .select({ id: productDays.id })
1019
+ .from(productDays)
1020
+ .where(eq(productDays.id, dayId))
1021
+ .limit(1);
1022
+ if (!day) {
1023
+ return null;
1024
+ }
1025
+ const [row] = await db
1026
+ .insert(productDayServices)
1027
+ .values({ ...data, dayId })
1028
+ .returning();
1029
+ await recalculateProductCost(db, productId);
1030
+ return row;
1031
+ },
1032
+ async updateDayService(db, productId, serviceId, data) {
1033
+ const [row] = await db
1034
+ .update(productDayServices)
1035
+ .set(data)
1036
+ .where(eq(productDayServices.id, serviceId))
1037
+ .returning();
1038
+ if (!row) {
1039
+ return null;
1040
+ }
1041
+ await recalculateProductCost(db, productId);
1042
+ return row;
1043
+ },
1044
+ async deleteDayService(db, productId, serviceId) {
1045
+ const [row] = await db
1046
+ .delete(productDayServices)
1047
+ .where(eq(productDayServices.id, serviceId))
1048
+ .returning({ id: productDayServices.id });
1049
+ if (!row) {
1050
+ return null;
1051
+ }
1052
+ await recalculateProductCost(db, productId);
1053
+ return row;
1054
+ },
1055
+ listVersions(db, productId) {
1056
+ return db
1057
+ .select()
1058
+ .from(productVersions)
1059
+ .where(eq(productVersions.productId, productId))
1060
+ .orderBy(desc(productVersions.versionNumber));
1061
+ },
1062
+ async createVersion(db, productId, userId, data) {
1063
+ const [product] = await db.select().from(products).where(eq(products.id, productId)).limit(1);
1064
+ if (!product) {
1065
+ return null;
1066
+ }
1067
+ const days = await db
1068
+ .select()
1069
+ .from(productDays)
1070
+ .where(eq(productDays.productId, productId))
1071
+ .orderBy(asc(productDays.dayNumber));
1072
+ const options = await db
1073
+ .select()
1074
+ .from(productOptions)
1075
+ .where(eq(productOptions.productId, productId))
1076
+ .orderBy(asc(productOptions.sortOrder), asc(productOptions.createdAt));
1077
+ const optionsWithUnits = await Promise.all(options.map(async (option) => {
1078
+ const units = await db
1079
+ .select()
1080
+ .from(optionUnits)
1081
+ .where(eq(optionUnits.optionId, option.id))
1082
+ .orderBy(asc(optionUnits.sortOrder), asc(optionUnits.createdAt));
1083
+ return { ...option, units };
1084
+ }));
1085
+ const daysWithServices = await Promise.all(days.map(async (day) => {
1086
+ const services = await db
1087
+ .select()
1088
+ .from(productDayServices)
1089
+ .where(eq(productDayServices.dayId, day.id))
1090
+ .orderBy(asc(productDayServices.sortOrder));
1091
+ return { ...day, services };
1092
+ }));
1093
+ const [maxVersion] = await db
1094
+ .select({ max: sql `coalesce(max(${productVersions.versionNumber}), 0)` })
1095
+ .from(productVersions)
1096
+ .where(eq(productVersions.productId, productId));
1097
+ const [row] = await db
1098
+ .insert(productVersions)
1099
+ .values({
1100
+ productId,
1101
+ versionNumber: (maxVersion?.max ?? 0) + 1,
1102
+ snapshot: { ...product, options: optionsWithUnits, days: daysWithServices },
1103
+ authorId: userId,
1104
+ notes: data.notes,
1105
+ })
1106
+ .returning();
1107
+ return row;
1108
+ },
1109
+ listNotes(db, productId) {
1110
+ return db
1111
+ .select()
1112
+ .from(productNotes)
1113
+ .where(eq(productNotes.productId, productId))
1114
+ .orderBy(productNotes.createdAt);
1115
+ },
1116
+ async createNote(db, productId, userId, data) {
1117
+ const [product] = await db
1118
+ .select({ id: products.id })
1119
+ .from(products)
1120
+ .where(eq(products.id, productId))
1121
+ .limit(1);
1122
+ if (!product) {
1123
+ return null;
1124
+ }
1125
+ const [row] = await db
1126
+ .insert(productNotes)
1127
+ .values({
1128
+ productId,
1129
+ authorId: userId,
1130
+ content: data.content,
1131
+ })
1132
+ .returning();
1133
+ return row;
1134
+ },
1135
+ async recalculate(db, productId) {
1136
+ const [product] = await db
1137
+ .select({ id: products.id })
1138
+ .from(products)
1139
+ .where(eq(products.id, productId))
1140
+ .limit(1);
1141
+ if (!product) {
1142
+ return null;
1143
+ }
1144
+ return recalculateProductCost(db, productId);
1145
+ },
1146
+ // ==========================================================================
1147
+ // Product Types
1148
+ // ==========================================================================
1149
+ async listProductTypes(db, query) {
1150
+ const conditions = [];
1151
+ if (query.active !== undefined) {
1152
+ conditions.push(eq(productTypes.active, query.active));
1153
+ }
1154
+ if (query.search) {
1155
+ const term = `%${query.search}%`;
1156
+ conditions.push(or(ilike(productTypes.name, term), ilike(productTypes.code, term)));
1157
+ }
1158
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
1159
+ const [rows, countResult] = await Promise.all([
1160
+ db
1161
+ .select()
1162
+ .from(productTypes)
1163
+ .where(where)
1164
+ .limit(query.limit)
1165
+ .offset(query.offset)
1166
+ .orderBy(asc(productTypes.sortOrder), asc(productTypes.name)),
1167
+ db.select({ count: sql `count(*)::int` }).from(productTypes).where(where),
1168
+ ]);
1169
+ return {
1170
+ data: rows,
1171
+ total: countResult[0]?.count ?? 0,
1172
+ limit: query.limit,
1173
+ offset: query.offset,
1174
+ };
1175
+ },
1176
+ async getProductTypeById(db, id) {
1177
+ const [row] = await db.select().from(productTypes).where(eq(productTypes.id, id)).limit(1);
1178
+ return row ?? null;
1179
+ },
1180
+ async createProductType(db, data) {
1181
+ const [row] = await db.insert(productTypes).values(data).returning();
1182
+ return row;
1183
+ },
1184
+ async updateProductType(db, id, data) {
1185
+ const [row] = await db
1186
+ .update(productTypes)
1187
+ .set({ ...data, updatedAt: new Date() })
1188
+ .where(eq(productTypes.id, id))
1189
+ .returning();
1190
+ return row ?? null;
1191
+ },
1192
+ async deleteProductType(db, id) {
1193
+ const [row] = await db
1194
+ .delete(productTypes)
1195
+ .where(eq(productTypes.id, id))
1196
+ .returning({ id: productTypes.id });
1197
+ return row ?? null;
1198
+ },
1199
+ // ==========================================================================
1200
+ // Product Categories
1201
+ // ==========================================================================
1202
+ async listProductCategories(db, query) {
1203
+ const conditions = [];
1204
+ if (query.parentId) {
1205
+ conditions.push(eq(productCategories.parentId, query.parentId));
1206
+ }
1207
+ if (query.active !== undefined) {
1208
+ conditions.push(eq(productCategories.active, query.active));
1209
+ }
1210
+ if (query.search) {
1211
+ const term = `%${query.search}%`;
1212
+ conditions.push(or(ilike(productCategories.name, term), ilike(productCategories.slug, term)));
1213
+ }
1214
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
1215
+ const [rows, countResult] = await Promise.all([
1216
+ db
1217
+ .select()
1218
+ .from(productCategories)
1219
+ .where(where)
1220
+ .limit(query.limit)
1221
+ .offset(query.offset)
1222
+ .orderBy(asc(productCategories.sortOrder), asc(productCategories.name)),
1223
+ db.select({ count: sql `count(*)::int` }).from(productCategories).where(where),
1224
+ ]);
1225
+ return {
1226
+ data: rows,
1227
+ total: countResult[0]?.count ?? 0,
1228
+ limit: query.limit,
1229
+ offset: query.offset,
1230
+ };
1231
+ },
1232
+ async getProductCategoryById(db, id) {
1233
+ const [row] = await db
1234
+ .select()
1235
+ .from(productCategories)
1236
+ .where(eq(productCategories.id, id))
1237
+ .limit(1);
1238
+ return row ?? null;
1239
+ },
1240
+ async createProductCategory(db, data) {
1241
+ const [row] = await db.insert(productCategories).values(data).returning();
1242
+ return row;
1243
+ },
1244
+ async updateProductCategory(db, id, data) {
1245
+ const [row] = await db
1246
+ .update(productCategories)
1247
+ .set({ ...data, updatedAt: new Date() })
1248
+ .where(eq(productCategories.id, id))
1249
+ .returning();
1250
+ return row ?? null;
1251
+ },
1252
+ async deleteProductCategory(db, id) {
1253
+ const [row] = await db
1254
+ .delete(productCategories)
1255
+ .where(eq(productCategories.id, id))
1256
+ .returning({ id: productCategories.id });
1257
+ return row ?? null;
1258
+ },
1259
+ // ==========================================================================
1260
+ // Product Tags
1261
+ // ==========================================================================
1262
+ async listProductTags(db, query) {
1263
+ const conditions = [];
1264
+ if (query.search) {
1265
+ const term = `%${query.search}%`;
1266
+ conditions.push(ilike(productTags.name, term));
1267
+ }
1268
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
1269
+ const [rows, countResult] = await Promise.all([
1270
+ db
1271
+ .select()
1272
+ .from(productTags)
1273
+ .where(where)
1274
+ .limit(query.limit)
1275
+ .offset(query.offset)
1276
+ .orderBy(asc(productTags.name)),
1277
+ db.select({ count: sql `count(*)::int` }).from(productTags).where(where),
1278
+ ]);
1279
+ return {
1280
+ data: rows,
1281
+ total: countResult[0]?.count ?? 0,
1282
+ limit: query.limit,
1283
+ offset: query.offset,
1284
+ };
1285
+ },
1286
+ async getProductTagById(db, id) {
1287
+ const [row] = await db.select().from(productTags).where(eq(productTags.id, id)).limit(1);
1288
+ return row ?? null;
1289
+ },
1290
+ async createProductTag(db, data) {
1291
+ const [row] = await db.insert(productTags).values(data).returning();
1292
+ return row;
1293
+ },
1294
+ async updateProductTag(db, id, data) {
1295
+ const [row] = await db
1296
+ .update(productTags)
1297
+ .set({ ...data, updatedAt: new Date() })
1298
+ .where(eq(productTags.id, id))
1299
+ .returning();
1300
+ return row ?? null;
1301
+ },
1302
+ async deleteProductTag(db, id) {
1303
+ const [row] = await db
1304
+ .delete(productTags)
1305
+ .where(eq(productTags.id, id))
1306
+ .returning({ id: productTags.id });
1307
+ return row ?? null;
1308
+ },
1309
+ // ==========================================================================
1310
+ // Product <-> Category associations
1311
+ // ==========================================================================
1312
+ async addProductToCategory(db, productId, categoryId, sortOrder = 0) {
1313
+ const [row] = await db
1314
+ .insert(productCategoryProducts)
1315
+ .values({ productId, categoryId, sortOrder })
1316
+ .onConflictDoNothing()
1317
+ .returning();
1318
+ return row ?? null;
1319
+ },
1320
+ async removeProductFromCategory(db, productId, categoryId) {
1321
+ const [row] = await db
1322
+ .delete(productCategoryProducts)
1323
+ .where(and(eq(productCategoryProducts.productId, productId), eq(productCategoryProducts.categoryId, categoryId)))
1324
+ .returning({ productId: productCategoryProducts.productId });
1325
+ return row ?? null;
1326
+ },
1327
+ async listProductCategories_(db, productId) {
1328
+ const rows = await db
1329
+ .select({ category: productCategories })
1330
+ .from(productCategoryProducts)
1331
+ .innerJoin(productCategories, eq(productCategoryProducts.categoryId, productCategories.id))
1332
+ .where(eq(productCategoryProducts.productId, productId))
1333
+ .orderBy(asc(productCategoryProducts.sortOrder));
1334
+ return rows.map((r) => r.category);
1335
+ },
1336
+ // ==========================================================================
1337
+ // Product <-> Tag associations
1338
+ // ==========================================================================
1339
+ async addProductTag(db, productId, tagId) {
1340
+ const [row] = await db
1341
+ .insert(productTagProducts)
1342
+ .values({ productId, tagId })
1343
+ .onConflictDoNothing()
1344
+ .returning();
1345
+ return row ?? null;
1346
+ },
1347
+ async removeProductTag(db, productId, tagId) {
1348
+ const [row] = await db
1349
+ .delete(productTagProducts)
1350
+ .where(and(eq(productTagProducts.productId, productId), eq(productTagProducts.tagId, tagId)))
1351
+ .returning({ productId: productTagProducts.productId });
1352
+ return row ?? null;
1353
+ },
1354
+ async listProductTags_(db, productId) {
1355
+ const rows = await db
1356
+ .select({ tag: productTags })
1357
+ .from(productTagProducts)
1358
+ .innerJoin(productTags, eq(productTagProducts.tagId, productTags.id))
1359
+ .where(eq(productTagProducts.productId, productId))
1360
+ .orderBy(asc(productTags.name));
1361
+ return rows.map((r) => r.tag);
1362
+ },
1363
+ // ==========================================================================
1364
+ // Product Media
1365
+ // ==========================================================================
1366
+ async listMedia(db, productId, query) {
1367
+ const conditions = [eq(productMedia.productId, productId)];
1368
+ if (query.dayId !== undefined) {
1369
+ conditions.push(eq(productMedia.dayId, query.dayId));
1370
+ }
1371
+ if (query.mediaType) {
1372
+ conditions.push(eq(productMedia.mediaType, query.mediaType));
1373
+ }
1374
+ const where = and(...conditions);
1375
+ const [rows, countResult] = await Promise.all([
1376
+ db
1377
+ .select()
1378
+ .from(productMedia)
1379
+ .where(where)
1380
+ .limit(query.limit)
1381
+ .offset(query.offset)
1382
+ .orderBy(desc(productMedia.isCover), asc(productMedia.sortOrder), asc(productMedia.createdAt)),
1383
+ db.select({ count: sql `count(*)::int` }).from(productMedia).where(where),
1384
+ ]);
1385
+ return {
1386
+ data: rows,
1387
+ total: countResult[0]?.count ?? 0,
1388
+ limit: query.limit,
1389
+ offset: query.offset,
1390
+ };
1391
+ },
1392
+ async listProductLevelMedia(db, productId, query) {
1393
+ const conditions = [
1394
+ eq(productMedia.productId, productId),
1395
+ sql `${productMedia.dayId} is null`,
1396
+ ];
1397
+ if (query.mediaType) {
1398
+ conditions.push(eq(productMedia.mediaType, query.mediaType));
1399
+ }
1400
+ const where = and(...conditions);
1401
+ const [rows, countResult] = await Promise.all([
1402
+ db
1403
+ .select()
1404
+ .from(productMedia)
1405
+ .where(where)
1406
+ .limit(query.limit)
1407
+ .offset(query.offset)
1408
+ .orderBy(desc(productMedia.isCover), asc(productMedia.sortOrder), asc(productMedia.createdAt)),
1409
+ db.select({ count: sql `count(*)::int` }).from(productMedia).where(where),
1410
+ ]);
1411
+ return {
1412
+ data: rows,
1413
+ total: countResult[0]?.count ?? 0,
1414
+ limit: query.limit,
1415
+ offset: query.offset,
1416
+ };
1417
+ },
1418
+ async getMediaById(db, id) {
1419
+ const [row] = await db.select().from(productMedia).where(eq(productMedia.id, id)).limit(1);
1420
+ return row ?? null;
1421
+ },
1422
+ async createMedia(db, productId, data) {
1423
+ const product = await ensureProductExists(db, productId);
1424
+ if (!product) {
1425
+ return null;
1426
+ }
1427
+ if (data.dayId) {
1428
+ const [day] = await db
1429
+ .select({ id: productDays.id, productId: productDays.productId })
1430
+ .from(productDays)
1431
+ .where(eq(productDays.id, data.dayId))
1432
+ .limit(1);
1433
+ if (!day || day.productId !== productId) {
1434
+ return null;
1435
+ }
1436
+ }
1437
+ const [row] = await db
1438
+ .insert(productMedia)
1439
+ .values({
1440
+ productId,
1441
+ dayId: data.dayId ?? null,
1442
+ mediaType: data.mediaType,
1443
+ name: data.name,
1444
+ url: data.url,
1445
+ storageKey: data.storageKey ?? null,
1446
+ mimeType: data.mimeType ?? null,
1447
+ fileSize: data.fileSize ?? null,
1448
+ altText: data.altText ?? null,
1449
+ sortOrder: data.sortOrder,
1450
+ isCover: data.isCover,
1451
+ })
1452
+ .returning();
1453
+ return row ?? null;
1454
+ },
1455
+ async updateMedia(db, id, data) {
1456
+ const [row] = await db
1457
+ .update(productMedia)
1458
+ .set({ ...data, updatedAt: new Date() })
1459
+ .where(eq(productMedia.id, id))
1460
+ .returning();
1461
+ return row ?? null;
1462
+ },
1463
+ async deleteMedia(db, id) {
1464
+ const [row] = await db
1465
+ .delete(productMedia)
1466
+ .where(eq(productMedia.id, id))
1467
+ .returning();
1468
+ return row ?? null;
1469
+ },
1470
+ async setCoverMedia(db, productId, mediaId, dayId) {
1471
+ // Unset existing cover in the same scope (product-level or day-level)
1472
+ const scopeConditions = [eq(productMedia.productId, productId)];
1473
+ if (dayId) {
1474
+ scopeConditions.push(eq(productMedia.dayId, dayId));
1475
+ }
1476
+ else {
1477
+ scopeConditions.push(sql `${productMedia.dayId} is null`);
1478
+ }
1479
+ await db
1480
+ .update(productMedia)
1481
+ .set({ isCover: false, updatedAt: new Date() })
1482
+ .where(and(...scopeConditions));
1483
+ const [row] = await db
1484
+ .update(productMedia)
1485
+ .set({ isCover: true, updatedAt: new Date() })
1486
+ .where(eq(productMedia.id, mediaId))
1487
+ .returning();
1488
+ return row ?? null;
1489
+ },
1490
+ async reorderMedia(db, data) {
1491
+ const results = await Promise.all(data.items.map(async (item) => {
1492
+ const [row] = await db
1493
+ .update(productMedia)
1494
+ .set({ sortOrder: item.sortOrder, updatedAt: new Date() })
1495
+ .where(eq(productMedia.id, item.id))
1496
+ .returning({ id: productMedia.id });
1497
+ return row;
1498
+ }));
1499
+ return results.filter((r) => r != null);
1500
+ },
1501
+ };