@porulle/core 0.1.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth/middleware.d.ts.map +1 -1
  3. package/dist/auth/middleware.js +17 -1
  4. package/dist/config/define-config.d.ts.map +1 -1
  5. package/dist/config/define-config.js +6 -0
  6. package/dist/config/types.d.ts +12 -0
  7. package/dist/config/types.d.ts.map +1 -1
  8. package/dist/hooks/checkout.d.ts +3 -3
  9. package/dist/hooks/checkout.js +4 -4
  10. package/dist/index.d.ts +9 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +6 -1
  13. package/dist/interfaces/rest/audit-middleware.d.ts +46 -0
  14. package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -0
  15. package/dist/interfaces/rest/audit-middleware.js +82 -0
  16. package/dist/interfaces/rest/parse-json.d.ts +39 -0
  17. package/dist/interfaces/rest/parse-json.d.ts.map +1 -0
  18. package/dist/interfaces/rest/parse-json.js +45 -0
  19. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  20. package/dist/interfaces/rest/routes/catalog.js +17 -2
  21. package/dist/interfaces/rest/routes/customers.d.ts.map +1 -1
  22. package/dist/interfaces/rest/routes/customers.js +59 -4
  23. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  24. package/dist/interfaces/rest/routes/inventory.js +4 -2
  25. package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
  26. package/dist/interfaces/rest/routes/media.js +6 -0
  27. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  28. package/dist/interfaces/rest/routes/orders.js +23 -1
  29. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  30. package/dist/interfaces/rest/routes/promotions.js +21 -1
  31. package/dist/interfaces/rest/schemas/carts.d.ts +175 -0
  32. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  33. package/dist/interfaces/rest/schemas/catalog.d.ts +1410 -0
  34. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  35. package/dist/interfaces/rest/schemas/catalog.js +28 -1
  36. package/dist/interfaces/rest/schemas/checkout.d.ts +35 -0
  37. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  38. package/dist/interfaces/rest/schemas/customer-portal.d.ts +280 -0
  39. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  40. package/dist/interfaces/rest/schemas/customers.d.ts +863 -0
  41. package/dist/interfaces/rest/schemas/customers.d.ts.map +1 -1
  42. package/dist/interfaces/rest/schemas/customers.js +88 -0
  43. package/dist/interfaces/rest/schemas/inventory.d.ts +182 -1
  44. package/dist/interfaces/rest/schemas/inventory.d.ts.map +1 -1
  45. package/dist/interfaces/rest/schemas/media.d.ts +105 -0
  46. package/dist/interfaces/rest/schemas/media.d.ts.map +1 -1
  47. package/dist/interfaces/rest/schemas/orders.d.ts +136 -0
  48. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  49. package/dist/interfaces/rest/schemas/orders.js +20 -0
  50. package/dist/interfaces/rest/schemas/pricing.d.ts +70 -0
  51. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  52. package/dist/interfaces/rest/schemas/promotions.d.ts +298 -0
  53. package/dist/interfaces/rest/schemas/promotions.d.ts.map +1 -1
  54. package/dist/interfaces/rest/schemas/promotions.js +25 -1
  55. package/dist/interfaces/rest/schemas/shared.d.ts +47 -0
  56. package/dist/interfaces/rest/schemas/shared.d.ts.map +1 -1
  57. package/dist/interfaces/rest/schemas/shared.js +8 -0
  58. package/dist/interfaces/rest/schemas/webhooks.d.ts +70 -0
  59. package/dist/interfaces/rest/schemas/webhooks.d.ts.map +1 -1
  60. package/dist/kernel/database/adapter.d.ts +6 -0
  61. package/dist/kernel/database/adapter.d.ts.map +1 -1
  62. package/dist/kernel/database/adapter.js +62 -1
  63. package/dist/kernel/database/migrate.d.ts +22 -2
  64. package/dist/kernel/database/migrate.d.ts.map +1 -1
  65. package/dist/kernel/database/migrate.js +42 -2
  66. package/dist/modules/analytics/hooks.d.ts +5 -5
  67. package/dist/modules/analytics/hooks.js +5 -5
  68. package/dist/modules/analytics/repository/index.d.ts.map +1 -1
  69. package/dist/modules/catalog/category-service.d.ts +6 -1
  70. package/dist/modules/catalog/category-service.d.ts.map +1 -1
  71. package/dist/modules/catalog/category-service.js +35 -5
  72. package/dist/modules/catalog/schema.d.ts +17 -0
  73. package/dist/modules/catalog/schema.d.ts.map +1 -1
  74. package/dist/modules/catalog/schema.js +2 -0
  75. package/dist/modules/catalog/service.d.ts +11 -2
  76. package/dist/modules/catalog/service.d.ts.map +1 -1
  77. package/dist/modules/catalog/service.js +8 -2
  78. package/dist/modules/customers/repository/index.d.ts +8 -1
  79. package/dist/modules/customers/repository/index.d.ts.map +1 -1
  80. package/dist/modules/customers/repository/index.js +42 -2
  81. package/dist/modules/customers/schema.d.ts +162 -0
  82. package/dist/modules/customers/schema.d.ts.map +1 -1
  83. package/dist/modules/customers/schema.js +18 -0
  84. package/dist/modules/customers/service.d.ts +32 -2
  85. package/dist/modules/customers/service.d.ts.map +1 -1
  86. package/dist/modules/customers/service.js +119 -3
  87. package/dist/modules/inventory/schemas.d.ts +7 -1
  88. package/dist/modules/inventory/schemas.d.ts.map +1 -1
  89. package/dist/modules/inventory/schemas.js +6 -1
  90. package/dist/modules/inventory/service.d.ts +18 -0
  91. package/dist/modules/inventory/service.d.ts.map +1 -1
  92. package/dist/modules/inventory/service.js +58 -21
  93. package/dist/modules/media/noop-adapter.d.ts +11 -0
  94. package/dist/modules/media/noop-adapter.d.ts.map +1 -0
  95. package/dist/modules/media/noop-adapter.js +31 -0
  96. package/dist/modules/orders/repository/index.d.ts +19 -0
  97. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  98. package/dist/modules/orders/repository/index.js +42 -1
  99. package/dist/modules/orders/service.d.ts +31 -0
  100. package/dist/modules/orders/service.d.ts.map +1 -1
  101. package/dist/modules/orders/service.js +52 -2
  102. package/dist/modules/payments/repository/index.d.ts.map +1 -1
  103. package/dist/modules/promotions/schemas.d.ts +46 -0
  104. package/dist/modules/promotions/schemas.d.ts.map +1 -1
  105. package/dist/modules/promotions/schemas.js +19 -8
  106. package/dist/modules/promotions/service.d.ts +8 -2
  107. package/dist/modules/promotions/service.d.ts.map +1 -1
  108. package/dist/modules/promotions/service.js +74 -9
  109. package/dist/modules/search/repository/index.d.ts.map +1 -1
  110. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  111. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  112. package/dist/runtime/client-ip.d.ts +31 -0
  113. package/dist/runtime/client-ip.d.ts.map +1 -0
  114. package/dist/runtime/client-ip.js +31 -0
  115. package/dist/runtime/server.d.ts +4 -0
  116. package/dist/runtime/server.d.ts.map +1 -1
  117. package/dist/runtime/server.js +35 -19
  118. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  119. package/dist/test-utils/create-plugin-test-app.js +4 -1
  120. package/dist/test-utils/test-actors.d.ts +3 -2
  121. package/dist/test-utils/test-actors.d.ts.map +1 -1
  122. package/dist/test-utils/test-actors.js +3 -2
  123. package/package.json +15 -15
  124. package/src/auth/middleware.ts +18 -1
  125. package/src/config/define-config.ts +7 -0
  126. package/src/config/types.ts +12 -0
  127. package/src/hooks/checkout.ts +4 -4
  128. package/src/index.ts +18 -1
  129. package/src/interfaces/rest/audit-middleware.ts +110 -0
  130. package/src/interfaces/rest/parse-json.ts +81 -0
  131. package/src/interfaces/rest/routes/catalog.ts +21 -1
  132. package/src/interfaces/rest/routes/customers.ts +64 -3
  133. package/src/interfaces/rest/routes/inventory.ts +4 -2
  134. package/src/interfaces/rest/routes/media.ts +7 -0
  135. package/src/interfaces/rest/routes/orders.ts +21 -1
  136. package/src/interfaces/rest/routes/promotions.ts +22 -1
  137. package/src/interfaces/rest/schemas/catalog.ts +30 -1
  138. package/src/interfaces/rest/schemas/customers.ts +99 -0
  139. package/src/interfaces/rest/schemas/orders.ts +21 -0
  140. package/src/interfaces/rest/schemas/promotions.ts +32 -1
  141. package/src/interfaces/rest/schemas/shared.ts +9 -0
  142. package/src/kernel/database/adapter.ts +64 -1
  143. package/src/kernel/database/migrate.ts +50 -2
  144. package/src/modules/analytics/hooks.ts +5 -5
  145. package/src/modules/analytics/repository/index.ts +0 -1
  146. package/src/modules/catalog/category-service.ts +26 -5
  147. package/src/modules/catalog/schema.ts +2 -0
  148. package/src/modules/catalog/service.ts +25 -3
  149. package/src/modules/customers/repository/index.ts +55 -1
  150. package/src/modules/customers/schema.ts +19 -0
  151. package/src/modules/customers/service.ts +137 -2
  152. package/src/modules/inventory/schemas.ts +6 -1
  153. package/src/modules/inventory/service.ts +84 -29
  154. package/src/modules/media/noop-adapter.ts +36 -0
  155. package/src/modules/orders/repository/index.ts +63 -1
  156. package/src/modules/orders/service.ts +86 -3
  157. package/src/modules/payments/repository/index.ts +0 -1
  158. package/src/modules/promotions/schemas.ts +21 -4
  159. package/src/modules/promotions/service.ts +90 -18
  160. package/src/modules/search/repository/index.ts +0 -1
  161. package/src/modules/shipping/repository/index.ts +0 -1
  162. package/src/modules/tax/repository/index.ts +0 -1
  163. package/src/runtime/client-ip.ts +49 -0
  164. package/src/runtime/server.ts +39 -18
  165. package/src/test-utils/create-plugin-test-app.ts +3 -1
  166. package/src/test-utils/test-actors.ts +3 -2
@@ -0,0 +1,110 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { Actor } from "../../auth/types.js";
3
+ import { createHookContext } from "../../kernel/hooks/create-context.js";
4
+ import type { ServiceContainer } from "../../kernel/hooks/types.js";
5
+ import type { PluginDb } from "../../kernel/database/plugin-types.js";
6
+ import type { Kernel } from "../../runtime/kernel-types.js";
7
+
8
+ /**
9
+ * Hono context variables a handler can set to override the audit defaults.
10
+ * Add to your app's Env Variables to get type-safe `c.set(...)` calls.
11
+ */
12
+ export interface AuditVars {
13
+ /** Override the derived event name. */
14
+ auditEvent: string;
15
+ /** Override the audit payload (defaults to `{}`). */
16
+ auditPayload: Record<string, unknown>;
17
+ /** Override the derived entity type. */
18
+ auditEntityType: string;
19
+ /** Override the derived entity id. */
20
+ auditEntityId: string;
21
+ /** Set to skip the audit write for this request entirely. */
22
+ auditSkip: boolean;
23
+ }
24
+
25
+ const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
26
+ const UUID_RE_GLOBAL = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi;
27
+ const MUTATING = new Set(["POST", "PUT", "PATCH", "DELETE"]);
28
+
29
+ /** First non-`api` path segment, normalized (e.g. `/api/pos/customers/...` → `pos`). */
30
+ function deriveEntityType(path: string): string {
31
+ const segments = path.split("/").filter(Boolean);
32
+ const start = segments[0] === "api" ? 1 : 0;
33
+ return segments[start] ?? "unknown";
34
+ }
35
+
36
+ /**
37
+ * Middleware that writes exactly one `commerce_audit_log` row per successful
38
+ * (2xx) state-changing request, so every porulle app gets audit-by-default
39
+ * without per-route boilerplate.
40
+ *
41
+ * ```ts
42
+ * app.use("*", auditMiddleware(kernel));
43
+ * ```
44
+ *
45
+ * Handlers may override the derived defaults via context variables
46
+ * (see {@link AuditVars}):
47
+ *
48
+ * ```ts
49
+ * c.set("auditEvent", "refund.manager_override");
50
+ * c.set("auditEntityType", "order");
51
+ * c.set("auditEntityId", orderId);
52
+ * c.set("auditSkip", true); // opt out
53
+ * ```
54
+ *
55
+ * Behavior:
56
+ * - GET/HEAD/OPTIONS and non-2xx responses never write a row.
57
+ * - entityId derivation: handler override → first UUID in the path →
58
+ * `data.id` peeked from the JSON response body → `"n/a"`.
59
+ * - event default: `<verb>:<path-with-uuids-as-:id>`.
60
+ * - Writes are best-effort — a failed audit write never affects the response.
61
+ */
62
+ export function auditMiddleware(kernel: Kernel): MiddlewareHandler {
63
+ return async (c, next) => {
64
+ await next();
65
+
66
+ const method = c.req.method.toUpperCase();
67
+ if (!MUTATING.has(method)) return;
68
+
69
+ const status = c.res.status;
70
+ if (status < 200 || status >= 300) return;
71
+
72
+ if (c.get("auditSkip") === true) return;
73
+
74
+ const path = c.req.path;
75
+ const event =
76
+ (c.get("auditEvent") as string | undefined) ??
77
+ `${method.toLowerCase()}:${path.replace(UUID_RE_GLOBAL, ":id")}`;
78
+ const entityType =
79
+ (c.get("auditEntityType") as string | undefined) ?? deriveEntityType(path);
80
+ const payload = (c.get("auditPayload") as Record<string, unknown> | undefined) ?? {};
81
+
82
+ let entityId =
83
+ (c.get("auditEntityId") as string | undefined) ?? path.match(UUID_RE)?.[0];
84
+ if (!entityId) {
85
+ // Peek the response body (a clone — the original stream is untouched).
86
+ try {
87
+ const body = (await c.res.clone().json()) as { data?: { id?: unknown } };
88
+ if (typeof body?.data?.id === "string") entityId = body.data.id;
89
+ } catch {
90
+ // non-JSON / empty body — fall through to "n/a"
91
+ }
92
+ }
93
+ entityId ??= "n/a";
94
+
95
+ try {
96
+ const requestId = c.get("requestId") as string | undefined;
97
+ const ctx = createHookContext({
98
+ actor: (c.get("actor") as Actor | null) ?? null,
99
+ ...(requestId ? { requestId } : {}),
100
+ logger: kernel.logger,
101
+ services: kernel.services as unknown as ServiceContainer,
102
+ database: { db: kernel.database.db as unknown as PluginDb },
103
+ origin: "rest",
104
+ });
105
+ await kernel.services.audit.record({ entityType, entityId, event, payload, ctx });
106
+ } catch {
107
+ // Best-effort: a transient audit-write failure must not bubble to the response.
108
+ }
109
+ };
110
+ }
@@ -0,0 +1,81 @@
1
+ import type { Context } from "hono";
2
+ import type { z } from "zod";
3
+
4
+ /**
5
+ * A single field-level validation failure. Shape is stable across the API
6
+ * so clients can bind form errors without ad-hoc parsing.
7
+ */
8
+ export interface ValidationIssue {
9
+ /** Dotted path to the offending field (e.g. "address.zip"). Empty for whole-body errors. */
10
+ path: string;
11
+ /** Human-readable message. */
12
+ message: string;
13
+ /** Machine code (zod issue code, or "invalid_json"). */
14
+ code: string;
15
+ }
16
+
17
+ /** The error envelope's optional `details` payload. */
18
+ export interface ErrorDetails {
19
+ issues?: ValidationIssue[];
20
+ [key: string]: unknown;
21
+ }
22
+
23
+ /**
24
+ * Build the uniform error envelope `{ error: { code, message, details? } }`.
25
+ * `details` is omitted when not provided so existing responses are unchanged.
26
+ */
27
+ export function err(
28
+ c: Context,
29
+ status: 400 | 401 | 403 | 404 | 409 | 422 | 500,
30
+ code: string,
31
+ message: string,
32
+ details?: ErrorDetails,
33
+ ): Response {
34
+ return c.json(
35
+ { error: { code, message, ...(details ? { details } : {}) } },
36
+ status,
37
+ );
38
+ }
39
+
40
+ /**
41
+ * Parse and validate a request body against a Zod schema.
42
+ *
43
+ * Returns the typed value on success, or a 422 `Response` (the uniform error
44
+ * envelope, with `details.issues[]` populated from the Zod failure) that the
45
+ * caller returns as-is. Replaces the unsafe `(await c.req.json()) as T` cast.
46
+ *
47
+ * ```ts
48
+ * const body = await parseJson(c, MyBodySchema);
49
+ * if (body instanceof Response) return body;
50
+ * // body is z.infer<typeof MyBodySchema>
51
+ * ```
52
+ */
53
+ export async function parseJson<S extends z.ZodType>(
54
+ c: Context,
55
+ schema: S,
56
+ ): Promise<z.infer<S> | Response> {
57
+ let raw: unknown;
58
+ try {
59
+ raw = await c.req.json();
60
+ } catch {
61
+ return err(c, 422, "VALIDATION_FAILED", "Request body must be valid JSON.", {
62
+ issues: [{ path: "", message: "Request body must be valid JSON.", code: "invalid_json" }],
63
+ });
64
+ }
65
+
66
+ const result = schema.safeParse(raw);
67
+ if (!result.success) {
68
+ const issues: ValidationIssue[] = result.error.issues.map((issue) => ({
69
+ path: issue.path.map(String).join("."),
70
+ message: issue.message,
71
+ code: issue.code,
72
+ }));
73
+ const first = issues[0];
74
+ const message = first
75
+ ? `${first.path ? `${first.path}: ` : ""}${first.message}`
76
+ : "Validation failed.";
77
+ return err(c, 422, "VALIDATION_FAILED", message, { issues });
78
+ }
79
+
80
+ return result.data;
81
+ }
@@ -35,6 +35,8 @@ import {
35
35
  updateEntityRoute,
36
36
  setEntityAttributesRoute,
37
37
  createCategoryRoute,
38
+ archiveCategoryRoute,
39
+ restoreCategoryRoute,
38
40
  updateCategoryRoute,
39
41
  createBrandRoute,
40
42
  updateBrandRoute,
@@ -280,7 +282,11 @@ export function catalogRoutes(kernel: Kernel) {
280
282
 
281
283
  // @ts-expect-error -- openapi handler union return type
282
284
  router.openapi(listCategoriesRoute, async (c) => {
283
- const result = await kernel.services.catalog.listCategories({ actor: c.get("actor"), tx: null, requestId: "" });
285
+ const includeArchived = c.req.query("includeArchived") === "true";
286
+ const result = await kernel.services.catalog.listCategories(
287
+ { actor: c.get("actor"), tx: null, requestId: "" },
288
+ { includeArchived },
289
+ );
284
290
  if (!result.ok)
285
291
  return c.json(
286
292
  mapErrorToResponse(result.error),
@@ -289,6 +295,20 @@ export function catalogRoutes(kernel: Kernel) {
289
295
  return c.json({ data: result.value });
290
296
  });
291
297
 
298
+ // @ts-expect-error -- openapi handler union return type
299
+ router.openapi(archiveCategoryRoute, async (c) => {
300
+ const result = await kernel.services.catalog.archiveCategory(c.req.param("categoryId"), c.get("actor"));
301
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
302
+ return c.json({ data: result.value });
303
+ });
304
+
305
+ // @ts-expect-error -- openapi handler union return type
306
+ router.openapi(restoreCategoryRoute, async (c) => {
307
+ const result = await kernel.services.catalog.restoreCategory(c.req.param("categoryId"), c.get("actor"));
308
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
309
+ return c.json({ data: result.value });
310
+ });
311
+
292
312
  // @ts-expect-error -- openapi handler union return type
293
313
  router.openapi(createCategoryRoute, async (c) => {
294
314
  const result = await kernel.services.catalog.createCategory(
@@ -2,12 +2,17 @@ import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { Kernel } from "../../../runtime/kernel.js";
3
3
  import {
4
4
  listCustomersRoute,
5
+ createCustomerRoute,
5
6
  getCustomerRoute,
6
7
  updateCustomerRoute,
7
8
  getCustomerOrdersRoute,
8
9
  getCustomerAddressesRoute,
10
+ listInteractionsRoute,
11
+ createInteractionRoute,
12
+ updateInteractionRoute,
13
+ deleteInteractionRoute,
9
14
  } from "../schemas/customers.js";
10
- import { type AppEnv, mapErrorToResponse, mapErrorToStatus, parsePagination, requirePerm } from "../utils.js";
15
+ import { type AppEnv, mapErrorToResponse, mapErrorToStatus, parsePagination, parseInclude, requirePerm } from "../utils.js";
11
16
 
12
17
  export function customerRoutes(kernel: Kernel) {
13
18
  const router = new OpenAPIHono<AppEnv>();
@@ -37,6 +42,14 @@ export function customerRoutes(kernel: Kernel) {
37
42
  });
38
43
  });
39
44
 
45
+ // @ts-expect-error -- openapi handler union return type
46
+ router.openapi(createCustomerRoute, async (c) => {
47
+ const body = c.req.valid("json");
48
+ const result = await kernel.services.customers.createWalkIn(body, c.get("actor"));
49
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
50
+ return c.json({ data: result.value }, 201);
51
+ });
52
+
40
53
  // @ts-expect-error -- openapi handler union return type
41
54
  router.openapi(getCustomerRoute, async (c) => {
42
55
  const { id } = c.req.valid("param");
@@ -58,7 +71,10 @@ export function customerRoutes(kernel: Kernel) {
58
71
  for (const [k, v] of Object.entries(body)) {
59
72
  if (v !== undefined) updates[k] = v;
60
73
  }
61
- const result = await kernel.services.customers.update(id, updates, actor);
74
+ const replaceMetadata = c.req.query("metadataReplace") === "true";
75
+ const result = await kernel.services.customers.update(id, updates, actor, undefined, {
76
+ replaceMetadata,
77
+ });
62
78
  if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
63
79
  return c.json({ data: result.value });
64
80
  });
@@ -69,13 +85,22 @@ export function customerRoutes(kernel: Kernel) {
69
85
  const actor = c.get("actor");
70
86
  const { page, limit } = parsePagination(c.req.query());
71
87
  const status = c.req.query("status") || undefined;
88
+ const includeTotals = parseInclude(c.req.query("include")).has("totals");
72
89
 
73
90
  const result = await kernel.services.orders.listByCustomer(
74
91
  id,
75
- { page, limit, ...(status ? { status } : {}) },
92
+ { page, limit, ...(status ? { status } : {}), ...(includeTotals ? { includeTotals: true } : {}) },
76
93
  actor,
77
94
  );
78
95
  if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
96
+ if (includeTotals) {
97
+ // Wrapped shape: { data: { items, totals } } (+ pagination meta).
98
+ return c.json({
99
+ data: { items: result.value.items, totals: result.value.totals },
100
+ meta: { pagination: result.value.pagination },
101
+ });
102
+ }
103
+ // Default (back-compat): flat array.
79
104
  return c.json({ data: result.value.items, meta: { pagination: result.value.pagination } });
80
105
  });
81
106
 
@@ -96,5 +121,41 @@ export function customerRoutes(kernel: Kernel) {
96
121
  return c.json({ data: addressResult.value });
97
122
  });
98
123
 
124
+ // ─── Customer interactions (#3) ──────────────────────────────────────────
125
+
126
+ // @ts-expect-error -- openapi handler union return type
127
+ router.openapi(listInteractionsRoute, async (c) => {
128
+ const { id } = c.req.valid("param");
129
+ const result = await kernel.services.customers.listInteractions(id, c.get("actor"));
130
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
131
+ return c.json({ data: result.value });
132
+ });
133
+
134
+ // @ts-expect-error -- openapi handler union return type
135
+ router.openapi(createInteractionRoute, async (c) => {
136
+ const { id } = c.req.valid("param");
137
+ const body = c.req.valid("json");
138
+ const result = await kernel.services.customers.createInteraction(id, body, c.get("actor"));
139
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
140
+ return c.json({ data: result.value }, 201);
141
+ });
142
+
143
+ // @ts-expect-error -- openapi handler union return type
144
+ router.openapi(updateInteractionRoute, async (c) => {
145
+ const { id, iid } = c.req.valid("param");
146
+ const body = c.req.valid("json");
147
+ const result = await kernel.services.customers.updateInteraction(id, iid, body, c.get("actor"));
148
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
149
+ return c.json({ data: result.value });
150
+ });
151
+
152
+ // @ts-expect-error -- openapi handler union return type
153
+ router.openapi(deleteInteractionRoute, async (c) => {
154
+ const { id, iid } = c.req.valid("param");
155
+ const result = await kernel.services.customers.deleteInteraction(id, iid, c.get("actor"));
156
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
157
+ return c.json({ data: { deleted: true } });
158
+ });
159
+
99
160
  return router;
100
161
  }
@@ -51,9 +51,11 @@ export function inventoryRoutes(kernel: Kernel) {
51
51
  // @ts-expect-error -- openapi handler union return type
52
52
  router.openapi(inventoryAdjustRoute, async (c) => {
53
53
  const body = c.req.valid("json");
54
- const result = await kernel.services.inventory.adjust(body, c.get("actor"));
54
+ const result = await kernel.services.inventory.adjustDetailed(body, c.get("actor"));
55
55
  if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
56
- return c.json({ data: result.value });
56
+ const { level, before, after, delta, movementId } = result.value;
57
+ // Additive: level fields (back-compat) plus before/after/delta/movementId.
58
+ return c.json({ data: { ...level, before, after, delta, movementId } });
57
59
  });
58
60
 
59
61
  // @ts-expect-error -- openapi handler union return type
@@ -11,6 +11,13 @@ export function mediaRoutes(kernel: Kernel) {
11
11
  router.use("/upload", requirePerm("media:write"));
12
12
 
13
13
  router.post("/upload", async (c) => {
14
+ // No storage configured (the default no-op adapter) → media is disabled.
15
+ if (kernel.config.storage?.providerId === "noop") {
16
+ return c.json({
17
+ error: { code: "storage_not_supported", message: "Media storage is not configured." },
18
+ }, 501);
19
+ }
20
+
14
21
  const body = await c.req.parseBody();
15
22
  const file = body.file as File;
16
23
 
@@ -1,6 +1,6 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import type { Kernel } from "../../../runtime/kernel.js";
3
- import { changeOrderStatusRoute, listOrdersRoute, getOrderRoute, getOrderFulfillmentsRoute } from "../schemas/orders.js";
3
+ import { changeOrderStatusRoute, listOrdersRoute, orderLookupRoute, getOrderRoute, getOrderFulfillmentsRoute } from "../schemas/orders.js";
4
4
  import { type AppEnv, isUUID, mapErrorToResponse, mapErrorToStatus, parsePagination } from "../utils.js";
5
5
 
6
6
  export function orderRoutes(kernel: Kernel) {
@@ -28,6 +28,26 @@ export function orderRoutes(kernel: Kernel) {
28
28
  });
29
29
  });
30
30
 
31
+ // Registered before getOrderRoute so "/lookup" isn't matched as an idOrNumber.
32
+ // @ts-expect-error -- openapi handler union return type
33
+ router.openapi(orderLookupRoute, async (c) => {
34
+ const q = c.req.query("q") ?? "";
35
+ const fromRaw = c.req.query("from");
36
+ const toRaw = c.req.query("to");
37
+ const opts: { from?: Date; to?: Date } = {};
38
+ if (fromRaw) {
39
+ const d = new Date(fromRaw);
40
+ if (!Number.isNaN(d.getTime())) opts.from = d;
41
+ }
42
+ if (toRaw) {
43
+ const d = new Date(toRaw);
44
+ if (!Number.isNaN(d.getTime())) opts.to = d;
45
+ }
46
+ const result = await kernel.services.orders.lookup(q, opts, c.get("actor"));
47
+ if (!result.ok) return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
48
+ return c.json({ data: result.value });
49
+ });
50
+
31
51
  // @ts-expect-error -- openapi handler union return type
32
52
  router.openapi(getOrderRoute, async (c) => {
33
53
  const idOrNumber = c.req.param("idOrNumber");
@@ -1,7 +1,7 @@
1
1
  import { OpenAPIHono } from "@hono/zod-openapi";
2
2
  import { rateLimiter } from "hono-rate-limiter";
3
3
  import type { Kernel } from "../../../runtime/kernel.js";
4
- import { createPromotionRoute, validatePromotionRoute, deactivatePromotionRoute, listPromotionsRoute } from "../schemas/promotions.js";
4
+ import { createPromotionRoute, updatePromotionRoute, validatePromotionRoute, deactivatePromotionRoute, listPromotionsRoute } from "../schemas/promotions.js";
5
5
  import type { PromotionStatusFilter } from "../../../modules/promotions/service.js";
6
6
  import { type AppEnv, mapErrorToResponse, mapErrorToStatus, requirePerm } from "../utils.js";
7
7
  import { resolveOrgId } from "../../../auth/org.js";
@@ -77,6 +77,27 @@ export function promotionRoutes(kernel: Kernel) {
77
77
  return c.json({ data: result.value });
78
78
  });
79
79
 
80
+ // Guard inline (not via router.use("/:id")) because a "/:id" middleware
81
+ // would also match single-segment routes like POST /validate.
82
+ router.use("/:id", async (c, next) => {
83
+ if (c.req.method !== "PATCH") return next();
84
+ return requirePerm("promotions:manage")(c, next);
85
+ });
86
+
87
+ // @ts-expect-error -- openapi() enforces strict response typing but our handler
88
+ // returns union responses (200 | 400 | 404 | 422). The route definition
89
+ // documents the contract; the handler returns dynamic status.
90
+ router.openapi(updatePromotionRoute, async (c) => {
91
+ const body = c.req.valid("json");
92
+ const actor = c.get("actor");
93
+ const orgId = resolveOrgId(actor);
94
+ const result = await kernel.services.promotions.update(orgId, c.req.param("id"), body, actor);
95
+ if (!result.ok) {
96
+ return c.json(mapErrorToResponse(result.error), mapErrorToStatus(result.error));
97
+ }
98
+ return c.json({ data: result.value });
99
+ });
100
+
80
101
  router.use("/:id/deactivate", requirePerm("promotions:manage"));
81
102
 
82
103
  // @ts-expect-error -- openapi() enforces strict response typing but our handler
@@ -108,7 +108,12 @@ export const listCategoriesRoute = createRoute({
108
108
  method: "get",
109
109
  path: "/categories",
110
110
  tags: ["Catalog"],
111
- summary: "List all categories",
111
+ summary: "List categories (active by default)",
112
+ request: {
113
+ query: z.object({
114
+ includeArchived: z.string().optional().openapi({ example: "true" }),
115
+ }),
116
+ },
112
117
  responses: {
113
118
  200: { content: { "application/json": { schema: DataResponse } }, description: "Success" },
114
119
  },
@@ -342,6 +347,30 @@ export const updateCategoryRoute = createRoute({
342
347
  },
343
348
  });
344
349
 
350
+ export const archiveCategoryRoute = createRoute({
351
+ method: "post",
352
+ path: "/categories/{categoryId}/archive",
353
+ tags: ["Catalog"],
354
+ summary: "Archive a category (soft delete)",
355
+ request: { params: CategoryIdParam },
356
+ responses: {
357
+ 200: { content: { "application/json": { schema: DataResponse } }, description: "Archived" },
358
+ ...errorResponses,
359
+ },
360
+ });
361
+
362
+ export const restoreCategoryRoute = createRoute({
363
+ method: "post",
364
+ path: "/categories/{categoryId}/restore",
365
+ tags: ["Catalog"],
366
+ summary: "Restore an archived category",
367
+ request: { params: CategoryIdParam },
368
+ responses: {
369
+ 200: { content: { "application/json": { schema: DataResponse } }, description: "Restored" },
370
+ ...errorResponses,
371
+ },
372
+ });
373
+
345
374
  export const createBrandRoute = createRoute({
346
375
  method: "post",
347
376
  path: "/brands",
@@ -1,6 +1,34 @@
1
1
  import { z, createRoute } from "@hono/zod-openapi";
2
2
  import { errorResponses } from "./shared.js";
3
3
 
4
+ export const CreateCustomerBodySchema = z.object({
5
+ // Optional: omit for walk-in / POS customers who never log in. A synthetic
6
+ // anonymous_<uuid> id is generated and metadata.walkIn is set.
7
+ userId: z.string().optional().openapi({ example: "user_123" }),
8
+ firstName: z.string().optional().openapi({ example: "Nimali" }),
9
+ lastName: z.string().optional().openapi({ example: "Perera" }),
10
+ phone: z.string().optional().openapi({ example: "+94 77 412 6601" }),
11
+ email: z.string().optional().openapi({ example: "nimali@example.com" }),
12
+ metadata: z.record(z.string(), z.unknown()).optional(),
13
+ }).openapi("CreateCustomerBody");
14
+
15
+ export const createCustomerRoute = createRoute({
16
+ method: "post",
17
+ path: "/",
18
+ tags: ["Customers"],
19
+ summary: "Create a customer (supports walk-in / userId-less)",
20
+ request: {
21
+ body: { content: { "application/json": { schema: CreateCustomerBodySchema } }, required: true },
22
+ },
23
+ responses: {
24
+ 201: {
25
+ content: { "application/json": { schema: z.object({ data: z.record(z.string(), z.unknown()) }) } },
26
+ description: "Customer created.",
27
+ },
28
+ ...errorResponses,
29
+ },
30
+ });
31
+
4
32
  export const listCustomersRoute = createRoute({
5
33
  method: "get",
6
34
  path: "/",
@@ -153,3 +181,74 @@ export const getCustomerAddressesRoute = createRoute({
153
181
  ...errorResponses,
154
182
  },
155
183
  });
184
+
185
+ // ─── Customer interactions (#3) ──────────────────────────────────────────────
186
+
187
+ export const InteractionKindEnum = z.enum([
188
+ "visit", "call", "inquiry", "fitting", "follow_up", "message",
189
+ ]);
190
+
191
+ export const CreateInteractionBodySchema = z.object({
192
+ kind: InteractionKindEnum.openapi({ example: "visit" }),
193
+ notes: z.string().min(1).openapi({ example: "Asked about the navy blazer in M." }),
194
+ relatedEntityId: z.string().uuid().nullable().optional(),
195
+ metadata: z.record(z.string(), z.unknown()).optional(),
196
+ }).openapi("CreateInteractionBody");
197
+
198
+ export const UpdateInteractionBodySchema = CreateInteractionBodySchema.partial().openapi("UpdateInteractionBody");
199
+
200
+ const InteractionDataResponse = z.object({ data: z.record(z.string(), z.unknown()) });
201
+
202
+ export const listInteractionsRoute = createRoute({
203
+ method: "get",
204
+ path: "/{id}/interactions",
205
+ tags: ["Customers"],
206
+ summary: "List a customer's interactions",
207
+ request: { params: z.object({ id: z.string().uuid() }) },
208
+ responses: {
209
+ 200: { content: { "application/json": { schema: z.object({ data: z.array(z.record(z.string(), z.unknown())) }) } }, description: "Interactions" },
210
+ ...errorResponses,
211
+ },
212
+ });
213
+
214
+ export const createInteractionRoute = createRoute({
215
+ method: "post",
216
+ path: "/{id}/interactions",
217
+ tags: ["Customers"],
218
+ summary: "Log a customer interaction",
219
+ request: {
220
+ params: z.object({ id: z.string().uuid() }),
221
+ body: { content: { "application/json": { schema: CreateInteractionBodySchema } }, required: true },
222
+ },
223
+ responses: {
224
+ 201: { content: { "application/json": { schema: InteractionDataResponse } }, description: "Interaction logged" },
225
+ ...errorResponses,
226
+ },
227
+ });
228
+
229
+ export const updateInteractionRoute = createRoute({
230
+ method: "patch",
231
+ path: "/{id}/interactions/{iid}",
232
+ tags: ["Customers"],
233
+ summary: "Edit a customer interaction",
234
+ request: {
235
+ params: z.object({ id: z.string().uuid(), iid: z.string().uuid() }),
236
+ body: { content: { "application/json": { schema: UpdateInteractionBodySchema } }, required: true },
237
+ },
238
+ responses: {
239
+ 200: { content: { "application/json": { schema: InteractionDataResponse } }, description: "Interaction updated" },
240
+ ...errorResponses,
241
+ },
242
+ });
243
+
244
+ export const deleteInteractionRoute = createRoute({
245
+ method: "delete",
246
+ path: "/{id}/interactions/{iid}",
247
+ tags: ["Customers"],
248
+ summary: "Delete a customer interaction",
249
+ request: { params: z.object({ id: z.string().uuid(), iid: z.string().uuid() }) },
250
+ responses: {
251
+ 200: { content: { "application/json": { schema: z.object({ data: z.object({ deleted: z.literal(true) }) }) } }, description: "Deleted" },
252
+ ...errorResponses,
253
+ },
254
+ });
@@ -44,6 +44,27 @@ export const listOrdersRoute = createRoute({
44
44
  },
45
45
  });
46
46
 
47
+ export const orderLookupRoute = createRoute({
48
+ method: "get",
49
+ path: "/lookup",
50
+ tags: ["Orders"],
51
+ summary: "Fuzzy order lookup (receipt-less return / support)",
52
+ description: "Find orders by order number, customer email/name/phone, or walk-in label. Minimum query length is 3 characters.",
53
+ request: {
54
+ query: z.object({
55
+ q: z.string().optional().openapi({ example: "Perera" }),
56
+ from: z.string().optional().openapi({ example: "2026-01-01" }),
57
+ to: z.string().optional().openapi({ example: "2026-12-31" }),
58
+ }),
59
+ },
60
+ responses: {
61
+ 200: {
62
+ content: { "application/json": { schema: z.object({ data: z.object({ items: z.array(z.record(z.string(), z.unknown())), hint: z.string().optional() }) }) } },
63
+ description: "Lookup results",
64
+ },
65
+ },
66
+ });
67
+
47
68
  export const getOrderRoute = createRoute({
48
69
  method: "get",
49
70
  path: "/{idOrNumber}",