@voyantjs/catalog-authoring 0.104.1 → 0.106.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.
- package/LICENSE +201 -0
- package/dist/builder.d.ts +37 -0
- package/dist/builder.d.ts.map +1 -0
- package/dist/builder.js +248 -0
- package/dist/clone-content.d.ts +38 -0
- package/dist/clone-content.d.ts.map +1 -0
- package/dist/clone-content.js +367 -0
- package/dist/clone-pricing.d.ts +9 -0
- package/dist/clone-pricing.d.ts.map +1 -0
- package/dist/clone-pricing.js +242 -0
- package/dist/clone.d.ts +45 -0
- package/dist/clone.d.ts.map +1 -0
- package/dist/clone.js +141 -0
- package/dist/errors.d.ts +21 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/extension.d.ts +95 -8
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +92 -2
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/service.d.ts +28 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +65 -0
- package/dist/spec.d.ts +50 -32
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js +20 -15
- package/dist/validate.d.ts +17 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +83 -0
- package/package.json +47 -50
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
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single, agent-recoverable problem. The shape is deliberately verbose so an
|
|
3
|
+
* LLM tool runtime can fix the spec and retry without a human: `field` says
|
|
4
|
+
* what to change, `message` says why, `fix` says how.
|
|
5
|
+
*/
|
|
6
|
+
export interface AuthoringIssue {
|
|
7
|
+
code: string;
|
|
8
|
+
field?: string;
|
|
9
|
+
message: string;
|
|
10
|
+
fix?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Thrown by the validator (and the builder, for structural problems it can only
|
|
14
|
+
* detect mid-build, e.g. an unresolved catalog). Routes translate this to a 422
|
|
15
|
+
* with `{ errors }` so the caller can self-correct.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AuthoringValidationError extends Error {
|
|
18
|
+
readonly issues: AuthoringIssue[];
|
|
19
|
+
constructor(issues: AuthoringIssue[]);
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED;;;;GAIG;AACH,qBAAa,wBAAyB,SAAQ,KAAK;IACjD,QAAQ,CAAC,MAAM,EAAE,cAAc,EAAE,CAAA;gBAErB,MAAM,EAAE,cAAc,EAAE;CAKrC"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Thrown by the validator (and the builder, for structural problems it can only
|
|
3
|
+
* detect mid-build, e.g. an unresolved catalog). Routes translate this to a 422
|
|
4
|
+
* with `{ errors }` so the caller can self-correct.
|
|
5
|
+
*/
|
|
6
|
+
export class AuthoringValidationError extends Error {
|
|
7
|
+
issues;
|
|
8
|
+
constructor(issues) {
|
|
9
|
+
super(issues.map((i) => i.message).join("; ") || "Invalid product graph");
|
|
10
|
+
this.name = "AuthoringValidationError";
|
|
11
|
+
this.issues = issues;
|
|
12
|
+
}
|
|
13
|
+
}
|
package/dist/extension.d.ts
CHANGED
|
@@ -1,21 +1,25 @@
|
|
|
1
|
+
import type { EventBus } from "@voyantjs/core";
|
|
1
2
|
import type { HonoExtension } from "@voyantjs/hono/module";
|
|
2
3
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
4
|
/**
|
|
4
|
-
* Catalog authoring rides on the `products` admin prefix as a HonoExtension,
|
|
5
|
-
*
|
|
5
|
+
* Catalog authoring rides on the `products` admin prefix as a HonoExtension, so
|
|
6
|
+
* its routes land at `/v1/admin/products/...` without `packages/products`
|
|
6
7
|
* depending on this package (which would cycle, since this package depends on
|
|
7
8
|
* both products and pricing). Same mechanism as `bookingsSupplierExtension`.
|
|
8
9
|
*
|
|
9
|
-
* POST /v1/admin/products/{id}/duplicate
|
|
10
|
-
* POST /v1/admin/products/compose
|
|
10
|
+
* POST /v1/admin/products/{id}/duplicate — deep-clone a product graph (#1493)
|
|
11
|
+
* POST /v1/admin/products/compose — build a new product graph from a spec (#1495)
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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`.
|
|
14
17
|
*/
|
|
15
18
|
type Env = {
|
|
16
19
|
Variables: {
|
|
17
20
|
db: PostgresJsDatabase;
|
|
18
21
|
userId?: string;
|
|
22
|
+
eventBus?: EventBus;
|
|
19
23
|
};
|
|
20
24
|
};
|
|
21
25
|
export declare const catalogAuthoringRoutes: import("hono/hono-base").HonoBase<Env, {
|
|
@@ -24,9 +28,32 @@ export declare const catalogAuthoringRoutes: import("hono/hono-base").HonoBase<E
|
|
|
24
28
|
input: {};
|
|
25
29
|
output: {
|
|
26
30
|
error: string;
|
|
31
|
+
issues: {
|
|
32
|
+
code: string;
|
|
33
|
+
field?: string | undefined;
|
|
34
|
+
message: string;
|
|
35
|
+
fix?: string | undefined;
|
|
36
|
+
}[];
|
|
27
37
|
};
|
|
28
38
|
outputFormat: "json";
|
|
29
|
-
status:
|
|
39
|
+
status: 422;
|
|
40
|
+
} | {
|
|
41
|
+
input: {};
|
|
42
|
+
output: {
|
|
43
|
+
data: {
|
|
44
|
+
id: string;
|
|
45
|
+
options: {
|
|
46
|
+
ref: string;
|
|
47
|
+
id: string;
|
|
48
|
+
units: {
|
|
49
|
+
ref: string;
|
|
50
|
+
id: string;
|
|
51
|
+
}[];
|
|
52
|
+
}[];
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
outputFormat: "json";
|
|
56
|
+
status: 200 | 201;
|
|
30
57
|
};
|
|
31
58
|
};
|
|
32
59
|
} & {
|
|
@@ -41,7 +68,67 @@ export declare const catalogAuthoringRoutes: import("hono/hono-base").HonoBase<E
|
|
|
41
68
|
error: string;
|
|
42
69
|
};
|
|
43
70
|
outputFormat: "json";
|
|
44
|
-
status:
|
|
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;
|
|
45
132
|
};
|
|
46
133
|
};
|
|
47
134
|
}, "/", "/:id/duplicate">;
|
package/dist/extension.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"
|
|
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,7 +1,97 @@
|
|
|
1
|
+
import { parseJsonBody } from "@voyantjs/hono";
|
|
2
|
+
import { appendProductMutationLedgerEntry, emitProductContentChanged, } from "@voyantjs/products";
|
|
3
|
+
import { productStatusEnum, productVisibilityEnum } from "@voyantjs/products/schema";
|
|
1
4
|
import { Hono } from "hono";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { cloneProduct } from "./clone.js";
|
|
7
|
+
import { composeProduct } from "./service.js";
|
|
8
|
+
import { productGraphSpecSchema } from "./spec.js";
|
|
9
|
+
const composeBodySchema = z.object({
|
|
10
|
+
spec: productGraphSpecSchema,
|
|
11
|
+
idempotencyKey: z.string().min(1).max(255).optional(),
|
|
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
|
+
});
|
|
20
|
+
/** Header takes precedence over a body-supplied key. */
|
|
21
|
+
function idempotencyKey(c, bodyKey) {
|
|
22
|
+
return c.req.header("Idempotency-Key") ?? bodyKey;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Records the same action-ledger entry + `product.content.changed` event the
|
|
26
|
+
* granular product routes emit, so an authored product is indexed and audited
|
|
27
|
+
* like any other create. Only called for freshly built products (a reused
|
|
28
|
+
* idempotent response created nothing new).
|
|
29
|
+
*/
|
|
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";
|
|
34
|
+
await appendProductMutationLedgerEntry(c, {
|
|
35
|
+
action,
|
|
36
|
+
productId,
|
|
37
|
+
changedFields: [],
|
|
38
|
+
subject: "product",
|
|
39
|
+
actionName: `product.${verb}`,
|
|
40
|
+
routeOrToolName: `products.${verb}`,
|
|
41
|
+
});
|
|
42
|
+
await emitProductContentChanged(c.get("eventBus"), { id: productId, axis: "product" });
|
|
43
|
+
}
|
|
2
44
|
export const catalogAuthoringRoutes = new Hono()
|
|
3
|
-
.post("/compose", (c) =>
|
|
4
|
-
|
|
45
|
+
.post("/compose", async (c) => {
|
|
46
|
+
const body = await parseJsonBody(c, composeBodySchema);
|
|
47
|
+
const outcome = await composeProduct(c.get("db"), body.spec, {
|
|
48
|
+
userId: c.get("userId"),
|
|
49
|
+
idempotencyKey: idempotencyKey(c, body.idempotencyKey),
|
|
50
|
+
});
|
|
51
|
+
if (outcome.status === "invalid") {
|
|
52
|
+
return c.json({ error: "invalid_product_graph", issues: outcome.issues }, 422);
|
|
53
|
+
}
|
|
54
|
+
if (!outcome.reused) {
|
|
55
|
+
await recordAuthoring(c, "create", outcome.result.productId);
|
|
56
|
+
}
|
|
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);
|
|
94
|
+
});
|
|
5
95
|
const catalogAuthoringExtensionDef = {
|
|
6
96
|
name: "catalog-authoring",
|
|
7
97
|
module: "products",
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { Module } from "@voyantjs/core";
|
|
2
2
|
export declare const catalogAuthoringModule: Module;
|
|
3
|
+
export { type BuildProductGraphOptions, type BuildProductGraphResult, buildProductGraph, } from "./builder.js";
|
|
4
|
+
export { type ClonedOption, type CloneProductOptions, type CloneProductOutcome, cloneProduct, } from "./clone.js";
|
|
5
|
+
export { type AuthoringIssue, AuthoringValidationError } from "./errors.js";
|
|
3
6
|
export { catalogAuthoringExtension, catalogAuthoringRoutes } from "./extension.js";
|
|
4
7
|
export type { NewProductAuthoringRequest, ProductAuthoringRequest } from "./schema.js";
|
|
5
8
|
export { productAuthoringRequests } from "./schema.js";
|
|
6
|
-
export { type
|
|
9
|
+
export { type AuthoringRunOptions, type ComposeProductOutcome, composeProduct, } from "./service.js";
|
|
10
|
+
export { type ProductGraphSpec, productGraphSpecSchema } from "./spec.js";
|
|
11
|
+
export { validateProductGraph } from "./validate.js";
|
|
7
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,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,gBAAgB,
|
|
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
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export const catalogAuthoringModule = {
|
|
2
2
|
name: "catalog-authoring",
|
|
3
3
|
};
|
|
4
|
+
export { buildProductGraph, } from "./builder.js";
|
|
5
|
+
export { cloneProduct, } from "./clone.js";
|
|
6
|
+
export { AuthoringValidationError } from "./errors.js";
|
|
4
7
|
export { catalogAuthoringExtension, catalogAuthoringRoutes } from "./extension.js";
|
|
5
8
|
export { productAuthoringRequests } from "./schema.js";
|
|
6
|
-
export {
|
|
9
|
+
export { composeProduct, } from "./service.js";
|
|
10
|
+
export { productGraphSpecSchema } from "./spec.js";
|
|
11
|
+
export { validateProductGraph } from "./validate.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import { type BuildProductGraphResult } from "./builder.js";
|
|
3
|
+
import { type AuthoringIssue } from "./errors.js";
|
|
4
|
+
import type { ProductGraphSpec } from "./spec.js";
|
|
5
|
+
export interface AuthoringRunOptions {
|
|
6
|
+
userId?: string;
|
|
7
|
+
/** Dedup key; a retried request with the same key returns the first result. */
|
|
8
|
+
idempotencyKey?: string;
|
|
9
|
+
}
|
|
10
|
+
export type ComposeProductOutcome = {
|
|
11
|
+
status: "ok";
|
|
12
|
+
result: BuildProductGraphResult;
|
|
13
|
+
reused: boolean;
|
|
14
|
+
} | {
|
|
15
|
+
status: "invalid";
|
|
16
|
+
issues: AuthoringIssue[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Compose a brand-new product graph from a caller-supplied spec (#1495). Runs the
|
|
20
|
+
* category validator first; an invalid spec is returned (never built) so the
|
|
21
|
+
* caller can self-correct. Rules without a catalog fall back to the operator
|
|
22
|
+
* default.
|
|
23
|
+
*
|
|
24
|
+
* Cloning an existing product lives in `clone.ts` (`cloneProduct`); this module
|
|
25
|
+
* is the from-scratch path. See #1493 / #1495.
|
|
26
|
+
*/
|
|
27
|
+
export declare function composeProduct(db: PostgresJsDatabase, spec: ProductGraphSpec, options?: AuthoringRunOptions): Promise<ComposeProductOutcome>;
|
|
28
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +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;;;;;;;;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
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { priceCatalogs } from "@voyantjs/pricing/schema";
|
|
2
|
+
import { and, eq, sql } from "drizzle-orm";
|
|
3
|
+
import { buildProductGraph } from "./builder.js";
|
|
4
|
+
import { AuthoringValidationError } from "./errors.js";
|
|
5
|
+
import { productAuthoringRequests } from "./schema.js";
|
|
6
|
+
import { validateProductGraph } from "./validate.js";
|
|
7
|
+
/** Resolves the operator's default price catalog id, if one exists. */
|
|
8
|
+
async function getDefaultCatalogId(db) {
|
|
9
|
+
const [row] = await db
|
|
10
|
+
.select({ id: priceCatalogs.id })
|
|
11
|
+
.from(priceCatalogs)
|
|
12
|
+
.where(and(eq(priceCatalogs.isDefault, true), eq(priceCatalogs.active, true)))
|
|
13
|
+
.limit(1);
|
|
14
|
+
return row?.id;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Resolves an idempotency key inside an open transaction. Returns the previously
|
|
18
|
+
* created product id when the key has been seen, else runs `build`, records the
|
|
19
|
+
* key, and returns the fresh result. The advisory lock serializes concurrent
|
|
20
|
+
* requests sharing a key (the booking-create guard pattern).
|
|
21
|
+
*/
|
|
22
|
+
async function withIdempotency(tx, key, operation, build) {
|
|
23
|
+
if (!key) {
|
|
24
|
+
return { result: await build(), reused: false };
|
|
25
|
+
}
|
|
26
|
+
await tx.execute(sql `SELECT pg_advisory_xact_lock(hashtextextended(${key}, 0))`);
|
|
27
|
+
const [existing] = await tx
|
|
28
|
+
.select({ productId: productAuthoringRequests.productId })
|
|
29
|
+
.from(productAuthoringRequests)
|
|
30
|
+
.where(eq(productAuthoringRequests.idempotencyKey, key))
|
|
31
|
+
.limit(1);
|
|
32
|
+
if (existing) {
|
|
33
|
+
return { result: { productId: existing.productId, options: [] }, reused: true };
|
|
34
|
+
}
|
|
35
|
+
const result = await build();
|
|
36
|
+
await tx
|
|
37
|
+
.insert(productAuthoringRequests)
|
|
38
|
+
.values({ idempotencyKey: key, productId: result.productId, operation });
|
|
39
|
+
return { result, reused: false };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Compose a brand-new product graph from a caller-supplied spec (#1495). Runs the
|
|
43
|
+
* category validator first; an invalid spec is returned (never built) so the
|
|
44
|
+
* caller can self-correct. Rules without a catalog fall back to the operator
|
|
45
|
+
* default.
|
|
46
|
+
*
|
|
47
|
+
* Cloning an existing product lives in `clone.ts` (`cloneProduct`); this module
|
|
48
|
+
* is the from-scratch path. See #1493 / #1495.
|
|
49
|
+
*/
|
|
50
|
+
export async function composeProduct(db, spec, options = {}) {
|
|
51
|
+
const issues = validateProductGraph(spec);
|
|
52
|
+
if (issues.length)
|
|
53
|
+
return { status: "invalid", issues };
|
|
54
|
+
const defaultCatalogId = await getDefaultCatalogId(db);
|
|
55
|
+
try {
|
|
56
|
+
const { result, reused } = await db.transaction((tx) => withIdempotency(tx, options.idempotencyKey, "compose", () => buildProductGraph(tx, spec, { userId: options.userId, defaultCatalogId })));
|
|
57
|
+
return { status: "ok", result, reused };
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof AuthoringValidationError) {
|
|
61
|
+
return { status: "invalid", issues: error.issues };
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|