@porulle/core 0.1.0 → 0.5.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 (165) hide show
  1. package/dist/auth/middleware.d.ts.map +1 -1
  2. package/dist/auth/middleware.js +17 -1
  3. package/dist/config/define-config.d.ts.map +1 -1
  4. package/dist/config/define-config.js +6 -0
  5. package/dist/config/types.d.ts +12 -0
  6. package/dist/config/types.d.ts.map +1 -1
  7. package/dist/hooks/checkout.d.ts +3 -3
  8. package/dist/hooks/checkout.js +4 -4
  9. package/dist/index.d.ts +9 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js +6 -1
  12. package/dist/interfaces/rest/audit-middleware.d.ts +46 -0
  13. package/dist/interfaces/rest/audit-middleware.d.ts.map +1 -0
  14. package/dist/interfaces/rest/audit-middleware.js +82 -0
  15. package/dist/interfaces/rest/parse-json.d.ts +39 -0
  16. package/dist/interfaces/rest/parse-json.d.ts.map +1 -0
  17. package/dist/interfaces/rest/parse-json.js +45 -0
  18. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  19. package/dist/interfaces/rest/routes/catalog.js +17 -2
  20. package/dist/interfaces/rest/routes/customers.d.ts.map +1 -1
  21. package/dist/interfaces/rest/routes/customers.js +59 -4
  22. package/dist/interfaces/rest/routes/inventory.d.ts.map +1 -1
  23. package/dist/interfaces/rest/routes/inventory.js +4 -2
  24. package/dist/interfaces/rest/routes/media.d.ts.map +1 -1
  25. package/dist/interfaces/rest/routes/media.js +6 -0
  26. package/dist/interfaces/rest/routes/orders.d.ts.map +1 -1
  27. package/dist/interfaces/rest/routes/orders.js +23 -1
  28. package/dist/interfaces/rest/routes/promotions.d.ts.map +1 -1
  29. package/dist/interfaces/rest/routes/promotions.js +21 -1
  30. package/dist/interfaces/rest/schemas/carts.d.ts +175 -0
  31. package/dist/interfaces/rest/schemas/carts.d.ts.map +1 -1
  32. package/dist/interfaces/rest/schemas/catalog.d.ts +1410 -0
  33. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  34. package/dist/interfaces/rest/schemas/catalog.js +28 -1
  35. package/dist/interfaces/rest/schemas/checkout.d.ts +35 -0
  36. package/dist/interfaces/rest/schemas/checkout.d.ts.map +1 -1
  37. package/dist/interfaces/rest/schemas/customer-portal.d.ts +280 -0
  38. package/dist/interfaces/rest/schemas/customer-portal.d.ts.map +1 -1
  39. package/dist/interfaces/rest/schemas/customers.d.ts +863 -0
  40. package/dist/interfaces/rest/schemas/customers.d.ts.map +1 -1
  41. package/dist/interfaces/rest/schemas/customers.js +88 -0
  42. package/dist/interfaces/rest/schemas/inventory.d.ts +182 -1
  43. package/dist/interfaces/rest/schemas/inventory.d.ts.map +1 -1
  44. package/dist/interfaces/rest/schemas/media.d.ts +105 -0
  45. package/dist/interfaces/rest/schemas/media.d.ts.map +1 -1
  46. package/dist/interfaces/rest/schemas/orders.d.ts +136 -0
  47. package/dist/interfaces/rest/schemas/orders.d.ts.map +1 -1
  48. package/dist/interfaces/rest/schemas/orders.js +20 -0
  49. package/dist/interfaces/rest/schemas/pricing.d.ts +70 -0
  50. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  51. package/dist/interfaces/rest/schemas/promotions.d.ts +298 -0
  52. package/dist/interfaces/rest/schemas/promotions.d.ts.map +1 -1
  53. package/dist/interfaces/rest/schemas/promotions.js +25 -1
  54. package/dist/interfaces/rest/schemas/shared.d.ts +47 -0
  55. package/dist/interfaces/rest/schemas/shared.d.ts.map +1 -1
  56. package/dist/interfaces/rest/schemas/shared.js +8 -0
  57. package/dist/interfaces/rest/schemas/webhooks.d.ts +70 -0
  58. package/dist/interfaces/rest/schemas/webhooks.d.ts.map +1 -1
  59. package/dist/kernel/database/adapter.d.ts +6 -0
  60. package/dist/kernel/database/adapter.d.ts.map +1 -1
  61. package/dist/kernel/database/adapter.js +62 -1
  62. package/dist/kernel/database/migrate.d.ts +22 -2
  63. package/dist/kernel/database/migrate.d.ts.map +1 -1
  64. package/dist/kernel/database/migrate.js +42 -2
  65. package/dist/modules/analytics/hooks.d.ts +5 -5
  66. package/dist/modules/analytics/hooks.js +5 -5
  67. package/dist/modules/analytics/repository/index.d.ts.map +1 -1
  68. package/dist/modules/catalog/category-service.d.ts +6 -1
  69. package/dist/modules/catalog/category-service.d.ts.map +1 -1
  70. package/dist/modules/catalog/category-service.js +35 -5
  71. package/dist/modules/catalog/schema.d.ts +17 -0
  72. package/dist/modules/catalog/schema.d.ts.map +1 -1
  73. package/dist/modules/catalog/schema.js +2 -0
  74. package/dist/modules/catalog/service.d.ts +11 -2
  75. package/dist/modules/catalog/service.d.ts.map +1 -1
  76. package/dist/modules/catalog/service.js +8 -2
  77. package/dist/modules/customers/repository/index.d.ts +8 -1
  78. package/dist/modules/customers/repository/index.d.ts.map +1 -1
  79. package/dist/modules/customers/repository/index.js +42 -2
  80. package/dist/modules/customers/schema.d.ts +162 -0
  81. package/dist/modules/customers/schema.d.ts.map +1 -1
  82. package/dist/modules/customers/schema.js +18 -0
  83. package/dist/modules/customers/service.d.ts +32 -2
  84. package/dist/modules/customers/service.d.ts.map +1 -1
  85. package/dist/modules/customers/service.js +119 -3
  86. package/dist/modules/inventory/schemas.d.ts +7 -1
  87. package/dist/modules/inventory/schemas.d.ts.map +1 -1
  88. package/dist/modules/inventory/schemas.js +6 -1
  89. package/dist/modules/inventory/service.d.ts +18 -0
  90. package/dist/modules/inventory/service.d.ts.map +1 -1
  91. package/dist/modules/inventory/service.js +58 -21
  92. package/dist/modules/media/noop-adapter.d.ts +11 -0
  93. package/dist/modules/media/noop-adapter.d.ts.map +1 -0
  94. package/dist/modules/media/noop-adapter.js +31 -0
  95. package/dist/modules/orders/repository/index.d.ts +19 -0
  96. package/dist/modules/orders/repository/index.d.ts.map +1 -1
  97. package/dist/modules/orders/repository/index.js +42 -1
  98. package/dist/modules/orders/service.d.ts +31 -0
  99. package/dist/modules/orders/service.d.ts.map +1 -1
  100. package/dist/modules/orders/service.js +52 -2
  101. package/dist/modules/payments/repository/index.d.ts.map +1 -1
  102. package/dist/modules/promotions/schemas.d.ts +46 -0
  103. package/dist/modules/promotions/schemas.d.ts.map +1 -1
  104. package/dist/modules/promotions/schemas.js +19 -8
  105. package/dist/modules/promotions/service.d.ts +8 -2
  106. package/dist/modules/promotions/service.d.ts.map +1 -1
  107. package/dist/modules/promotions/service.js +74 -9
  108. package/dist/modules/search/repository/index.d.ts.map +1 -1
  109. package/dist/modules/shipping/repository/index.d.ts.map +1 -1
  110. package/dist/modules/tax/repository/index.d.ts.map +1 -1
  111. package/dist/runtime/client-ip.d.ts +31 -0
  112. package/dist/runtime/client-ip.d.ts.map +1 -0
  113. package/dist/runtime/client-ip.js +31 -0
  114. package/dist/runtime/server.d.ts +4 -0
  115. package/dist/runtime/server.d.ts.map +1 -1
  116. package/dist/runtime/server.js +35 -19
  117. package/dist/test-utils/create-plugin-test-app.d.ts.map +1 -1
  118. package/dist/test-utils/create-plugin-test-app.js +4 -1
  119. package/dist/test-utils/test-actors.d.ts +3 -2
  120. package/dist/test-utils/test-actors.d.ts.map +1 -1
  121. package/dist/test-utils/test-actors.js +3 -2
  122. package/package.json +4 -4
  123. package/src/auth/middleware.ts +18 -1
  124. package/src/config/define-config.ts +7 -0
  125. package/src/config/types.ts +12 -0
  126. package/src/hooks/checkout.ts +4 -4
  127. package/src/index.ts +18 -1
  128. package/src/interfaces/rest/audit-middleware.ts +110 -0
  129. package/src/interfaces/rest/parse-json.ts +81 -0
  130. package/src/interfaces/rest/routes/catalog.ts +21 -1
  131. package/src/interfaces/rest/routes/customers.ts +64 -3
  132. package/src/interfaces/rest/routes/inventory.ts +4 -2
  133. package/src/interfaces/rest/routes/media.ts +7 -0
  134. package/src/interfaces/rest/routes/orders.ts +21 -1
  135. package/src/interfaces/rest/routes/promotions.ts +22 -1
  136. package/src/interfaces/rest/schemas/catalog.ts +30 -1
  137. package/src/interfaces/rest/schemas/customers.ts +99 -0
  138. package/src/interfaces/rest/schemas/orders.ts +21 -0
  139. package/src/interfaces/rest/schemas/promotions.ts +32 -1
  140. package/src/interfaces/rest/schemas/shared.ts +9 -0
  141. package/src/kernel/database/adapter.ts +64 -1
  142. package/src/kernel/database/migrate.ts +50 -2
  143. package/src/modules/analytics/hooks.ts +5 -5
  144. package/src/modules/analytics/repository/index.ts +0 -1
  145. package/src/modules/catalog/category-service.ts +26 -5
  146. package/src/modules/catalog/schema.ts +2 -0
  147. package/src/modules/catalog/service.ts +25 -3
  148. package/src/modules/customers/repository/index.ts +55 -1
  149. package/src/modules/customers/schema.ts +19 -0
  150. package/src/modules/customers/service.ts +137 -2
  151. package/src/modules/inventory/schemas.ts +6 -1
  152. package/src/modules/inventory/service.ts +84 -29
  153. package/src/modules/media/noop-adapter.ts +36 -0
  154. package/src/modules/orders/repository/index.ts +63 -1
  155. package/src/modules/orders/service.ts +86 -3
  156. package/src/modules/payments/repository/index.ts +0 -1
  157. package/src/modules/promotions/schemas.ts +21 -4
  158. package/src/modules/promotions/service.ts +90 -18
  159. package/src/modules/search/repository/index.ts +0 -1
  160. package/src/modules/shipping/repository/index.ts +0 -1
  161. package/src/modules/tax/repository/index.ts +0 -1
  162. package/src/runtime/client-ip.ts +49 -0
  163. package/src/runtime/server.ts +39 -18
  164. package/src/test-utils/create-plugin-test-app.ts +3 -1
  165. package/src/test-utils/test-actors.ts +3 -2
@@ -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}",
@@ -3,9 +3,16 @@ import { ErrorSchema, errorResponses, UuidParamSchema } from "./shared.js";
3
3
 
4
4
  // ─── Request Schemas ────────────────────────────────────────────────────────
5
5
 
6
- import { CreatePromotionBodySchema } from "../../../modules/promotions/schemas.js";
6
+ import {
7
+ CreatePromotionBodySchema,
8
+ UpdatePromotionBodySchema as UpdatePromotionBodySchemaBase,
9
+ } from "../../../modules/promotions/schemas.js";
7
10
  export { CreatePromotionBodySchema };
8
11
 
12
+ export const UpdatePromotionBodySchema = UpdatePromotionBodySchemaBase.openapi(
13
+ "UpdatePromotionRequest",
14
+ );
15
+
9
16
  export const ValidatePromotionBodySchema = z.object({
10
17
  code: z.string().openapi({ example: "SUMMER10" }),
11
18
  currency: z.string().length(3).openapi({ example: "USD" }),
@@ -98,6 +105,30 @@ export const validatePromotionRoute = createRoute({
98
105
  },
99
106
  });
100
107
 
108
+ export const updatePromotionRoute = createRoute({
109
+ method: "patch",
110
+ path: "/{id}",
111
+ tags: ["Promotions"],
112
+ summary: "Edit a promotion",
113
+ description: "Update any subset of a promotion's fields (name, code, type, value, validity dates, scope, metadata, isActive). Validated the same way create is.",
114
+ request: {
115
+ params: PromotionIdParam,
116
+ body: {
117
+ content: {
118
+ "application/json": { schema: UpdatePromotionBodySchema },
119
+ },
120
+ required: true,
121
+ },
122
+ },
123
+ responses: {
124
+ 200: {
125
+ content: { "application/json": { schema: PromotionResponseSchema } },
126
+ description: "Promotion updated.",
127
+ },
128
+ ...errorResponses,
129
+ },
130
+ });
131
+
101
132
  export const deactivatePromotionRoute = createRoute({
102
133
  method: "post",
103
134
  path: "/{id}/deactivate",
@@ -2,10 +2,19 @@ import { z } from "@hono/zod-openapi";
2
2
 
3
3
  // ─── Error Response ──────────────────────────────────────────────────────────
4
4
 
5
+ export const ValidationIssueSchema = z.object({
6
+ path: z.string().openapi({ example: "cartId" }),
7
+ message: z.string().openapi({ example: "Invalid uuid" }),
8
+ code: z.string().openapi({ example: "invalid_string" }),
9
+ }).openapi("ValidationIssue");
10
+
5
11
  export const ErrorSchema = z.object({
6
12
  error: z.object({
7
13
  code: z.string().openapi({ example: "VALIDATION_FAILED" }),
8
14
  message: z.string().openapi({ example: "cartId: Invalid uuid" }),
15
+ details: z.object({
16
+ issues: z.array(ValidationIssueSchema).optional(),
17
+ }).optional(),
9
18
  }),
10
19
  }).openapi("Error");
11
20
 
@@ -16,6 +16,69 @@ export interface DatabaseConnectionFactoryInput {
16
16
  adapter: DatabaseAdapter;
17
17
  }
18
18
 
19
+ /**
20
+ * Marker used to retrieve the raw, un-normalized driver from a wrapped db.
21
+ * Tools that drive the driver directly (drizzle-kit's `pushSchema`, which
22
+ * relies on the native `db.execute()` shape) must unwrap first via {@link unwrapDb}.
23
+ */
24
+ const RAW_DB = Symbol.for("porulle.rawDb");
25
+
26
+ /**
27
+ * Return the raw driver behind a db normalized by {@link normalizeExecuteShape}.
28
+ * If `db` isn't wrapped, it is returned unchanged. Use before handing a db to
29
+ * drizzle-kit (`pushSchema`/introspection), which needs the native result shape.
30
+ */
31
+ export function unwrapDb<T>(db: T): T {
32
+ if (db != null && typeof db === "object") {
33
+ const raw = (db as Record<symbol, unknown>)[RAW_DB];
34
+ if (raw) return raw as T;
35
+ }
36
+ return db;
37
+ }
38
+
39
+ /**
40
+ * Normalize `db.execute()` so it returns a row array regardless of the
41
+ * underlying driver. postgres-js returns the rows directly, while neon-http
42
+ * and node-postgres (and PGlite) return `{ rows, command, rowCount }`.
43
+ * Without this, raw-SQL code that destructures the result as an array breaks
44
+ * on the `{ rows }` drivers. We intercept only `execute`; every other method
45
+ * is bound to the real driver instance so its internals are untouched.
46
+ */
47
+ function normalizeExecuteShape<T extends object>(db: T): T {
48
+ return new Proxy(db, {
49
+ get(target, prop, receiver) {
50
+ if (prop === RAW_DB) return target;
51
+ if (prop === "execute") {
52
+ const orig = Reflect.get(target, prop, receiver);
53
+ if (typeof orig !== "function") return orig;
54
+ return async (...args: unknown[]) => {
55
+ const result = await (orig as (...a: unknown[]) => Promise<unknown>).apply(target, args);
56
+ if (
57
+ result != null &&
58
+ typeof result === "object" &&
59
+ !Array.isArray(result) &&
60
+ Array.isArray((result as { rows?: unknown }).rows)
61
+ ) {
62
+ return (result as { rows: unknown[] }).rows;
63
+ }
64
+ return result;
65
+ };
66
+ }
67
+ const value = Reflect.get(target, prop, target);
68
+ // Bind methods to the real instance so drizzle's internals (incl. any
69
+ // private fields) are never accessed through the proxy.
70
+ return typeof value === "function" ? value.bind(target) : value;
71
+ },
72
+ });
73
+ }
74
+
19
75
  export function createDatabaseConnection(input: DatabaseConnectionFactoryInput): DatabaseAdapter {
20
- return input.adapter;
76
+ const adapter = input.adapter;
77
+ return {
78
+ provider: adapter.provider,
79
+ db: normalizeExecuteShape(adapter.db as object),
80
+ transaction<T>(fn: (tx: unknown) => Promise<T>): Promise<T> {
81
+ return adapter.transaction((tx) => fn(normalizeExecuteShape(tx as object)));
82
+ },
83
+ };
21
84
  }