@voyantjs/catalog-authoring 0.105.0 → 0.106.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,38 @@
1
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
2
+ /** Shared, mutable state threaded across the clone copy phases (id remaps). */
3
+ export interface CloneContext {
4
+ tx: PostgresJsDatabase;
5
+ sourceId: string;
6
+ targetId: string;
7
+ copyDepartures: boolean;
8
+ optionIdMap: Map<string, string>;
9
+ unitIdMap: Map<string, string>;
10
+ unitsByNewOption: Map<string, {
11
+ id: string;
12
+ }[]>;
13
+ itineraryIdMap: Map<string, string>;
14
+ dayIdMap: Map<string, string>;
15
+ startTimeIdMap: Map<string, string>;
16
+ ruleIdMap: Map<string, string>;
17
+ slotIdMap: Map<string, string>;
18
+ optionPriceRuleIdMap: Map<string, string>;
19
+ optionUnitPriceRuleIdMap: Map<string, string>;
20
+ pricingCategoryIdMap: Map<string, string>;
21
+ productExtraIdMap: Map<string, string>;
22
+ optionExtraConfigIdMap: Map<string, string>;
23
+ }
24
+ type SystemColumns = {
25
+ id?: unknown;
26
+ createdAt?: unknown;
27
+ updatedAt?: unknown;
28
+ };
29
+ export declare function withoutSystemColumns<T extends SystemColumns>(row: T): Omit<T, "id" | "createdAt" | "updatedAt">;
30
+ /**
31
+ * Copies the product's content graph: per-product settings, options + units
32
+ * (with translations + resource templates), pricing categories (+ dependencies,
33
+ * remapped), itineraries/days/services, media, and extras. Populates the id
34
+ * remaps in {@link CloneContext} for the pricing/availability phase.
35
+ */
36
+ export declare function copyProductContent(ctx: CloneContext): Promise<void>;
37
+ export {};
38
+ //# sourceMappingURL=clone-content.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clone-content.d.ts","sourceRoot":"","sources":["../src/clone-content.ts"],"names":[],"mappings":"AA2BA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAEjE,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,kBAAkB,CAAA;IACtB,QAAQ,EAAE,MAAM,CAAA;IAChB,QAAQ,EAAE,MAAM,CAAA;IAChB,cAAc,EAAE,OAAO,CAAA;IACvB,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,gBAAgB,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,EAAE,CAAC,CAAA;IAC/C,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7B,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACnC,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,SAAS,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC9B,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACzC,wBAAwB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC7C,oBAAoB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACzC,iBAAiB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IACtC,sBAAsB,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAC5C;AAED,KAAK,aAAa,GAAG;IACnB,EAAE,CAAC,EAAE,OAAO,CAAA;IACZ,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,SAAS,CAAC,EAAE,OAAO,CAAA;CACpB,CAAA;AAED,wBAAgB,oBAAoB,CAAC,CAAC,SAAS,aAAa,EAAE,GAAG,EAAE,CAAC,6CAGnE;AAED;;;;;GAKG;AACH,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CA6HzE"}
@@ -0,0 +1,367 @@
1
+ import { productOptionResourceTemplates } from "@voyantjs/availability";
2
+ import { optionExtraConfigs, productExtras } from "@voyantjs/extras";
3
+ import { pricingCategories, pricingCategoryDependencies } from "@voyantjs/pricing/schema";
4
+ import { optionUnits, optionUnitTranslations, productActivationSettings, productCapabilities, productCategoryProducts, productDayServices, productDays, productDayTranslations, productDeliveryFormats, productDestinations, productFaqs, productFeatures, productItineraries, productLocations, productMedia, productOptions, productOptionTranslations, productTagProducts, productTicketSettings, productTranslations, productVisibilitySettings, } from "@voyantjs/products/schema";
5
+ import { eq, inArray } from "drizzle-orm";
6
+ export function withoutSystemColumns(row) {
7
+ const { id: _id, createdAt: _createdAt, updatedAt: _updatedAt, ...values } = row;
8
+ return values;
9
+ }
10
+ /**
11
+ * Copies the product's content graph: per-product settings, options + units
12
+ * (with translations + resource templates), pricing categories (+ dependencies,
13
+ * remapped), itineraries/days/services, media, and extras. Populates the id
14
+ * remaps in {@link CloneContext} for the pricing/availability phase.
15
+ */
16
+ export async function copyProductContent(ctx) {
17
+ const { tx, sourceId, targetId } = ctx;
18
+ const activationRows = await tx
19
+ .select()
20
+ .from(productActivationSettings)
21
+ .where(eq(productActivationSettings.productId, sourceId));
22
+ for (const row of activationRows) {
23
+ await tx.insert(productActivationSettings).values({
24
+ ...withoutSystemColumns(row),
25
+ productId: targetId,
26
+ activationMode: "manual",
27
+ activateAt: null,
28
+ deactivateAt: null,
29
+ sellAt: null,
30
+ stopSellAt: null,
31
+ });
32
+ }
33
+ const ticketRows = await tx
34
+ .select()
35
+ .from(productTicketSettings)
36
+ .where(eq(productTicketSettings.productId, sourceId));
37
+ for (const row of ticketRows) {
38
+ await tx
39
+ .insert(productTicketSettings)
40
+ .values({ ...withoutSystemColumns(row), productId: targetId });
41
+ }
42
+ const visibilityRows = await tx
43
+ .select()
44
+ .from(productVisibilitySettings)
45
+ .where(eq(productVisibilitySettings.productId, sourceId));
46
+ for (const row of visibilityRows) {
47
+ await tx
48
+ .insert(productVisibilitySettings)
49
+ .values({ ...withoutSystemColumns(row), productId: targetId });
50
+ }
51
+ const capabilityRows = await tx
52
+ .select()
53
+ .from(productCapabilities)
54
+ .where(eq(productCapabilities.productId, sourceId));
55
+ for (const row of capabilityRows) {
56
+ await tx
57
+ .insert(productCapabilities)
58
+ .values({ ...withoutSystemColumns(row), productId: targetId });
59
+ }
60
+ const deliveryFormatRows = await tx
61
+ .select()
62
+ .from(productDeliveryFormats)
63
+ .where(eq(productDeliveryFormats.productId, sourceId));
64
+ for (const row of deliveryFormatRows) {
65
+ await tx
66
+ .insert(productDeliveryFormats)
67
+ .values({ ...withoutSystemColumns(row), productId: targetId });
68
+ }
69
+ const featureRows = await tx
70
+ .select()
71
+ .from(productFeatures)
72
+ .where(eq(productFeatures.productId, sourceId));
73
+ for (const row of featureRows) {
74
+ await tx.insert(productFeatures).values({ ...withoutSystemColumns(row), productId: targetId });
75
+ }
76
+ const faqRows = await tx.select().from(productFaqs).where(eq(productFaqs.productId, sourceId));
77
+ for (const row of faqRows) {
78
+ await tx.insert(productFaqs).values({ ...withoutSystemColumns(row), productId: targetId });
79
+ }
80
+ const locationRows = await tx
81
+ .select()
82
+ .from(productLocations)
83
+ .where(eq(productLocations.productId, sourceId));
84
+ for (const row of locationRows) {
85
+ await tx.insert(productLocations).values({ ...withoutSystemColumns(row), productId: targetId });
86
+ }
87
+ const translationRows = await tx
88
+ .select()
89
+ .from(productTranslations)
90
+ .where(eq(productTranslations.productId, sourceId));
91
+ for (const row of translationRows) {
92
+ await tx
93
+ .insert(productTranslations)
94
+ .values({ ...withoutSystemColumns(row), productId: targetId });
95
+ }
96
+ const categoryRows = await tx
97
+ .select()
98
+ .from(productCategoryProducts)
99
+ .where(eq(productCategoryProducts.productId, sourceId));
100
+ for (const row of categoryRows) {
101
+ await tx
102
+ .insert(productCategoryProducts)
103
+ .values({ ...withoutSystemColumns(row), productId: targetId });
104
+ }
105
+ const tagRows = await tx
106
+ .select()
107
+ .from(productTagProducts)
108
+ .where(eq(productTagProducts.productId, sourceId));
109
+ for (const row of tagRows) {
110
+ await tx
111
+ .insert(productTagProducts)
112
+ .values({ ...withoutSystemColumns(row), productId: targetId });
113
+ }
114
+ const destinationRows = await tx
115
+ .select()
116
+ .from(productDestinations)
117
+ .where(eq(productDestinations.productId, sourceId));
118
+ for (const row of destinationRows) {
119
+ await tx
120
+ .insert(productDestinations)
121
+ .values({ ...withoutSystemColumns(row), productId: targetId });
122
+ }
123
+ await copyOptionsAndUnits(ctx);
124
+ await copyPricingCategories(ctx);
125
+ await copyItinerary(ctx);
126
+ await copyMedia(ctx);
127
+ await copyExtras(ctx);
128
+ }
129
+ async function copyOptionsAndUnits(ctx) {
130
+ const { tx, sourceId, targetId, optionIdMap, unitIdMap, unitsByNewOption } = ctx;
131
+ const sourceOptions = await tx
132
+ .select()
133
+ .from(productOptions)
134
+ .where(eq(productOptions.productId, sourceId));
135
+ for (const row of sourceOptions) {
136
+ const [copy] = await tx
137
+ .insert(productOptions)
138
+ .values({ ...withoutSystemColumns(row), productId: targetId })
139
+ .returning();
140
+ if (copy) {
141
+ optionIdMap.set(row.id, copy.id);
142
+ unitsByNewOption.set(copy.id, []);
143
+ }
144
+ }
145
+ const sourceOptionIds = sourceOptions.map((row) => row.id);
146
+ if (sourceOptionIds.length === 0)
147
+ return;
148
+ const optionTranslationRows = await tx
149
+ .select()
150
+ .from(productOptionTranslations)
151
+ .where(inArray(productOptionTranslations.optionId, sourceOptionIds));
152
+ for (const row of optionTranslationRows) {
153
+ const targetOptionId = optionIdMap.get(row.optionId);
154
+ if (!targetOptionId)
155
+ continue;
156
+ await tx
157
+ .insert(productOptionTranslations)
158
+ .values({ ...withoutSystemColumns(row), optionId: targetOptionId });
159
+ }
160
+ const sourceUnits = await tx
161
+ .select()
162
+ .from(optionUnits)
163
+ .where(inArray(optionUnits.optionId, sourceOptionIds));
164
+ for (const row of sourceUnits) {
165
+ const targetOptionId = optionIdMap.get(row.optionId);
166
+ if (!targetOptionId)
167
+ continue;
168
+ const [copy] = await tx
169
+ .insert(optionUnits)
170
+ .values({ ...withoutSystemColumns(row), optionId: targetOptionId })
171
+ .returning();
172
+ if (copy) {
173
+ unitIdMap.set(row.id, copy.id);
174
+ unitsByNewOption.get(targetOptionId)?.push({ id: copy.id });
175
+ }
176
+ }
177
+ const sourceUnitIds = sourceUnits.map((row) => row.id);
178
+ if (sourceUnitIds.length > 0) {
179
+ const unitTranslationRows = await tx
180
+ .select()
181
+ .from(optionUnitTranslations)
182
+ .where(inArray(optionUnitTranslations.unitId, sourceUnitIds));
183
+ for (const row of unitTranslationRows) {
184
+ const targetUnitId = unitIdMap.get(row.unitId);
185
+ if (!targetUnitId)
186
+ continue;
187
+ await tx
188
+ .insert(optionUnitTranslations)
189
+ .values({ ...withoutSystemColumns(row), unitId: targetUnitId });
190
+ }
191
+ }
192
+ const resourceTemplateRows = await tx
193
+ .select()
194
+ .from(productOptionResourceTemplates)
195
+ .where(inArray(productOptionResourceTemplates.productOptionId, sourceOptionIds));
196
+ for (const row of resourceTemplateRows) {
197
+ const targetOptionId = optionIdMap.get(row.productOptionId);
198
+ if (!targetOptionId)
199
+ continue;
200
+ await tx
201
+ .insert(productOptionResourceTemplates)
202
+ .values({ ...withoutSystemColumns(row), productOptionId: targetOptionId });
203
+ }
204
+ }
205
+ async function copyPricingCategories(ctx) {
206
+ const { tx, sourceId, targetId, optionIdMap, unitIdMap, pricingCategoryIdMap } = ctx;
207
+ const pricingCategoryRows = await tx
208
+ .select()
209
+ .from(pricingCategories)
210
+ .where(eq(pricingCategories.productId, sourceId));
211
+ for (const row of pricingCategoryRows) {
212
+ const targetOptionId = row.optionId ? (optionIdMap.get(row.optionId) ?? null) : null;
213
+ const targetUnitId = row.unitId ? (unitIdMap.get(row.unitId) ?? null) : null;
214
+ if (row.optionId && !targetOptionId)
215
+ continue;
216
+ if (row.unitId && !targetUnitId)
217
+ continue;
218
+ const [copy] = await tx
219
+ .insert(pricingCategories)
220
+ .values({
221
+ ...withoutSystemColumns(row),
222
+ productId: targetId,
223
+ optionId: targetOptionId,
224
+ unitId: targetUnitId,
225
+ })
226
+ .returning();
227
+ if (copy)
228
+ pricingCategoryIdMap.set(row.id, copy.id);
229
+ }
230
+ const sourcePricingCategoryIds = pricingCategoryRows.map((row) => row.id);
231
+ if (sourcePricingCategoryIds.length === 0)
232
+ return;
233
+ const dependencyRows = await tx
234
+ .select()
235
+ .from(pricingCategoryDependencies)
236
+ .where(inArray(pricingCategoryDependencies.pricingCategoryId, sourcePricingCategoryIds));
237
+ for (const row of dependencyRows) {
238
+ const targetPricingCategoryId = pricingCategoryIdMap.get(row.pricingCategoryId);
239
+ const targetMasterPricingCategoryId = pricingCategoryIdMap.get(row.masterPricingCategoryId);
240
+ if (!targetPricingCategoryId || !targetMasterPricingCategoryId)
241
+ continue;
242
+ await tx.insert(pricingCategoryDependencies).values({
243
+ ...withoutSystemColumns(row),
244
+ pricingCategoryId: targetPricingCategoryId,
245
+ masterPricingCategoryId: targetMasterPricingCategoryId,
246
+ });
247
+ }
248
+ }
249
+ async function copyItinerary(ctx) {
250
+ const { tx, sourceId, targetId, itineraryIdMap, dayIdMap } = ctx;
251
+ const sourceItineraries = await tx
252
+ .select()
253
+ .from(productItineraries)
254
+ .where(eq(productItineraries.productId, sourceId));
255
+ for (const row of sourceItineraries) {
256
+ const [copy] = await tx
257
+ .insert(productItineraries)
258
+ .values({ ...withoutSystemColumns(row), productId: targetId })
259
+ .returning();
260
+ if (copy)
261
+ itineraryIdMap.set(row.id, copy.id);
262
+ }
263
+ if (sourceItineraries.length === 0) {
264
+ await tx
265
+ .insert(productItineraries)
266
+ .values({ productId: targetId, name: "Default", isDefault: true, sortOrder: 0 });
267
+ return;
268
+ }
269
+ const sourceItineraryIds = sourceItineraries.map((row) => row.id);
270
+ const sourceDays = await tx
271
+ .select()
272
+ .from(productDays)
273
+ .where(inArray(productDays.itineraryId, sourceItineraryIds));
274
+ for (const row of sourceDays) {
275
+ const targetItineraryId = itineraryIdMap.get(row.itineraryId);
276
+ if (!targetItineraryId)
277
+ continue;
278
+ const [copy] = await tx
279
+ .insert(productDays)
280
+ .values({ ...withoutSystemColumns(row), itineraryId: targetItineraryId })
281
+ .returning();
282
+ if (copy)
283
+ dayIdMap.set(row.id, copy.id);
284
+ }
285
+ const sourceDayIds = sourceDays.map((row) => row.id);
286
+ if (sourceDayIds.length === 0)
287
+ return;
288
+ const dayServiceRows = await tx
289
+ .select()
290
+ .from(productDayServices)
291
+ .where(inArray(productDayServices.dayId, sourceDayIds));
292
+ for (const row of dayServiceRows) {
293
+ const targetDayId = dayIdMap.get(row.dayId);
294
+ if (!targetDayId)
295
+ continue;
296
+ await tx.insert(productDayServices).values({ ...withoutSystemColumns(row), dayId: targetDayId });
297
+ }
298
+ const dayTranslationRows = await tx
299
+ .select()
300
+ .from(productDayTranslations)
301
+ .where(inArray(productDayTranslations.dayId, sourceDayIds));
302
+ for (const row of dayTranslationRows) {
303
+ const targetDayId = dayIdMap.get(row.dayId);
304
+ if (!targetDayId)
305
+ continue;
306
+ await tx
307
+ .insert(productDayTranslations)
308
+ .values({ ...withoutSystemColumns(row), dayId: targetDayId });
309
+ }
310
+ }
311
+ async function copyMedia(ctx) {
312
+ const { tx, sourceId, targetId, dayIdMap } = ctx;
313
+ const mediaRows = await tx.select().from(productMedia).where(eq(productMedia.productId, sourceId));
314
+ for (const row of mediaRows) {
315
+ if (row.isBrochure)
316
+ continue;
317
+ const targetDayId = row.dayId ? dayIdMap.get(row.dayId) : null;
318
+ if (row.dayId && !targetDayId)
319
+ continue;
320
+ await tx.insert(productMedia).values({
321
+ ...withoutSystemColumns(row),
322
+ productId: targetId,
323
+ dayId: targetDayId,
324
+ isBrochure: false,
325
+ isBrochureCurrent: false,
326
+ brochureVersion: null,
327
+ });
328
+ }
329
+ }
330
+ async function copyExtras(ctx) {
331
+ const { tx, sourceId, targetId, optionIdMap, productExtraIdMap, optionExtraConfigIdMap } = ctx;
332
+ const extraRows = await tx
333
+ .select()
334
+ .from(productExtras)
335
+ .where(eq(productExtras.productId, sourceId));
336
+ for (const row of extraRows) {
337
+ const [copy] = await tx
338
+ .insert(productExtras)
339
+ .values({ ...withoutSystemColumns(row), productId: targetId })
340
+ .returning();
341
+ if (copy)
342
+ productExtraIdMap.set(row.id, copy.id);
343
+ }
344
+ const sourceOptionIds = [...optionIdMap.keys()];
345
+ if (sourceOptionIds.length === 0)
346
+ return;
347
+ const extraConfigRows = await tx
348
+ .select()
349
+ .from(optionExtraConfigs)
350
+ .where(inArray(optionExtraConfigs.optionId, sourceOptionIds));
351
+ for (const row of extraConfigRows) {
352
+ const targetOptionId = optionIdMap.get(row.optionId);
353
+ const targetProductExtraId = productExtraIdMap.get(row.productExtraId);
354
+ if (!targetOptionId || !targetProductExtraId)
355
+ continue;
356
+ const [copy] = await tx
357
+ .insert(optionExtraConfigs)
358
+ .values({
359
+ ...withoutSystemColumns(row),
360
+ optionId: targetOptionId,
361
+ productExtraId: targetProductExtraId,
362
+ })
363
+ .returning();
364
+ if (copy)
365
+ optionExtraConfigIdMap.set(row.id, copy.id);
366
+ }
367
+ }
@@ -0,0 +1,9 @@
1
+ import { type CloneContext } from "./clone-content.js";
2
+ /**
3
+ * Copies availability (when `copyDepartures`) and the full pricing-rule graph —
4
+ * option price rules → unit price rules (with remapped pricing categories) →
5
+ * tiers, plus start-time / pickup / dropoff / extra rules and departure
6
+ * overrides. Relies on the id remaps populated by {@link copyProductContent}.
7
+ */
8
+ export declare function copyPricingAndAvailability(ctx: CloneContext): Promise<void>;
9
+ //# sourceMappingURL=clone-pricing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clone-pricing.d.ts","sourceRoot":"","sources":["../src/clone-pricing.ts"],"names":[],"mappings":"AAgBA,OAAO,EAAE,KAAK,YAAY,EAAwB,MAAM,oBAAoB,CAAA;AAE5E;;;;;GAKG;AACH,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAQjF"}
@@ -0,0 +1,242 @@
1
+ import { availabilityRules, availabilitySlots, availabilityStartTimes, } from "@voyantjs/availability";
2
+ import { departurePriceOverrides, dropoffPriceRules, extraPriceRules, optionPriceRules, optionStartTimeRules, optionUnitPriceRules, optionUnitTiers, pickupPriceRules, } from "@voyantjs/pricing/schema";
3
+ import { eq, inArray } from "drizzle-orm";
4
+ import { withoutSystemColumns } from "./clone-content.js";
5
+ /**
6
+ * Copies availability (when `copyDepartures`) and the full pricing-rule graph —
7
+ * option price rules → unit price rules (with remapped pricing categories) →
8
+ * tiers, plus start-time / pickup / dropoff / extra rules and departure
9
+ * overrides. Relies on the id remaps populated by {@link copyProductContent}.
10
+ */
11
+ export async function copyPricingAndAvailability(ctx) {
12
+ if (ctx.copyDepartures) {
13
+ await copyAvailability(ctx);
14
+ }
15
+ await copyPriceRules(ctx);
16
+ if (ctx.copyDepartures && ctx.slotIdMap.size > 0) {
17
+ await copyDepartureOverrides(ctx);
18
+ }
19
+ }
20
+ async function copyAvailability(ctx) {
21
+ const { tx, sourceId, targetId, optionIdMap, itineraryIdMap, startTimeIdMap, ruleIdMap, slotIdMap, } = ctx;
22
+ const startTimeRows = await tx
23
+ .select()
24
+ .from(availabilityStartTimes)
25
+ .where(eq(availabilityStartTimes.productId, sourceId));
26
+ for (const row of startTimeRows) {
27
+ const [copy] = await tx
28
+ .insert(availabilityStartTimes)
29
+ .values({
30
+ ...withoutSystemColumns(row),
31
+ productId: targetId,
32
+ optionId: row.optionId ? (optionIdMap.get(row.optionId) ?? null) : null,
33
+ })
34
+ .returning();
35
+ if (copy)
36
+ startTimeIdMap.set(row.id, copy.id);
37
+ }
38
+ const availabilityRuleRows = await tx
39
+ .select()
40
+ .from(availabilityRules)
41
+ .where(eq(availabilityRules.productId, sourceId));
42
+ for (const row of availabilityRuleRows) {
43
+ const [copy] = await tx
44
+ .insert(availabilityRules)
45
+ .values({
46
+ ...withoutSystemColumns(row),
47
+ productId: targetId,
48
+ optionId: row.optionId ? (optionIdMap.get(row.optionId) ?? null) : null,
49
+ })
50
+ .returning();
51
+ if (copy)
52
+ ruleIdMap.set(row.id, copy.id);
53
+ }
54
+ const slotRows = await tx
55
+ .select()
56
+ .from(availabilitySlots)
57
+ .where(eq(availabilitySlots.productId, sourceId));
58
+ for (const row of slotRows) {
59
+ const targetItineraryId = row.itineraryId ? (itineraryIdMap.get(row.itineraryId) ?? null) : null;
60
+ const targetOptionId = row.optionId ? (optionIdMap.get(row.optionId) ?? null) : null;
61
+ const targetRuleId = row.availabilityRuleId
62
+ ? (ruleIdMap.get(row.availabilityRuleId) ?? null)
63
+ : null;
64
+ const targetStartTimeId = row.startTimeId ? (startTimeIdMap.get(row.startTimeId) ?? null) : null;
65
+ const [copy] = await tx
66
+ .insert(availabilitySlots)
67
+ .values({
68
+ ...withoutSystemColumns(row),
69
+ productId: targetId,
70
+ itineraryId: targetItineraryId,
71
+ optionId: targetOptionId,
72
+ availabilityRuleId: targetRuleId,
73
+ startTimeId: targetStartTimeId,
74
+ remainingPax: row.initialPax,
75
+ remainingPickups: row.initialPickups,
76
+ })
77
+ .returning();
78
+ if (copy)
79
+ slotIdMap.set(row.id, copy.id);
80
+ }
81
+ }
82
+ async function copyPriceRules(ctx) {
83
+ const { tx, targetId, optionIdMap, unitIdMap, startTimeIdMap, optionPriceRuleIdMap, optionUnitPriceRuleIdMap, pricingCategoryIdMap, productExtraIdMap, optionExtraConfigIdMap, } = ctx;
84
+ const sourceOptionIds = [...optionIdMap.keys()];
85
+ if (sourceOptionIds.length === 0)
86
+ return;
87
+ const optionPriceRuleRows = await tx
88
+ .select()
89
+ .from(optionPriceRules)
90
+ .where(inArray(optionPriceRules.optionId, sourceOptionIds));
91
+ for (const row of optionPriceRuleRows) {
92
+ const targetOptionId = optionIdMap.get(row.optionId);
93
+ if (!targetOptionId)
94
+ continue;
95
+ const [copy] = await tx
96
+ .insert(optionPriceRules)
97
+ .values({ ...withoutSystemColumns(row), productId: targetId, optionId: targetOptionId })
98
+ .returning();
99
+ if (copy)
100
+ optionPriceRuleIdMap.set(row.id, copy.id);
101
+ }
102
+ const sourceOptionPriceRuleIds = optionPriceRuleRows.map((row) => row.id);
103
+ if (sourceOptionPriceRuleIds.length === 0)
104
+ return;
105
+ const optionUnitPriceRuleRows = await tx
106
+ .select()
107
+ .from(optionUnitPriceRules)
108
+ .where(inArray(optionUnitPriceRules.optionPriceRuleId, sourceOptionPriceRuleIds));
109
+ for (const row of optionUnitPriceRuleRows) {
110
+ const targetPriceRuleId = optionPriceRuleIdMap.get(row.optionPriceRuleId);
111
+ const targetOptionId = optionIdMap.get(row.optionId);
112
+ const targetUnitId = unitIdMap.get(row.unitId);
113
+ const targetPricingCategoryId = row.pricingCategoryId
114
+ ? (pricingCategoryIdMap.get(row.pricingCategoryId) ?? null)
115
+ : null;
116
+ if (!targetPriceRuleId || !targetOptionId || !targetUnitId)
117
+ continue;
118
+ if (row.pricingCategoryId && !targetPricingCategoryId)
119
+ continue;
120
+ const [copy] = await tx
121
+ .insert(optionUnitPriceRules)
122
+ .values({
123
+ ...withoutSystemColumns(row),
124
+ optionPriceRuleId: targetPriceRuleId,
125
+ optionId: targetOptionId,
126
+ unitId: targetUnitId,
127
+ pricingCategoryId: targetPricingCategoryId,
128
+ })
129
+ .returning();
130
+ if (copy)
131
+ optionUnitPriceRuleIdMap.set(row.id, copy.id);
132
+ }
133
+ const sourceOptionUnitPriceRuleIds = optionUnitPriceRuleRows.map((row) => row.id);
134
+ if (sourceOptionUnitPriceRuleIds.length > 0) {
135
+ const tierRows = await tx
136
+ .select()
137
+ .from(optionUnitTiers)
138
+ .where(inArray(optionUnitTiers.optionUnitPriceRuleId, sourceOptionUnitPriceRuleIds));
139
+ for (const row of tierRows) {
140
+ const targetUnitPriceRuleId = optionUnitPriceRuleIdMap.get(row.optionUnitPriceRuleId);
141
+ if (!targetUnitPriceRuleId)
142
+ continue;
143
+ await tx
144
+ .insert(optionUnitTiers)
145
+ .values({ ...withoutSystemColumns(row), optionUnitPriceRuleId: targetUnitPriceRuleId });
146
+ }
147
+ }
148
+ const startRuleRows = await tx
149
+ .select()
150
+ .from(optionStartTimeRules)
151
+ .where(inArray(optionStartTimeRules.optionPriceRuleId, sourceOptionPriceRuleIds));
152
+ for (const row of startRuleRows) {
153
+ const targetPriceRuleId = optionPriceRuleIdMap.get(row.optionPriceRuleId);
154
+ const targetOptionId = optionIdMap.get(row.optionId);
155
+ const targetStartTimeId = startTimeIdMap.get(row.startTimeId);
156
+ if (!targetPriceRuleId || !targetOptionId || !targetStartTimeId)
157
+ continue;
158
+ await tx.insert(optionStartTimeRules).values({
159
+ ...withoutSystemColumns(row),
160
+ optionPriceRuleId: targetPriceRuleId,
161
+ optionId: targetOptionId,
162
+ startTimeId: targetStartTimeId,
163
+ });
164
+ }
165
+ const pickupPriceRuleRows = await tx
166
+ .select()
167
+ .from(pickupPriceRules)
168
+ .where(inArray(pickupPriceRules.optionPriceRuleId, sourceOptionPriceRuleIds));
169
+ for (const row of pickupPriceRuleRows) {
170
+ const targetPriceRuleId = optionPriceRuleIdMap.get(row.optionPriceRuleId);
171
+ const targetOptionId = optionIdMap.get(row.optionId);
172
+ if (!targetPriceRuleId || !targetOptionId)
173
+ continue;
174
+ await tx.insert(pickupPriceRules).values({
175
+ ...withoutSystemColumns(row),
176
+ optionPriceRuleId: targetPriceRuleId,
177
+ optionId: targetOptionId,
178
+ });
179
+ }
180
+ const dropoffPriceRuleRows = await tx
181
+ .select()
182
+ .from(dropoffPriceRules)
183
+ .where(inArray(dropoffPriceRules.optionPriceRuleId, sourceOptionPriceRuleIds));
184
+ for (const row of dropoffPriceRuleRows) {
185
+ const targetPriceRuleId = optionPriceRuleIdMap.get(row.optionPriceRuleId);
186
+ const targetOptionId = optionIdMap.get(row.optionId);
187
+ if (!targetPriceRuleId || !targetOptionId)
188
+ continue;
189
+ await tx.insert(dropoffPriceRules).values({
190
+ ...withoutSystemColumns(row),
191
+ optionPriceRuleId: targetPriceRuleId,
192
+ optionId: targetOptionId,
193
+ });
194
+ }
195
+ const extraPriceRuleRows = await tx
196
+ .select()
197
+ .from(extraPriceRules)
198
+ .where(inArray(extraPriceRules.optionPriceRuleId, sourceOptionPriceRuleIds));
199
+ for (const row of extraPriceRuleRows) {
200
+ const targetPriceRuleId = optionPriceRuleIdMap.get(row.optionPriceRuleId);
201
+ const targetOptionId = optionIdMap.get(row.optionId);
202
+ const targetProductExtraId = row.productExtraId
203
+ ? (productExtraIdMap.get(row.productExtraId) ?? null)
204
+ : null;
205
+ const targetOptionExtraConfigId = row.optionExtraConfigId
206
+ ? (optionExtraConfigIdMap.get(row.optionExtraConfigId) ?? null)
207
+ : null;
208
+ if (!targetPriceRuleId || !targetOptionId)
209
+ continue;
210
+ if (row.productExtraId && !targetProductExtraId)
211
+ continue;
212
+ if (row.optionExtraConfigId && !targetOptionExtraConfigId)
213
+ continue;
214
+ await tx.insert(extraPriceRules).values({
215
+ ...withoutSystemColumns(row),
216
+ optionPriceRuleId: targetPriceRuleId,
217
+ optionId: targetOptionId,
218
+ productExtraId: targetProductExtraId,
219
+ optionExtraConfigId: targetOptionExtraConfigId,
220
+ });
221
+ }
222
+ }
223
+ async function copyDepartureOverrides(ctx) {
224
+ const { tx, optionIdMap, unitIdMap, slotIdMap } = ctx;
225
+ const overrideRows = await tx
226
+ .select()
227
+ .from(departurePriceOverrides)
228
+ .where(inArray(departurePriceOverrides.departureId, [...slotIdMap.keys()]));
229
+ for (const row of overrideRows) {
230
+ const targetDepartureId = slotIdMap.get(row.departureId);
231
+ const targetOptionId = optionIdMap.get(row.optionId);
232
+ const targetUnitId = unitIdMap.get(row.optionUnitId);
233
+ if (!targetDepartureId || !targetOptionId || !targetUnitId)
234
+ continue;
235
+ await tx.insert(departurePriceOverrides).values({
236
+ ...withoutSystemColumns(row),
237
+ departureId: targetDepartureId,
238
+ optionId: targetOptionId,
239
+ optionUnitId: targetUnitId,
240
+ });
241
+ }
242
+ }
@@ -0,0 +1,45 @@
1
+ import { products } from "@voyantjs/products/schema";
2
+ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
3
+ type Product = typeof products.$inferSelect;
4
+ export interface CloneProductOptions {
5
+ /** New product name. Defaults to `"{source} (Copy)"` (preserves the UI's no-body call). */
6
+ name?: string;
7
+ /** New product status. Defaults to `draft`. */
8
+ status?: Product["status"];
9
+ /** New product visibility. Defaults to the source's. */
10
+ visibility?: Product["visibility"];
11
+ /**
12
+ * Copy availability slots/rules/start-times + departure price overrides.
13
+ * Defaults to `true` (the operator UI clones a full working copy). The Max AI
14
+ * agent passes `false` per #1493 — departures are date-specific, so the clone
15
+ * starts with none and the agent adds fresh ones.
16
+ */
17
+ copyDepartures?: boolean;
18
+ /** Author of the initial `product_versions` snapshot. When omitted, no snapshot is taken. */
19
+ userId?: string;
20
+ /** Dedup key; a retried request with the same key returns the first clone. */
21
+ idempotencyKey?: string;
22
+ }
23
+ export interface ClonedOption {
24
+ id: string;
25
+ units: {
26
+ id: string;
27
+ }[];
28
+ }
29
+ export type CloneProductOutcome = {
30
+ status: "ok";
31
+ product: Product;
32
+ options: ClonedOption[];
33
+ reused: boolean;
34
+ } | {
35
+ status: "not_found";
36
+ };
37
+ /**
38
+ * Deep-clone a product (#1493). Wraps {@link cloneGraph} with idempotency and an
39
+ * optional `product_versions` snapshot. The operator UI calls this with no
40
+ * overrides (full copy, `"{X} (Copy)"`, departures included); the agent passes
41
+ * `name` + `copyDepartures: false` + an `Idempotency-Key`.
42
+ */
43
+ export declare function cloneProduct(db: PostgresJsDatabase, sourceProductId: string, options?: CloneProductOptions): Promise<CloneProductOutcome>;
44
+ export {};
45
+ //# sourceMappingURL=clone.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"clone.d.ts","sourceRoot":"","sources":["../src/clone.ts"],"names":[],"mappings":"AACA,OAAO,EAA+B,QAAQ,EAAE,MAAM,2BAA2B,CAAA;AAEjF,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAKjE,KAAK,OAAO,GAAG,OAAO,QAAQ,CAAC,YAAY,CAAA;AAE3C,MAAM,WAAW,mBAAmB;IAClC,2FAA2F;IAC3F,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,+CAA+C;IAC/C,MAAM,CAAC,EAAE,OAAO,CAAC,QAAQ,CAAC,CAAA;IAC1B,wDAAwD;IACxD,UAAU,CAAC,EAAE,OAAO,CAAC,YAAY,CAAC,CAAA;IAClC;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB,6FAA6F;IAC7F,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,8EAA8E;IAC9E,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,EAAE,EAAE,MAAM,CAAA;IACV,KAAK,EAAE;QAAE,EAAE,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CACxB;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,YAAY,EAAE,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAC5E;IAAE,MAAM,EAAE,WAAW,CAAA;CAAE,CAAA;AA+G3B;;;;;GAKG;AACH,wBAAsB,YAAY,CAChC,EAAE,EAAE,kBAAkB,EACtB,eAAe,EAAE,MAAM,EACvB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,mBAAmB,CAAC,CAoD9B"}
package/dist/clone.js ADDED
@@ -0,0 +1,141 @@
1
+ import { productsService } from "@voyantjs/products";
2
+ import { optionUnits, productOptions, products } from "@voyantjs/products/schema";
3
+ import { eq, inArray, sql } from "drizzle-orm";
4
+ import { copyProductContent, withoutSystemColumns } from "./clone-content.js";
5
+ import { copyPricingAndAvailability } from "./clone-pricing.js";
6
+ import { productAuthoringRequests } from "./schema.js";
7
+ function copyName(name) {
8
+ return `${name} (Copy)`;
9
+ }
10
+ /**
11
+ * Reads a product's options + units back into the {@link ClonedOption} shape.
12
+ * Used to reconstruct the result for an idempotent retry, so a re-sent clone
13
+ * returns the same option/unit ids a fresh response would (the agent needs them
14
+ * to continue authoring after the exact lost-response case idempotency covers).
15
+ */
16
+ async function loadClonedOptions(tx, productId) {
17
+ const optionRows = await tx
18
+ .select({ id: productOptions.id })
19
+ .from(productOptions)
20
+ .where(eq(productOptions.productId, productId));
21
+ if (optionRows.length === 0)
22
+ return [];
23
+ const unitRows = await tx
24
+ .select({ id: optionUnits.id, optionId: optionUnits.optionId })
25
+ .from(optionUnits)
26
+ .where(inArray(optionUnits.optionId, optionRows.map((o) => o.id)));
27
+ return optionRows.map((o) => ({
28
+ id: o.id,
29
+ units: unitRows.filter((u) => u.optionId === o.id).map((u) => ({ id: u.id })),
30
+ }));
31
+ }
32
+ function newContext(tx, sourceId, targetId, copyDepartures) {
33
+ return {
34
+ tx,
35
+ sourceId,
36
+ targetId,
37
+ copyDepartures,
38
+ optionIdMap: new Map(),
39
+ unitIdMap: new Map(),
40
+ unitsByNewOption: new Map(),
41
+ itineraryIdMap: new Map(),
42
+ dayIdMap: new Map(),
43
+ startTimeIdMap: new Map(),
44
+ ruleIdMap: new Map(),
45
+ slotIdMap: new Map(),
46
+ optionPriceRuleIdMap: new Map(),
47
+ optionUnitPriceRuleIdMap: new Map(),
48
+ pricingCategoryIdMap: new Map(),
49
+ productExtraIdMap: new Map(),
50
+ optionExtraConfigIdMap: new Map(),
51
+ };
52
+ }
53
+ /**
54
+ * Deep-clone a product graph as a draft (#1493): the new product row, then its
55
+ * content (options/units, pricing categories, itinerary, media, extras) and its
56
+ * pricing/availability — copied with correct id remapping. Availability is
57
+ * copied only when `copyDepartures`. MUST run inside the caller's transaction.
58
+ */
59
+ async function cloneGraph(tx, sourceId, opts) {
60
+ const [sourceProduct] = await tx.select().from(products).where(eq(products.id, sourceId));
61
+ if (!sourceProduct)
62
+ return null;
63
+ const [targetProduct] = await tx
64
+ .insert(products)
65
+ .values({
66
+ ...withoutSystemColumns(sourceProduct),
67
+ name: opts.name ?? copyName(sourceProduct.name),
68
+ status: opts.status ?? "draft",
69
+ ...(opts.visibility ? { visibility: opts.visibility } : {}),
70
+ activated: false,
71
+ })
72
+ .returning();
73
+ if (!targetProduct) {
74
+ throw new Error("Failed to duplicate product");
75
+ }
76
+ const ctx = newContext(tx, sourceId, targetProduct.id, opts.copyDepartures);
77
+ await copyProductContent(ctx);
78
+ await copyPricingAndAvailability(ctx);
79
+ const options = [...ctx.optionIdMap.values()].map((id) => ({
80
+ id,
81
+ units: ctx.unitsByNewOption.get(id) ?? [],
82
+ }));
83
+ return { product: targetProduct, options };
84
+ }
85
+ /**
86
+ * Deep-clone a product (#1493). Wraps {@link cloneGraph} with idempotency and an
87
+ * optional `product_versions` snapshot. The operator UI calls this with no
88
+ * overrides (full copy, `"{X} (Copy)"`, departures included); the agent passes
89
+ * `name` + `copyDepartures: false` + an `Idempotency-Key`.
90
+ */
91
+ export async function cloneProduct(db, sourceProductId, options = {}) {
92
+ const [exists] = await db
93
+ .select({ id: products.id })
94
+ .from(products)
95
+ .where(eq(products.id, sourceProductId))
96
+ .limit(1);
97
+ if (!exists)
98
+ return { status: "not_found" };
99
+ const copyDepartures = options.copyDepartures ?? true;
100
+ const key = options.idempotencyKey;
101
+ const result = await db.transaction(async (tx) => {
102
+ if (key) {
103
+ await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
104
+ const [prev] = await tx
105
+ .select({ productId: productAuthoringRequests.productId })
106
+ .from(productAuthoringRequests)
107
+ .where(eq(productAuthoringRequests.idempotencyKey, key))
108
+ .limit(1);
109
+ if (prev) {
110
+ const [product] = await tx
111
+ .select()
112
+ .from(products)
113
+ .where(eq(products.id, prev.productId))
114
+ .limit(1);
115
+ if (product) {
116
+ return { product, options: await loadClonedOptions(tx, product.id), reused: true };
117
+ }
118
+ }
119
+ }
120
+ const cloned = await cloneGraph(tx, sourceProductId, {
121
+ name: options.name,
122
+ status: options.status,
123
+ visibility: options.visibility,
124
+ copyDepartures,
125
+ });
126
+ if (!cloned)
127
+ return null;
128
+ if (options.userId) {
129
+ await productsService.createVersion(tx, cloned.product.id, options.userId, {});
130
+ }
131
+ if (key) {
132
+ await tx
133
+ .insert(productAuthoringRequests)
134
+ .values({ idempotencyKey: key, productId: cloned.product.id, operation: "duplicate" });
135
+ }
136
+ return { ...cloned, reused: false };
137
+ });
138
+ if (!result)
139
+ return { status: "not_found" };
140
+ return { status: "ok", ...result };
141
+ }
@@ -3,18 +3,17 @@ import type { HonoExtension } from "@voyantjs/hono/module";
3
3
  import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
4
4
  /**
5
5
  * Catalog authoring rides on the `products` admin prefix as a HonoExtension, so
6
- * its route lands at `/v1/admin/products/...` without `packages/products`
6
+ * its routes land at `/v1/admin/products/...` without `packages/products`
7
7
  * depending on this package (which would cycle, since this package depends on
8
8
  * both products and pricing). Same mechanism as `bookingsSupplierExtension`.
9
9
  *
10
- * POST /v1/admin/products/composebuild a new product graph from a spec (#1495)
10
+ * POST /v1/admin/products/{id}/duplicatedeep-clone a product graph (#1493)
11
+ * POST /v1/admin/products/compose — build a new product graph from a spec (#1495)
11
12
  *
12
- * NOTE: deep-cloning an existing product (#1493) is deliberately NOT exposed
13
- * here. The operator template already serves a comprehensive deep-clone at
14
- * `POST /v1/admin/products/{id}/duplicate` (`duplicateProductAsDraft`); adding a
15
- * second handler at that path would shadow it. Composing a NEW graph is the
16
- * genuinely new, non-overlapping capability — it covers the cold-start /
17
- * never-authored-before case that clone cannot.
13
+ * The duplicate route is the canonical product clone (it replaces the operator
14
+ * template's previous local `duplicateProductAsDraft` route). No body a full
15
+ * copy named `"{X} (Copy)"` with departures (preserves the UI). The agent passes
16
+ * `{ name, copyDepartures: false }` + an `Idempotency-Key`.
18
17
  */
19
18
  type Env = {
20
19
  Variables: {
@@ -57,7 +56,82 @@ export declare const catalogAuthoringRoutes: import("hono/hono-base").HonoBase<E
57
56
  status: 200 | 201;
58
57
  };
59
58
  };
60
- }, "/", "/compose">;
59
+ } & {
60
+ "/:id/duplicate": {
61
+ $post: {
62
+ input: {
63
+ param: {
64
+ id: string;
65
+ };
66
+ };
67
+ output: {
68
+ error: string;
69
+ };
70
+ outputFormat: "json";
71
+ status: 400;
72
+ } | {
73
+ input: {
74
+ param: {
75
+ id: string;
76
+ };
77
+ };
78
+ output: {
79
+ error: string;
80
+ };
81
+ outputFormat: "json";
82
+ status: 404;
83
+ } | {
84
+ input: {
85
+ param: {
86
+ id: string;
87
+ };
88
+ };
89
+ output: {
90
+ data: {
91
+ id: string;
92
+ name: string;
93
+ createdAt: string;
94
+ updatedAt: string;
95
+ timezone: string | null;
96
+ sellAmountCents: number | null;
97
+ costAmountCents: number | null;
98
+ description: string | null;
99
+ facilityId: string | null;
100
+ status: "active" | "draft" | "archived";
101
+ startDate: string | null;
102
+ endDate: string | null;
103
+ inclusionsHtml: string | null;
104
+ exclusionsHtml: string | null;
105
+ termsHtml: string | null;
106
+ termsShowOnContract: boolean;
107
+ bookingMode: "other" | "date" | "date_time" | "open" | "stay" | "transfer" | "itinerary";
108
+ capacityMode: "on_request" | "free_sale" | "limited";
109
+ defaultLanguageTag: string | null;
110
+ visibility: "public" | "private" | "hidden";
111
+ activated: boolean;
112
+ reservationTimeoutMinutes: number | null;
113
+ sellCurrency: string;
114
+ marginPercent: number | null;
115
+ supplierId: string | null;
116
+ pax: number | null;
117
+ productTypeId: string | null;
118
+ contractTemplateId: string | null;
119
+ taxClassId: string | null;
120
+ customerPaymentPolicy: import("hono/utils/types").JSONValue;
121
+ tags: string[] | null;
122
+ };
123
+ options: {
124
+ id: string;
125
+ units: {
126
+ id: string;
127
+ }[];
128
+ }[];
129
+ };
130
+ outputFormat: "json";
131
+ status: 200 | 201;
132
+ };
133
+ };
134
+ }, "/", "/:id/duplicate">;
61
135
  export declare const catalogAuthoringExtension: HonoExtension;
62
136
  export {};
63
137
  //# sourceMappingURL=extension.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAa,MAAM,gBAAgB,CAAA;AAEzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAE1D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAOjE;;;;;;;;;;;;;;GAcG;AAEH,KAAK,GAAG,GAAG;IACT,SAAS,EAAE;QACT,EAAE,EAAE,kBAAkB,CAAA;QACtB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,QAAQ,CAAC,EAAE,QAAQ,CAAA;KACpB,CAAA;CACF,CAAA;AAuCD,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mBAmBjC,CAAA;AAOF,eAAO,MAAM,yBAAyB,EAAE,aAGvC,CAAA"}
1
+ {"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAa,MAAM,gBAAgB,CAAA;AAEzD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAO1D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAQjE;;;;;;;;;;;;;GAaG;AAEH,KAAK,GAAG,GAAG;IACT,SAAS,EAAE;QACT,EAAE,EAAE,kBAAkB,CAAA;QACtB,MAAM,CAAC,EAAE,MAAM,CAAA;QACf,QAAQ,CAAC,EAAE,QAAQ,CAAA;KACpB,CAAA;CACF,CAAA;AAmDD,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;yBA6D/B,CAAA;AAOJ,eAAO,MAAM,yBAAyB,EAAE,aAGvC,CAAA"}
package/dist/extension.js CHANGED
@@ -1,37 +1,48 @@
1
1
  import { parseJsonBody } from "@voyantjs/hono";
2
- import { appendProductMutationLedgerEntry, emitProductContentChanged } from "@voyantjs/products";
2
+ import { appendProductMutationLedgerEntry, emitProductContentChanged, } from "@voyantjs/products";
3
+ import { productStatusEnum, productVisibilityEnum } from "@voyantjs/products/schema";
3
4
  import { Hono } from "hono";
4
5
  import { z } from "zod";
6
+ import { cloneProduct } from "./clone.js";
5
7
  import { composeProduct } from "./service.js";
6
8
  import { productGraphSpecSchema } from "./spec.js";
7
9
  const composeBodySchema = z.object({
8
10
  spec: productGraphSpecSchema,
9
11
  idempotencyKey: z.string().min(1).max(255).optional(),
10
12
  });
13
+ const duplicateBodySchema = z.object({
14
+ name: z.string().min(1).max(255).optional(),
15
+ status: z.enum(productStatusEnum.enumValues).optional(),
16
+ visibility: z.enum(productVisibilityEnum.enumValues).optional(),
17
+ copyDepartures: z.boolean().optional(),
18
+ idempotencyKey: z.string().min(1).max(255).optional(),
19
+ });
11
20
  /** Header takes precedence over a body-supplied key. */
12
21
  function idempotencyKey(c, bodyKey) {
13
22
  return c.req.header("Idempotency-Key") ?? bodyKey;
14
23
  }
15
24
  /**
16
25
  * Records the same action-ledger entry + `product.content.changed` event the
17
- * granular product routes emit, so a composed product is indexed and audited
26
+ * granular product routes emit, so an authored product is indexed and audited
18
27
  * like any other create. Only called for freshly built products (a reused
19
28
  * idempotent response created nothing new).
20
29
  */
21
- async function recordComposed(
22
- // biome-ignore lint/suspicious/noExplicitAny: bridges this extension's Env to products' ledger Context<Env> (#1495); cast to LedgerContext below
23
- c, productId) {
30
+ async function recordAuthoring(
31
+ // biome-ignore lint/suspicious/noExplicitAny: bridges this extension's Env to products' ledger Context<Env> (#1493/#1495); cast to LedgerContext below
32
+ c, action, productId) {
33
+ const verb = action === "duplicate" ? "duplicate" : "compose";
24
34
  await appendProductMutationLedgerEntry(c, {
25
- action: "create",
35
+ action,
26
36
  productId,
27
37
  changedFields: [],
28
38
  subject: "product",
29
- actionName: "product.compose",
30
- routeOrToolName: "products.compose",
39
+ actionName: `product.${verb}`,
40
+ routeOrToolName: `products.${verb}`,
31
41
  });
32
42
  await emitProductContentChanged(c.get("eventBus"), { id: productId, axis: "product" });
33
43
  }
34
- export const catalogAuthoringRoutes = new Hono().post("/compose", async (c) => {
44
+ export const catalogAuthoringRoutes = new Hono()
45
+ .post("/compose", async (c) => {
35
46
  const body = await parseJsonBody(c, composeBodySchema);
36
47
  const outcome = await composeProduct(c.get("db"), body.spec, {
37
48
  userId: c.get("userId"),
@@ -41,9 +52,45 @@ export const catalogAuthoringRoutes = new Hono().post("/compose", async (c) => {
41
52
  return c.json({ error: "invalid_product_graph", issues: outcome.issues }, 422);
42
53
  }
43
54
  if (!outcome.reused) {
44
- await recordComposed(c, outcome.result.productId);
55
+ await recordAuthoring(c, "create", outcome.result.productId);
45
56
  }
46
57
  return c.json({ data: { id: outcome.result.productId, options: outcome.result.options } }, outcome.reused ? 200 : 201);
58
+ })
59
+ .post("/:id/duplicate", async (c) => {
60
+ // The UI clones with no body; the agent sends overrides. Tolerate both.
61
+ const raw = (await c.req.text()).trim();
62
+ let body = {};
63
+ if (raw.length > 0) {
64
+ let json;
65
+ try {
66
+ json = JSON.parse(raw);
67
+ }
68
+ catch {
69
+ return c.json({ error: "Invalid JSON body" }, 400);
70
+ }
71
+ const parsed = duplicateBodySchema.safeParse(json);
72
+ if (!parsed.success) {
73
+ return c.json({ error: "Invalid body", issues: parsed.error.issues }, 400);
74
+ }
75
+ body = parsed.data;
76
+ }
77
+ const outcome = await cloneProduct(c.get("db"), c.req.param("id"), {
78
+ name: body.name,
79
+ status: body.status,
80
+ visibility: body.visibility,
81
+ copyDepartures: body.copyDepartures,
82
+ userId: c.get("userId"),
83
+ idempotencyKey: idempotencyKey(c, body.idempotencyKey),
84
+ });
85
+ if (outcome.status === "not_found") {
86
+ return c.json({ error: "Product not found" }, 404);
87
+ }
88
+ if (!outcome.reused) {
89
+ await recordAuthoring(c, "duplicate", outcome.product.id);
90
+ }
91
+ // `data` stays the full product row (the UI reads `data.id`); `options`
92
+ // carries the cloned option/unit ids for agent follow-up calls.
93
+ return c.json({ data: outcome.product, options: outcome.options }, outcome.reused ? 200 : 201);
47
94
  });
48
95
  const catalogAuthoringExtensionDef = {
49
96
  name: "catalog-authoring",
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Module } from "@voyantjs/core";
2
2
  export declare const catalogAuthoringModule: Module;
3
3
  export { type BuildProductGraphOptions, type BuildProductGraphResult, buildProductGraph, } from "./builder.js";
4
+ export { type ClonedOption, type CloneProductOptions, type CloneProductOutcome, cloneProduct, } from "./clone.js";
4
5
  export { type AuthoringIssue, AuthoringValidationError } from "./errors.js";
5
6
  export { catalogAuthoringExtension, catalogAuthoringRoutes } from "./extension.js";
6
7
  export type { NewProductAuthoringRequest, ProductAuthoringRequest } from "./schema.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAE5C,eAAO,MAAM,sBAAsB,EAAE,MAEpC,CAAA;AAED,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,iBAAiB,GAClB,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,KAAK,cAAc,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAC3E,OAAO,EAAE,yBAAyB,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAA;AAClF,YAAY,EAAE,0BAA0B,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AACtF,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AACtD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,cAAc,GACf,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,KAAK,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAA;AACzE,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,gBAAgB,CAAA;AAE5C,eAAO,MAAM,sBAAsB,EAAE,MAEpC,CAAA;AAED,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,uBAAuB,EAC5B,iBAAiB,GAClB,MAAM,cAAc,CAAA;AACrB,OAAO,EACL,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,mBAAmB,EACxB,YAAY,GACb,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,KAAK,cAAc,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AAC3E,OAAO,EAAE,yBAAyB,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAA;AAClF,YAAY,EAAE,0BAA0B,EAAE,uBAAuB,EAAE,MAAM,aAAa,CAAA;AACtF,OAAO,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAA;AACtD,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,cAAc,GACf,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,KAAK,gBAAgB,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAA;AACzE,OAAO,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAA"}
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@ export const catalogAuthoringModule = {
2
2
  name: "catalog-authoring",
3
3
  };
4
4
  export { buildProductGraph, } from "./builder.js";
5
+ export { cloneProduct, } from "./clone.js";
5
6
  export { AuthoringValidationError } from "./errors.js";
6
7
  export { catalogAuthoringExtension, catalogAuthoringRoutes } from "./extension.js";
7
8
  export { productAuthoringRequests } from "./schema.js";
package/dist/service.d.ts CHANGED
@@ -21,9 +21,8 @@ export type ComposeProductOutcome = {
21
21
  * caller can self-correct. Rules without a catalog fall back to the operator
22
22
  * default.
23
23
  *
24
- * Cloning an existing product is intentionally NOT handled here: the operator
25
- * template already ships a comprehensive deep-clone (`duplicateProductAsDraft`)
26
- * at `POST /v1/admin/products/{id}/duplicate`. See #1493.
24
+ * Cloning an existing product lives in `clone.ts` (`cloneProduct`); this module
25
+ * is the from-scratch path. See #1493 / #1495.
27
26
  */
28
27
  export declare function composeProduct(db: PostgresJsDatabase, spec: ProductGraphSpec, options?: AuthoringRunOptions): Promise<ComposeProductOutcome>;
29
28
  //# sourceMappingURL=service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,EAAE,KAAK,uBAAuB,EAAqB,MAAM,cAAc,CAAA;AAC9E,OAAO,EAAE,KAAK,cAAc,EAA4B,MAAM,aAAa,CAAA;AAE3E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA;AAGjD,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAClE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,cAAc,EAAE,CAAA;CAAE,CAAA;AAgDnD;;;;;;;;;GASG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,gBAAgB,EACtB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,qBAAqB,CAAC,CAmBhC"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,EAAE,KAAK,uBAAuB,EAAqB,MAAM,cAAc,CAAA;AAC9E,OAAO,EAAE,KAAK,cAAc,EAA4B,MAAM,aAAa,CAAA;AAE3E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,WAAW,CAAA;AAGjD,MAAM,WAAW,mBAAmB;IAClC,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,+EAA+E;IAC/E,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAED,MAAM,MAAM,qBAAqB,GAC7B;IAAE,MAAM,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAC;IAAC,MAAM,EAAE,OAAO,CAAA;CAAE,GAClE;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,cAAc,EAAE,CAAA;CAAE,CAAA;AAgDnD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAClC,EAAE,EAAE,kBAAkB,EACtB,IAAI,EAAE,gBAAgB,EACtB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,qBAAqB,CAAC,CAmBhC"}
package/dist/service.js CHANGED
@@ -44,9 +44,8 @@ async function withIdempotency(tx, key, operation, build) {
44
44
  * caller can self-correct. Rules without a catalog fall back to the operator
45
45
  * default.
46
46
  *
47
- * Cloning an existing product is intentionally NOT handled here: the operator
48
- * template already ships a comprehensive deep-clone (`duplicateProductAsDraft`)
49
- * at `POST /v1/admin/products/{id}/duplicate`. See #1493.
47
+ * Cloning an existing product lives in `clone.ts` (`cloneProduct`); this module
48
+ * is the from-scratch path. See #1493 / #1495.
50
49
  */
51
50
  export async function composeProduct(db, spec, options = {}) {
52
51
  const issues = validateProductGraph(spec);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voyantjs/catalog-authoring",
3
- "version": "0.105.0",
3
+ "version": "0.106.1",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "exports": {
@@ -29,11 +29,13 @@
29
29
  "drizzle-orm": "^0.45.2",
30
30
  "hono": "^4.12.10",
31
31
  "zod": "^4.3.6",
32
+ "@voyantjs/availability": "^0.104.1",
32
33
  "@voyantjs/core": "^0.104.1",
33
34
  "@voyantjs/db": "^0.104.1",
35
+ "@voyantjs/extras": "^0.105.0",
34
36
  "@voyantjs/hono": "^0.104.1",
35
- "@voyantjs/pricing": "^0.104.1",
36
- "@voyantjs/products": "^0.104.3"
37
+ "@voyantjs/pricing": "^0.105.0",
38
+ "@voyantjs/products": "^0.105.0"
37
39
  },
38
40
  "devDependencies": {
39
41
  "typescript": "^6.0.2",
@@ -56,7 +58,9 @@
56
58
  "requiresSchemas": [
57
59
  "@voyantjs/db",
58
60
  "@voyantjs/products",
59
- "@voyantjs/pricing"
61
+ "@voyantjs/pricing",
62
+ "@voyantjs/availability",
63
+ "@voyantjs/extras"
60
64
  ]
61
65
  },
62
66
  "scripts": {