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