@voyantjs/products 0.3.0 → 0.3.1

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,479 @@
1
+ import { and, asc, desc, eq, ilike, inArray, notInArray, or, sql } from "drizzle-orm";
2
+ import { productCapabilities, productCategories, productCategoryProducts, productFaqs, productFeatures, productLocations, productMedia, products, productTagProducts, productTags, productTranslations, productTypes, productVisibilitySettings, } from "./schema.js";
3
+ function impossibleCondition() {
4
+ return sql `1 = 0`;
5
+ }
6
+ function normalizeDate(value) {
7
+ if (!value) {
8
+ return null;
9
+ }
10
+ return value instanceof Date ? value.toISOString() : value;
11
+ }
12
+ function normalizeLanguageTag(value) {
13
+ const normalized = value?.trim().toLowerCase();
14
+ return normalized || null;
15
+ }
16
+ async function listProductIdsForCategory(db, categoryId) {
17
+ const rows = await db
18
+ .select({ productId: productCategoryProducts.productId })
19
+ .from(productCategoryProducts)
20
+ .innerJoin(productCategories, eq(productCategories.id, productCategoryProducts.categoryId))
21
+ .where(and(eq(productCategoryProducts.categoryId, categoryId), eq(productCategories.active, true)));
22
+ return rows.map((row) => row.productId);
23
+ }
24
+ async function listProductIdsForTag(db, tagId) {
25
+ const rows = await db
26
+ .select({ productId: productTagProducts.productId })
27
+ .from(productTagProducts)
28
+ .where(eq(productTagProducts.tagId, tagId));
29
+ return rows.map((row) => row.productId);
30
+ }
31
+ async function listFeaturedProductIds(db) {
32
+ const rows = await db
33
+ .select({ productId: productVisibilitySettings.productId })
34
+ .from(productVisibilitySettings)
35
+ .where(eq(productVisibilitySettings.isFeatured, true));
36
+ return rows.map((row) => row.productId);
37
+ }
38
+ async function hydrateCatalogProducts(db, productRows, options) {
39
+ if (productRows.length === 0) {
40
+ return [];
41
+ }
42
+ const productIds = productRows.map((product) => product.id);
43
+ const productTypeIds = Array.from(new Set(productRows
44
+ .map((product) => product.productTypeId)
45
+ .filter((value) => Boolean(value))));
46
+ const [categoryRows, tagRows, translationRows, typeRows, capabilityRows, mediaRows, featuredRows, featureRows, faqRows, locationRows,] = await Promise.all([
47
+ db
48
+ .select({
49
+ productId: productCategoryProducts.productId,
50
+ id: productCategories.id,
51
+ parentId: productCategories.parentId,
52
+ name: productCategories.name,
53
+ slug: productCategories.slug,
54
+ description: productCategories.description,
55
+ sortOrder: productCategories.sortOrder,
56
+ })
57
+ .from(productCategoryProducts)
58
+ .innerJoin(productCategories, eq(productCategories.id, productCategoryProducts.categoryId))
59
+ .where(and(inArray(productCategoryProducts.productId, productIds), eq(productCategories.active, true)))
60
+ .orderBy(asc(productCategoryProducts.sortOrder), asc(productCategories.sortOrder), asc(productCategories.name)),
61
+ db
62
+ .select({
63
+ productId: productTagProducts.productId,
64
+ id: productTags.id,
65
+ name: productTags.name,
66
+ })
67
+ .from(productTagProducts)
68
+ .innerJoin(productTags, eq(productTags.id, productTagProducts.tagId))
69
+ .where(inArray(productTagProducts.productId, productIds))
70
+ .orderBy(asc(productTags.name)),
71
+ options?.languageTag
72
+ ? db
73
+ .select({
74
+ productId: productTranslations.productId,
75
+ languageTag: productTranslations.languageTag,
76
+ slug: productTranslations.slug,
77
+ name: productTranslations.name,
78
+ shortDescription: productTranslations.shortDescription,
79
+ description: productTranslations.description,
80
+ seoTitle: productTranslations.seoTitle,
81
+ seoDescription: productTranslations.seoDescription,
82
+ })
83
+ .from(productTranslations)
84
+ .where(and(inArray(productTranslations.productId, productIds), eq(productTranslations.languageTag, options.languageTag)))
85
+ : Promise.resolve([]),
86
+ productTypeIds.length > 0
87
+ ? db
88
+ .select({
89
+ id: productTypes.id,
90
+ code: productTypes.code,
91
+ name: productTypes.name,
92
+ description: productTypes.description,
93
+ })
94
+ .from(productTypes)
95
+ .where(and(inArray(productTypes.id, productTypeIds), eq(productTypes.active, true)))
96
+ : Promise.resolve([]),
97
+ db
98
+ .select({
99
+ productId: productCapabilities.productId,
100
+ capability: productCapabilities.capability,
101
+ })
102
+ .from(productCapabilities)
103
+ .where(and(inArray(productCapabilities.productId, productIds), eq(productCapabilities.enabled, true)))
104
+ .orderBy(asc(productCapabilities.capability)),
105
+ db
106
+ .select({
107
+ productId: productMedia.productId,
108
+ id: productMedia.id,
109
+ mediaType: productMedia.mediaType,
110
+ name: productMedia.name,
111
+ url: productMedia.url,
112
+ mimeType: productMedia.mimeType,
113
+ altText: productMedia.altText,
114
+ sortOrder: productMedia.sortOrder,
115
+ isCover: productMedia.isCover,
116
+ })
117
+ .from(productMedia)
118
+ .where(inArray(productMedia.productId, productIds))
119
+ .orderBy(desc(productMedia.isCover), asc(productMedia.sortOrder), asc(productMedia.createdAt)),
120
+ db
121
+ .select({ productId: productVisibilitySettings.productId })
122
+ .from(productVisibilitySettings)
123
+ .where(and(inArray(productVisibilitySettings.productId, productIds), eq(productVisibilitySettings.isFeatured, true))),
124
+ options?.includeContent
125
+ ? db
126
+ .select({
127
+ productId: productFeatures.productId,
128
+ id: productFeatures.id,
129
+ featureType: productFeatures.featureType,
130
+ title: productFeatures.title,
131
+ description: productFeatures.description,
132
+ sortOrder: productFeatures.sortOrder,
133
+ })
134
+ .from(productFeatures)
135
+ .where(inArray(productFeatures.productId, productIds))
136
+ .orderBy(asc(productFeatures.sortOrder), asc(productFeatures.createdAt))
137
+ : Promise.resolve([]),
138
+ options?.includeContent
139
+ ? db
140
+ .select({
141
+ productId: productFaqs.productId,
142
+ id: productFaqs.id,
143
+ question: productFaqs.question,
144
+ answer: productFaqs.answer,
145
+ sortOrder: productFaqs.sortOrder,
146
+ })
147
+ .from(productFaqs)
148
+ .where(inArray(productFaqs.productId, productIds))
149
+ .orderBy(asc(productFaqs.sortOrder), asc(productFaqs.createdAt))
150
+ : Promise.resolve([]),
151
+ options?.includeContent
152
+ ? db
153
+ .select({
154
+ productId: productLocations.productId,
155
+ id: productLocations.id,
156
+ locationType: productLocations.locationType,
157
+ title: productLocations.title,
158
+ address: productLocations.address,
159
+ city: productLocations.city,
160
+ countryCode: productLocations.countryCode,
161
+ latitude: productLocations.latitude,
162
+ longitude: productLocations.longitude,
163
+ sortOrder: productLocations.sortOrder,
164
+ })
165
+ .from(productLocations)
166
+ .where(inArray(productLocations.productId, productIds))
167
+ .orderBy(asc(productLocations.sortOrder), asc(productLocations.createdAt))
168
+ : Promise.resolve([]),
169
+ ]);
170
+ const categoriesByProduct = new Map();
171
+ for (const row of categoryRows) {
172
+ const existing = categoriesByProduct.get(row.productId) ?? [];
173
+ existing.push(row);
174
+ categoriesByProduct.set(row.productId, existing);
175
+ }
176
+ const tagsByProduct = new Map();
177
+ for (const row of tagRows) {
178
+ const existing = tagsByProduct.get(row.productId) ?? [];
179
+ existing.push(row);
180
+ tagsByProduct.set(row.productId, existing);
181
+ }
182
+ const translationByProduct = new Map(translationRows.map((row) => [row.productId, row]));
183
+ const capabilitiesByProduct = new Map();
184
+ for (const row of capabilityRows) {
185
+ const existing = capabilitiesByProduct.get(row.productId) ?? [];
186
+ existing.push(row.capability);
187
+ capabilitiesByProduct.set(row.productId, existing);
188
+ }
189
+ const mediaByProduct = new Map();
190
+ for (const row of mediaRows) {
191
+ const existing = mediaByProduct.get(row.productId) ?? [];
192
+ existing.push(row);
193
+ mediaByProduct.set(row.productId, existing);
194
+ }
195
+ const featuresByProduct = new Map();
196
+ for (const row of featureRows) {
197
+ const existing = featuresByProduct.get(row.productId) ?? [];
198
+ existing.push(row);
199
+ featuresByProduct.set(row.productId, existing);
200
+ }
201
+ const faqsByProduct = new Map();
202
+ for (const row of faqRows) {
203
+ const existing = faqsByProduct.get(row.productId) ?? [];
204
+ existing.push(row);
205
+ faqsByProduct.set(row.productId, existing);
206
+ }
207
+ const locationsByProduct = new Map();
208
+ for (const row of locationRows) {
209
+ const existing = locationsByProduct.get(row.productId) ?? [];
210
+ existing.push(row);
211
+ locationsByProduct.set(row.productId, existing);
212
+ }
213
+ const typeById = new Map(typeRows.map((row) => [row.id, row]));
214
+ const featuredIds = new Set(featuredRows.map((row) => row.productId));
215
+ return productRows.map((product) => {
216
+ const translation = translationByProduct.get(product.id) ?? null;
217
+ const media = (mediaByProduct.get(product.id) ?? []).map((row) => ({
218
+ id: row.id,
219
+ mediaType: row.mediaType,
220
+ name: row.name,
221
+ url: row.url,
222
+ mimeType: row.mimeType ?? null,
223
+ altText: row.altText ?? null,
224
+ sortOrder: row.sortOrder,
225
+ isCover: row.isCover,
226
+ }));
227
+ const base = {
228
+ id: product.id,
229
+ name: translation?.name ?? product.name,
230
+ description: translation?.description ?? product.description ?? null,
231
+ contentLanguageTag: translation?.languageTag ?? null,
232
+ slug: translation?.slug ?? null,
233
+ shortDescription: translation?.shortDescription ?? null,
234
+ seoTitle: translation?.seoTitle ?? null,
235
+ seoDescription: translation?.seoDescription ?? null,
236
+ bookingMode: product.bookingMode,
237
+ capacityMode: product.capacityMode,
238
+ visibility: product.visibility,
239
+ sellCurrency: product.sellCurrency,
240
+ sellAmountCents: product.sellAmountCents ?? null,
241
+ startDate: normalizeDate(product.startDate),
242
+ endDate: normalizeDate(product.endDate),
243
+ pax: product.pax ?? null,
244
+ productType: product.productTypeId ? (typeById.get(product.productTypeId) ?? null) : null,
245
+ categories: (categoriesByProduct.get(product.id) ?? []).map((row) => ({
246
+ id: row.id,
247
+ parentId: row.parentId ?? null,
248
+ name: row.name,
249
+ slug: row.slug,
250
+ description: row.description ?? null,
251
+ sortOrder: row.sortOrder,
252
+ })),
253
+ tags: (tagsByProduct.get(product.id) ?? []).map((row) => ({
254
+ id: row.id,
255
+ name: row.name,
256
+ })),
257
+ capabilities: capabilitiesByProduct.get(product.id) ?? [],
258
+ coverMedia: media.find((item) => item.isCover) ?? media[0] ?? null,
259
+ isFeatured: featuredIds.has(product.id),
260
+ };
261
+ if (!options?.includeContent) {
262
+ return base;
263
+ }
264
+ return {
265
+ ...base,
266
+ media,
267
+ features: (featuresByProduct.get(product.id) ?? []).map((row) => ({
268
+ id: row.id,
269
+ featureType: row.featureType,
270
+ title: row.title,
271
+ description: row.description ?? null,
272
+ sortOrder: row.sortOrder,
273
+ })),
274
+ faqs: (faqsByProduct.get(product.id) ?? []).map((row) => ({
275
+ id: row.id,
276
+ question: row.question,
277
+ answer: row.answer,
278
+ sortOrder: row.sortOrder,
279
+ })),
280
+ locations: (locationsByProduct.get(product.id) ?? []).map((row) => ({
281
+ id: row.id,
282
+ locationType: row.locationType,
283
+ title: row.title,
284
+ address: row.address ?? null,
285
+ city: row.city ?? null,
286
+ countryCode: row.countryCode ?? null,
287
+ latitude: row.latitude ?? null,
288
+ longitude: row.longitude ?? null,
289
+ sortOrder: row.sortOrder,
290
+ })),
291
+ };
292
+ });
293
+ }
294
+ function orderProducts(query) {
295
+ const direction = query.direction === "desc" ? desc : asc;
296
+ switch (query.sort) {
297
+ case "createdAt":
298
+ return direction(products.createdAt);
299
+ case "startDate":
300
+ return direction(products.startDate);
301
+ case "price":
302
+ return direction(products.sellAmountCents);
303
+ default:
304
+ return direction(products.name);
305
+ }
306
+ }
307
+ export const publicProductsService = {
308
+ async listCatalogProducts(db, query) {
309
+ const conditions = [
310
+ eq(products.status, "active"),
311
+ eq(products.activated, true),
312
+ eq(products.visibility, "public"),
313
+ ];
314
+ if (query.search) {
315
+ const term = `%${query.search}%`;
316
+ const searchCondition = or(ilike(products.name, term), ilike(products.description, term));
317
+ if (searchCondition) {
318
+ conditions.push(searchCondition);
319
+ }
320
+ }
321
+ if (query.bookingMode) {
322
+ conditions.push(eq(products.bookingMode, query.bookingMode));
323
+ }
324
+ if (query.capacityMode) {
325
+ conditions.push(eq(products.capacityMode, query.capacityMode));
326
+ }
327
+ if (query.productTypeId) {
328
+ conditions.push(eq(products.productTypeId, query.productTypeId));
329
+ }
330
+ if (query.categoryId) {
331
+ const productIds = await listProductIdsForCategory(db, query.categoryId);
332
+ conditions.push(productIds.length > 0 ? inArray(products.id, productIds) : impossibleCondition());
333
+ }
334
+ if (query.tagId) {
335
+ const productIds = await listProductIdsForTag(db, query.tagId);
336
+ conditions.push(productIds.length > 0 ? inArray(products.id, productIds) : impossibleCondition());
337
+ }
338
+ if (query.featured !== undefined) {
339
+ const productIds = await listFeaturedProductIds(db);
340
+ conditions.push(query.featured
341
+ ? productIds.length > 0
342
+ ? inArray(products.id, productIds)
343
+ : impossibleCondition()
344
+ : productIds.length > 0
345
+ ? notInArray(products.id, productIds)
346
+ : sql `1 = 1`);
347
+ }
348
+ const where = and(...conditions);
349
+ const [rows, countResult] = await Promise.all([
350
+ db
351
+ .select()
352
+ .from(products)
353
+ .where(where)
354
+ .orderBy(orderProducts(query), asc(products.id))
355
+ .limit(query.limit)
356
+ .offset(query.offset),
357
+ db.select({ count: sql `count(*)::int` }).from(products).where(where),
358
+ ]);
359
+ return {
360
+ data: await hydrateCatalogProducts(db, rows, {
361
+ languageTag: normalizeLanguageTag(query.languageTag),
362
+ }),
363
+ total: countResult[0]?.count ?? 0,
364
+ limit: query.limit,
365
+ offset: query.offset,
366
+ };
367
+ },
368
+ async getCatalogProductById(db, id, query = {}) {
369
+ const [row] = await db
370
+ .select()
371
+ .from(products)
372
+ .where(and(eq(products.id, id), eq(products.status, "active"), eq(products.activated, true), eq(products.visibility, "public")))
373
+ .limit(1);
374
+ if (!row) {
375
+ return null;
376
+ }
377
+ const [product] = await hydrateCatalogProducts(db, [row], {
378
+ includeContent: true,
379
+ languageTag: normalizeLanguageTag(query.languageTag),
380
+ });
381
+ return product ?? null;
382
+ },
383
+ async getCatalogProductBySlug(db, slug, query = {}) {
384
+ const normalizedSlug = slug.trim().toLowerCase();
385
+ const normalizedLanguageTag = normalizeLanguageTag(query.languageTag);
386
+ const conditions = [
387
+ sql `lower(${productTranslations.slug}) = ${normalizedSlug}`,
388
+ eq(products.status, "active"),
389
+ eq(products.activated, true),
390
+ eq(products.visibility, "public"),
391
+ ];
392
+ if (normalizedLanguageTag) {
393
+ conditions.push(eq(productTranslations.languageTag, normalizedLanguageTag));
394
+ }
395
+ const [row] = await db
396
+ .select({
397
+ productId: products.id,
398
+ languageTag: productTranslations.languageTag,
399
+ })
400
+ .from(productTranslations)
401
+ .innerJoin(products, eq(products.id, productTranslations.productId))
402
+ .where(and(...conditions))
403
+ .orderBy(desc(productTranslations.updatedAt))
404
+ .limit(1);
405
+ if (!row) {
406
+ return null;
407
+ }
408
+ return this.getCatalogProductById(db, row.productId, {
409
+ languageTag: normalizedLanguageTag ?? row.languageTag,
410
+ });
411
+ },
412
+ async listCatalogCategories(db, query) {
413
+ const conditions = [eq(productCategories.active, true)];
414
+ if (query.parentId) {
415
+ conditions.push(eq(productCategories.parentId, query.parentId));
416
+ }
417
+ if (query.search) {
418
+ const term = `%${query.search}%`;
419
+ const searchCondition = or(ilike(productCategories.name, term), ilike(productCategories.slug, term));
420
+ if (searchCondition) {
421
+ conditions.push(searchCondition);
422
+ }
423
+ }
424
+ const where = and(...conditions);
425
+ const [rows, countResult] = await Promise.all([
426
+ db
427
+ .select({
428
+ id: productCategories.id,
429
+ parentId: productCategories.parentId,
430
+ name: productCategories.name,
431
+ slug: productCategories.slug,
432
+ description: productCategories.description,
433
+ sortOrder: productCategories.sortOrder,
434
+ })
435
+ .from(productCategories)
436
+ .where(where)
437
+ .orderBy(asc(productCategories.sortOrder), asc(productCategories.name))
438
+ .limit(query.limit)
439
+ .offset(query.offset),
440
+ db.select({ count: sql `count(*)::int` }).from(productCategories).where(where),
441
+ ]);
442
+ return {
443
+ data: rows.map((row) => ({
444
+ ...row,
445
+ parentId: row.parentId ?? null,
446
+ description: row.description ?? null,
447
+ })),
448
+ total: countResult[0]?.count ?? 0,
449
+ limit: query.limit,
450
+ offset: query.offset,
451
+ };
452
+ },
453
+ async listCatalogTags(db, query) {
454
+ const conditions = [];
455
+ if (query.search) {
456
+ conditions.push(ilike(productTags.name, `%${query.search}%`));
457
+ }
458
+ const where = conditions.length > 0 ? and(...conditions) : undefined;
459
+ const [rows, countResult] = await Promise.all([
460
+ db
461
+ .select({
462
+ id: productTags.id,
463
+ name: productTags.name,
464
+ })
465
+ .from(productTags)
466
+ .where(where)
467
+ .orderBy(asc(productTags.name))
468
+ .limit(query.limit)
469
+ .offset(query.offset),
470
+ db.select({ count: sql `count(*)::int` }).from(productTags).where(where),
471
+ ]);
472
+ return {
473
+ data: rows,
474
+ total: countResult[0]?.count ?? 0,
475
+ limit: query.limit,
476
+ offset: query.offset,
477
+ };
478
+ },
479
+ };