@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/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
+ }
@@ -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
+ }
@@ -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
- * so its routes land at `/v1/admin/products/...` without `packages/products`
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 — clone (#1493)
10
- * POST /v1/admin/products/compose compose (#1495)
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
- * Phase 0: routes are mounted but not yet implemented. The builder, clone,
13
- * validator and compose land in later phases.
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: 501;
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: 501;
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">;
@@ -1 +1 @@
1
- {"version":3,"file":"extension.d.ts","sourceRoot":"","sources":["../src/extension.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,uBAAuB,CAAA;AAC1D,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AAGjE;;;;;;;;;;;GAWG;AAEH,KAAK,GAAG,GAAG;IACT,SAAS,EAAE;QACT,EAAE,EAAE,kBAAkB,CAAA;QACtB,MAAM,CAAC,EAAE,MAAM,CAAA;KAChB,CAAA;CACF,CAAA;AAED,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;yBAEwC,CAAA;AAO3E,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,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) => c.json({ error: "not_implemented" }, 501))
4
- .post("/:id/duplicate", (c) => c.json({ error: "not_implemented" }, 501));
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 ProductGraphSpec, productGraphSpecSchema, } from "./spec.js";
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
@@ -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,EACrB,sBAAsB,GACvB,MAAM,WAAW,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
@@ -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 { productGraphSpecSchema, } from "./spec.js";
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"}
@@ -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
+ }