@voyant-travel/inventory 0.2.0 → 0.3.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.
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product brochure generation route, owned by `@voyant-travel/inventory`.
|
|
3
|
+
*
|
|
4
|
+
* POST /:id/brochure/generate — generate + store a product brochure PDF
|
|
5
|
+
*
|
|
6
|
+
* The route registers a RELATIVE path; a deployment mounts the returned `Hono`
|
|
7
|
+
* at `/v1/admin/products`. The brochure task (`generateAndStoreProductBrochure`)
|
|
8
|
+
* already lives in this package; this factory only wires it to an HTTP surface.
|
|
9
|
+
*
|
|
10
|
+
* The deployment supplies the storage-backed specifics via `options`:
|
|
11
|
+
* - `resolveStorage(c)` — the R2-backed `StorageProvider` to upload into (or
|
|
12
|
+
* `null` when storage isn't configured → 503),
|
|
13
|
+
* - `resolvePrinter(c)` — an optional PDF printer (e.g. a browser-rendering
|
|
14
|
+
* service); when omitted the default pdf-lib printer is used,
|
|
15
|
+
* - `template` / `keyPrefix` / `maxSizeBytes` overrides.
|
|
16
|
+
*
|
|
17
|
+
* Keeping these injected means inventory never imports the deployment's R2
|
|
18
|
+
* binding or cloud client.
|
|
19
|
+
*/
|
|
20
|
+
import type { StorageProvider } from "@voyant-travel/storage";
|
|
21
|
+
import { type Context, Hono } from "hono";
|
|
22
|
+
import type { Env } from "./route-env.js";
|
|
23
|
+
import { type ProductBrochurePrinter, type ProductBrochureTemplateDefinition } from "./tasks/index.js";
|
|
24
|
+
/**
|
|
25
|
+
* Deployment-supplied options for the product brochure route. Structural only —
|
|
26
|
+
* the injected functions encapsulate the deployment's storage binding and
|
|
27
|
+
* (optional) PDF renderer so this package stays free of those static imports.
|
|
28
|
+
*/
|
|
29
|
+
export interface ProductBrochureRoutesOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the storage provider to upload the generated brochure into, or
|
|
32
|
+
* `null` when storage isn't configured (the route then responds `503`).
|
|
33
|
+
*/
|
|
34
|
+
resolveStorage(c: Context): StorageProvider | null;
|
|
35
|
+
/**
|
|
36
|
+
* Resolve an optional PDF printer for this request (e.g. a browser-rendering
|
|
37
|
+
* service). When omitted, the brochure task falls back to its built-in
|
|
38
|
+
* pdf-lib printer.
|
|
39
|
+
*/
|
|
40
|
+
resolvePrinter?(c: Context): ProductBrochurePrinter | null;
|
|
41
|
+
/**
|
|
42
|
+
* The brochure template. Defaults to {@link createDefaultProductBrochureTemplate}.
|
|
43
|
+
*/
|
|
44
|
+
template?: ProductBrochureTemplateDefinition;
|
|
45
|
+
/** Storage key prefix builder. Defaults to `brochures/products/:id`. */
|
|
46
|
+
keyPrefix?(productId: string): string;
|
|
47
|
+
/** Max generated PDF size in bytes before rejecting with 413. */
|
|
48
|
+
maxSizeBytes?: number;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Build the product brochure route (relative path; mount at
|
|
52
|
+
* `/v1/admin/products`). Storage + the optional printer are injected via
|
|
53
|
+
* `options`.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createProductBrochureRoutes(options: ProductBrochureRoutesOptions): Hono<Env>;
|
|
56
|
+
//# sourceMappingURL=routes-brochure.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes-brochure.d.ts","sourceRoot":"","sources":["../src/routes-brochure.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,EAAE,KAAK,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAA;AAGzC,OAAO,KAAK,EAAE,GAAG,EAAE,MAAM,gBAAgB,CAAA;AACzC,OAAO,EAGL,KAAK,sBAAsB,EAC3B,KAAK,iCAAiC,EACvC,MAAM,kBAAkB,CAAA;AAKzB;;;;GAIG;AACH,MAAM,WAAW,4BAA4B;IAC3C;;;OAGG;IACH,cAAc,CAAC,CAAC,EAAE,OAAO,GAAG,eAAe,GAAG,IAAI,CAAA;IAClD;;;;OAIG;IACH,cAAc,CAAC,CAAC,CAAC,EAAE,OAAO,GAAG,sBAAsB,GAAG,IAAI,CAAA;IAC1D;;OAEG;IACH,QAAQ,CAAC,EAAE,iCAAiC,CAAA;IAC5C,wEAAwE;IACxE,SAAS,CAAC,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAA;IACrC,iEAAiE;IACjE,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,4BAA4B,GAAG,IAAI,CAAC,GAAG,CAAC,CAiD5F"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Product brochure generation route, owned by `@voyant-travel/inventory`.
|
|
3
|
+
*
|
|
4
|
+
* POST /:id/brochure/generate — generate + store a product brochure PDF
|
|
5
|
+
*
|
|
6
|
+
* The route registers a RELATIVE path; a deployment mounts the returned `Hono`
|
|
7
|
+
* at `/v1/admin/products`. The brochure task (`generateAndStoreProductBrochure`)
|
|
8
|
+
* already lives in this package; this factory only wires it to an HTTP surface.
|
|
9
|
+
*
|
|
10
|
+
* The deployment supplies the storage-backed specifics via `options`:
|
|
11
|
+
* - `resolveStorage(c)` — the R2-backed `StorageProvider` to upload into (or
|
|
12
|
+
* `null` when storage isn't configured → 503),
|
|
13
|
+
* - `resolvePrinter(c)` — an optional PDF printer (e.g. a browser-rendering
|
|
14
|
+
* service); when omitted the default pdf-lib printer is used,
|
|
15
|
+
* - `template` / `keyPrefix` / `maxSizeBytes` overrides.
|
|
16
|
+
*
|
|
17
|
+
* Keeping these injected means inventory never imports the deployment's R2
|
|
18
|
+
* binding or cloud client.
|
|
19
|
+
*/
|
|
20
|
+
import { Hono } from "hono";
|
|
21
|
+
import { emitProductContentChanged } from "./events.js";
|
|
22
|
+
import { createDefaultProductBrochureTemplate, generateAndStoreProductBrochure, } from "./tasks/index.js";
|
|
23
|
+
/** 5 MiB cap on a generated brochure PDF before it's rejected with 413. */
|
|
24
|
+
const DEFAULT_MAX_BROCHURE_PDF_BYTES = 5 * 1024 * 1024;
|
|
25
|
+
/**
|
|
26
|
+
* Build the product brochure route (relative path; mount at
|
|
27
|
+
* `/v1/admin/products`). Storage + the optional printer are injected via
|
|
28
|
+
* `options`.
|
|
29
|
+
*/
|
|
30
|
+
export function createProductBrochureRoutes(options) {
|
|
31
|
+
const hono = new Hono();
|
|
32
|
+
const maxSizeBytes = options.maxSizeBytes ?? DEFAULT_MAX_BROCHURE_PDF_BYTES;
|
|
33
|
+
hono.post("/:id/brochure/generate", async (c) => {
|
|
34
|
+
const storage = options.resolveStorage(c);
|
|
35
|
+
if (!storage) {
|
|
36
|
+
return c.json({ error: "Storage not configured" }, 503);
|
|
37
|
+
}
|
|
38
|
+
const productId = c.req.param("id");
|
|
39
|
+
if (!productId)
|
|
40
|
+
return c.json({ error: "id route param is required" }, 400);
|
|
41
|
+
const printer = options.resolvePrinter?.(c) ?? null;
|
|
42
|
+
const keyPrefix = options.keyPrefix?.(productId) ?? `brochures/products/${productId}`;
|
|
43
|
+
let generated;
|
|
44
|
+
try {
|
|
45
|
+
generated = await generateAndStoreProductBrochure(c.get("db"), productId, {
|
|
46
|
+
storage,
|
|
47
|
+
template: options.template ?? createDefaultProductBrochureTemplate(),
|
|
48
|
+
...(printer ? { printer } : {}),
|
|
49
|
+
keyPrefix,
|
|
50
|
+
filename: ({ productId: generatedProductId, filename }) => `brochure-${generatedProductId}-${Date.now()}-${filename}`,
|
|
51
|
+
maxSizeBytes,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
56
|
+
if (message.includes("Generated brochure is too large")) {
|
|
57
|
+
return c.json({ error: message }, 413);
|
|
58
|
+
}
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
await emitProductContentChanged(c.get("eventBus"), { id: productId, axis: "media" });
|
|
62
|
+
return c.json({
|
|
63
|
+
data: generated.brochure,
|
|
64
|
+
metadata: {
|
|
65
|
+
filename: generated.filename,
|
|
66
|
+
sizeBytes: generated.sizeBytes,
|
|
67
|
+
storageKey: generated.storageKey,
|
|
68
|
+
url: generated.url,
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return hono;
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes-brochure.test.d.ts","sourceRoot":"","sources":["../src/routes-brochure.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
const brochures = vi.hoisted(() => ({
|
|
5
|
+
generateAndStoreProductBrochure: vi.fn(),
|
|
6
|
+
createDefaultProductBrochureTemplate: vi.fn(() => ({ id: "default" })),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("./tasks/index.js", () => brochures);
|
|
9
|
+
import { createProductBrochureRoutes } from "./routes-brochure.js";
|
|
10
|
+
function storageStub() {
|
|
11
|
+
return {
|
|
12
|
+
name: "stub",
|
|
13
|
+
upload: vi.fn(async () => ({ key: "k", url: "/u" })),
|
|
14
|
+
delete: vi.fn(async () => { }),
|
|
15
|
+
signedUrl: vi.fn(async () => "/signed"),
|
|
16
|
+
get: vi.fn(async () => null),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function mountApp(resolveStorage, db = {}) {
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.use("*", async (c, next) => {
|
|
22
|
+
c.set("db", db);
|
|
23
|
+
await next();
|
|
24
|
+
});
|
|
25
|
+
app.route("/v1/admin/products", createProductBrochureRoutes({ resolveStorage }));
|
|
26
|
+
return app;
|
|
27
|
+
}
|
|
28
|
+
describe("product brochure routes", () => {
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
});
|
|
32
|
+
it("responds 503 when storage is unconfigured", async () => {
|
|
33
|
+
const app = mountApp(() => null);
|
|
34
|
+
const response = await app.request("/v1/admin/products/prod_1/brochure/generate", {
|
|
35
|
+
method: "POST",
|
|
36
|
+
});
|
|
37
|
+
expect(response.status).toBe(503);
|
|
38
|
+
expect(brochures.generateAndStoreProductBrochure).not.toHaveBeenCalled();
|
|
39
|
+
});
|
|
40
|
+
it("generates + stores a brochure and returns metadata", async () => {
|
|
41
|
+
brochures.generateAndStoreProductBrochure.mockResolvedValue({
|
|
42
|
+
brochure: { id: "brch_1" },
|
|
43
|
+
filename: "brochure-prod_1.pdf",
|
|
44
|
+
sizeBytes: 1234,
|
|
45
|
+
storageKey: "brochures/products/prod_1/brochure-prod_1.pdf",
|
|
46
|
+
url: "/api/v1/media/brochures/products/prod_1/brochure-prod_1.pdf",
|
|
47
|
+
});
|
|
48
|
+
const storage = storageStub();
|
|
49
|
+
const app = mountApp(() => storage);
|
|
50
|
+
const response = await app.request("/v1/admin/products/prod_1/brochure/generate", {
|
|
51
|
+
method: "POST",
|
|
52
|
+
});
|
|
53
|
+
expect(response.status).toBe(200);
|
|
54
|
+
const body = (await response.json());
|
|
55
|
+
expect(body.data.id).toBe("brch_1");
|
|
56
|
+
expect(body.metadata.sizeBytes).toBe(1234);
|
|
57
|
+
expect(brochures.generateAndStoreProductBrochure).toHaveBeenCalledWith(expect.anything(), "prod_1", expect.objectContaining({
|
|
58
|
+
storage,
|
|
59
|
+
keyPrefix: "brochures/products/prod_1",
|
|
60
|
+
maxSizeBytes: 5 * 1024 * 1024,
|
|
61
|
+
}));
|
|
62
|
+
});
|
|
63
|
+
it("maps an oversized brochure to 413", async () => {
|
|
64
|
+
brochures.generateAndStoreProductBrochure.mockRejectedValue(new Error("Generated brochure is too large (9000000 bytes). Max allowed is 5242880 bytes."));
|
|
65
|
+
const app = mountApp(() => storageStub());
|
|
66
|
+
const response = await app.request("/v1/admin/products/prod_1/brochure/generate", {
|
|
67
|
+
method: "POST",
|
|
68
|
+
});
|
|
69
|
+
expect(response.status).toBe(413);
|
|
70
|
+
});
|
|
71
|
+
it("passes an injected printer through to the task", async () => {
|
|
72
|
+
brochures.generateAndStoreProductBrochure.mockResolvedValue({
|
|
73
|
+
brochure: { id: "brch_1" },
|
|
74
|
+
filename: "f.pdf",
|
|
75
|
+
sizeBytes: 1,
|
|
76
|
+
storageKey: "brochures/products/prod_1/f.pdf",
|
|
77
|
+
url: "/u",
|
|
78
|
+
});
|
|
79
|
+
const printer = vi.fn();
|
|
80
|
+
const app = new Hono();
|
|
81
|
+
app.use("*", async (c, next) => {
|
|
82
|
+
c.set("db", {});
|
|
83
|
+
await next();
|
|
84
|
+
});
|
|
85
|
+
app.route("/v1/admin/products", createProductBrochureRoutes({
|
|
86
|
+
resolveStorage: () => storageStub(),
|
|
87
|
+
resolvePrinter: () => printer,
|
|
88
|
+
}));
|
|
89
|
+
await app.request("/v1/admin/products/prod_1/brochure/generate", { method: "POST" });
|
|
90
|
+
expect(brochures.generateAndStoreProductBrochure).toHaveBeenCalledWith(expect.anything(), "prod_1", expect.objectContaining({ printer }));
|
|
91
|
+
});
|
|
92
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voyant-travel/inventory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -24,6 +24,11 @@
|
|
|
24
24
|
"import": "./dist/routes.js",
|
|
25
25
|
"default": "./dist/routes.js"
|
|
26
26
|
},
|
|
27
|
+
"./routes-brochure": {
|
|
28
|
+
"types": "./dist/routes-brochure.d.ts",
|
|
29
|
+
"import": "./dist/routes-brochure.js",
|
|
30
|
+
"default": "./dist/routes-brochure.js"
|
|
31
|
+
},
|
|
27
32
|
"./action-ledger-drift": {
|
|
28
33
|
"types": "./dist/action-ledger-drift.d.ts",
|
|
29
34
|
"import": "./dist/action-ledger-drift.js",
|
|
@@ -157,17 +162,17 @@
|
|
|
157
162
|
"pdf-lib": "^1.17.1",
|
|
158
163
|
"sanitize-html": "^2.17.4",
|
|
159
164
|
"zod": "^4.3.6",
|
|
160
|
-
"@voyant-travel/action-ledger": "^0.
|
|
161
|
-
"@voyant-travel/catalog": "^0.
|
|
165
|
+
"@voyant-travel/action-ledger": "^0.105.0",
|
|
166
|
+
"@voyant-travel/catalog": "^0.119.0",
|
|
162
167
|
"@voyant-travel/core": "^0.109.0",
|
|
163
|
-
"@voyant-travel/db": "^0.108.
|
|
168
|
+
"@voyant-travel/db": "^0.108.1",
|
|
164
169
|
"@voyant-travel/extras-contracts": "^0.104.2",
|
|
165
|
-
"@voyant-travel/hono": "^0.
|
|
166
|
-
"@voyant-travel/commerce": "^0.
|
|
167
|
-
"@voyant-travel/products-contracts": "^0.105.
|
|
168
|
-
"@voyant-travel/storage": "^0.
|
|
169
|
-
"@voyant-travel/utils": "^0.105.
|
|
170
|
-
"@voyant-travel/operations": "^0.1.
|
|
170
|
+
"@voyant-travel/hono": "^0.111.0",
|
|
171
|
+
"@voyant-travel/commerce": "^0.3.0",
|
|
172
|
+
"@voyant-travel/products-contracts": "^0.105.5",
|
|
173
|
+
"@voyant-travel/storage": "^0.105.0",
|
|
174
|
+
"@voyant-travel/utils": "^0.105.2",
|
|
175
|
+
"@voyant-travel/operations": "^0.1.1"
|
|
171
176
|
},
|
|
172
177
|
"devDependencies": {
|
|
173
178
|
"@types/sanitize-html": "^2.16.1",
|