@porulle/core 0.10.6 → 0.11.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 (136) hide show
  1. package/dist/config/types.d.ts +3 -0
  2. package/dist/config/types.d.ts.map +1 -1
  3. package/dist/hooks/checkout.d.ts +2 -0
  4. package/dist/hooks/checkout.d.ts.map +1 -1
  5. package/dist/hooks/checkout.js +2 -0
  6. package/dist/index.d.ts +3 -1
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +1 -0
  9. package/dist/interfaces/rest/index.d.ts.map +1 -1
  10. package/dist/interfaces/rest/index.js +4 -0
  11. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts +5 -0
  12. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts.map +1 -0
  13. package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +22 -0
  14. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts +5 -0
  15. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts.map +1 -0
  16. package/dist/interfaces/rest/routes/admin/entity-field-definitions.js +39 -0
  17. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  18. package/dist/interfaces/rest/routes/catalog.js +37 -2
  19. package/dist/interfaces/rest/routes/checkout.d.ts.map +1 -1
  20. package/dist/interfaces/rest/routes/checkout.js +7 -1
  21. package/dist/interfaces/rest/routes/payments.d.ts.map +1 -1
  22. package/dist/interfaces/rest/routes/payments.js +48 -30
  23. package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
  24. package/dist/interfaces/rest/routes/search.js +12 -0
  25. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts +134 -0
  26. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts.map +1 -0
  27. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.js +26 -0
  28. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts +592 -0
  29. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts.map +1 -0
  30. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.js +109 -0
  31. package/dist/interfaces/rest/schemas/catalog.d.ts +1364 -401
  32. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  33. package/dist/interfaces/rest/schemas/catalog.js +86 -0
  34. package/dist/interfaces/rest/schemas/pricing.d.ts +1 -0
  35. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  36. package/dist/interfaces/rest/schemas/search.d.ts +1 -0
  37. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  38. package/dist/interfaces/rest/schemas/search.js +1 -0
  39. package/dist/kernel/error-mapper.d.ts.map +1 -1
  40. package/dist/kernel/error-mapper.js +3 -0
  41. package/dist/modules/catalog/entity-service.d.ts +5 -3
  42. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  43. package/dist/modules/catalog/entity-service.js +73 -22
  44. package/dist/modules/catalog/module.d.ts +6 -1
  45. package/dist/modules/catalog/module.d.ts.map +1 -1
  46. package/dist/modules/catalog/module.js +6 -1
  47. package/dist/modules/catalog/ownership.d.ts +5 -0
  48. package/dist/modules/catalog/ownership.d.ts.map +1 -0
  49. package/dist/modules/catalog/ownership.js +11 -0
  50. package/dist/modules/catalog/repository/index.d.ts +52 -1
  51. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  52. package/dist/modules/catalog/repository/index.js +321 -2
  53. package/dist/modules/catalog/revision.d.ts +4 -0
  54. package/dist/modules/catalog/revision.d.ts.map +1 -0
  55. package/dist/modules/catalog/revision.js +33 -0
  56. package/dist/modules/catalog/schema.d.ts +979 -110
  57. package/dist/modules/catalog/schema.d.ts.map +1 -1
  58. package/dist/modules/catalog/schema.js +112 -2
  59. package/dist/modules/catalog/schemas.d.ts +2 -0
  60. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  61. package/dist/modules/catalog/schemas.js +2 -0
  62. package/dist/modules/catalog/service.d.ts +66 -3
  63. package/dist/modules/catalog/service.d.ts.map +1 -1
  64. package/dist/modules/catalog/service.js +512 -11
  65. package/dist/modules/channels/adapter.d.ts +85 -0
  66. package/dist/modules/channels/adapter.d.ts.map +1 -1
  67. package/dist/modules/channels/adapter.js +9 -1
  68. package/dist/modules/media/module.d.ts +68 -0
  69. package/dist/modules/media/module.d.ts.map +1 -1
  70. package/dist/modules/media/module.js +2 -0
  71. package/dist/modules/media/schema.d.ts +70 -0
  72. package/dist/modules/media/schema.d.ts.map +1 -1
  73. package/dist/modules/media/schema.js +20 -2
  74. package/dist/modules/media/service.d.ts +8 -1
  75. package/dist/modules/media/service.d.ts.map +1 -1
  76. package/dist/modules/media/service.js +66 -18
  77. package/dist/modules/orders/service.d.ts +2 -0
  78. package/dist/modules/orders/service.d.ts.map +1 -1
  79. package/dist/modules/orders/service.js +1 -0
  80. package/dist/modules/pricing/schema.d.ts +17 -0
  81. package/dist/modules/pricing/schema.d.ts.map +1 -1
  82. package/dist/modules/pricing/schema.js +1 -0
  83. package/dist/modules/pricing/schemas.d.ts +1 -0
  84. package/dist/modules/pricing/schemas.d.ts.map +1 -1
  85. package/dist/modules/pricing/schemas.js +1 -0
  86. package/dist/modules/pricing/service.d.ts.map +1 -1
  87. package/dist/modules/pricing/service.js +6 -1
  88. package/dist/modules/search/adapter.d.ts +2 -0
  89. package/dist/modules/search/adapter.d.ts.map +1 -1
  90. package/dist/modules/search/module.d.ts.map +1 -1
  91. package/dist/modules/search/module.js +2 -0
  92. package/dist/modules/search/service.d.ts +4 -0
  93. package/dist/modules/search/service.d.ts.map +1 -1
  94. package/dist/modules/search/service.js +74 -5
  95. package/dist/runtime/kernel-modules.d.ts +73 -0
  96. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  97. package/dist/utils/id.d.ts +1 -0
  98. package/dist/utils/id.d.ts.map +1 -1
  99. package/dist/utils/id.js +8 -0
  100. package/package.json +1 -1
  101. package/src/config/types.ts +3 -0
  102. package/src/hooks/checkout.ts +4 -0
  103. package/src/index.ts +14 -0
  104. package/src/interfaces/rest/index.ts +4 -0
  105. package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +37 -0
  106. package/src/interfaces/rest/routes/admin/entity-field-definitions.ts +89 -0
  107. package/src/interfaces/rest/routes/catalog.ts +85 -0
  108. package/src/interfaces/rest/routes/checkout.ts +7 -1
  109. package/src/interfaces/rest/routes/payments.ts +55 -35
  110. package/src/interfaces/rest/routes/search.ts +16 -0
  111. package/src/interfaces/rest/schemas/admin-custom-field-proposals.ts +29 -0
  112. package/src/interfaces/rest/schemas/admin-entity-field-definitions.ts +120 -0
  113. package/src/interfaces/rest/schemas/catalog.ts +92 -0
  114. package/src/interfaces/rest/schemas/search.ts +2 -0
  115. package/src/kernel/error-mapper.ts +3 -0
  116. package/src/modules/catalog/entity-service.ts +73 -20
  117. package/src/modules/catalog/module.ts +15 -0
  118. package/src/modules/catalog/ownership.ts +17 -0
  119. package/src/modules/catalog/repository/index.ts +547 -1
  120. package/src/modules/catalog/revision.ts +35 -0
  121. package/src/modules/catalog/schema.ts +169 -1
  122. package/src/modules/catalog/schemas.ts +2 -0
  123. package/src/modules/catalog/service.ts +738 -13
  124. package/src/modules/channels/adapter.ts +107 -1
  125. package/src/modules/media/module.ts +2 -0
  126. package/src/modules/media/schema.ts +23 -2
  127. package/src/modules/media/service.ts +84 -23
  128. package/src/modules/orders/service.ts +3 -0
  129. package/src/modules/pricing/schema.ts +1 -0
  130. package/src/modules/pricing/schemas.ts +1 -0
  131. package/src/modules/pricing/service.ts +7 -2
  132. package/src/modules/search/adapter.ts +2 -0
  133. package/src/modules/search/module.ts +2 -0
  134. package/src/modules/search/service.ts +87 -6
  135. package/src/utils/id.ts +11 -0
  136. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,89 @@
1
+ import { OpenAPIHono } from "@hono/zod-openapi";
2
+ import type { Kernel } from "../../../../runtime/kernel.js";
3
+ import type {
4
+ CreateEntityFieldDefinitionInput,
5
+ UpdateEntityFieldDefinitionInput,
6
+ } from "../../../../modules/catalog/service.js";
7
+ import {
8
+ archiveEntityFieldDefinitionRoute,
9
+ createEntityFieldDefinitionRoute,
10
+ listEntityFieldDefinitionsRoute,
11
+ updateEntityFieldDefinitionRoute,
12
+ } from "../../schemas/admin-entity-field-definitions.js";
13
+ import {
14
+ type AppEnv,
15
+ mapErrorToResponse,
16
+ mapErrorToStatus,
17
+ requirePerm,
18
+ } from "../../utils.js";
19
+
20
+ export function adminEntityFieldDefinitionRoutes(kernel: Kernel) {
21
+ const router = new OpenAPIHono<AppEnv>();
22
+
23
+ router.use("/entity-field-definitions", requirePerm("catalog:update"));
24
+ router.use("/entity-field-definitions/:id", requirePerm("catalog:update"));
25
+ router.use(
26
+ "/entity-field-definitions/:id/archive",
27
+ requirePerm("catalog:update"),
28
+ );
29
+
30
+ // @ts-expect-error -- openapi handler union return type
31
+ router.openapi(createEntityFieldDefinitionRoute, async (c) => {
32
+ const result = await kernel.services.catalog.createEntityFieldDefinition(
33
+ c.req.valid("json") as CreateEntityFieldDefinitionInput,
34
+ c.get("actor"),
35
+ );
36
+ if (!result.ok)
37
+ return c.json(
38
+ mapErrorToResponse(result.error),
39
+ mapErrorToStatus(result.error),
40
+ );
41
+ return c.json({ data: result.value }, 201);
42
+ });
43
+
44
+ // @ts-expect-error -- openapi handler union return type
45
+ router.openapi(listEntityFieldDefinitionsRoute, async (c) => {
46
+ const entityType = c.req.query("entityType");
47
+ const result = await kernel.services.catalog.listEntityFieldDefinitions(
48
+ c.get("actor"),
49
+ entityType,
50
+ );
51
+ if (!result.ok)
52
+ return c.json(
53
+ mapErrorToResponse(result.error),
54
+ mapErrorToStatus(result.error),
55
+ );
56
+ return c.json({ data: result.value });
57
+ });
58
+
59
+ // @ts-expect-error -- openapi handler union return type
60
+ router.openapi(updateEntityFieldDefinitionRoute, async (c) => {
61
+ const result = await kernel.services.catalog.updateEntityFieldDefinition(
62
+ c.req.param("id"),
63
+ c.req.valid("json") as UpdateEntityFieldDefinitionInput,
64
+ c.get("actor"),
65
+ );
66
+ if (!result.ok)
67
+ return c.json(
68
+ mapErrorToResponse(result.error),
69
+ mapErrorToStatus(result.error),
70
+ );
71
+ return c.json({ data: result.value });
72
+ });
73
+
74
+ // @ts-expect-error -- openapi handler union return type
75
+ router.openapi(archiveEntityFieldDefinitionRoute, async (c) => {
76
+ const result = await kernel.services.catalog.archiveEntityFieldDefinition(
77
+ c.req.param("id"),
78
+ c.get("actor"),
79
+ );
80
+ if (!result.ok)
81
+ return c.json(
82
+ mapErrorToResponse(result.error),
83
+ mapErrorToStatus(result.error),
84
+ );
85
+ return c.json({ data: result.value });
86
+ });
87
+
88
+ return router;
89
+ }
@@ -19,6 +19,8 @@ import {
19
19
  listEntitiesRoute,
20
20
  getEntityRoute,
21
21
  getEntityAttributesRoute,
22
+ listFieldOwnershipRoute,
23
+ setFieldOwnershipRoute,
22
24
  listCategoriesRoute,
23
25
  listBrandsRoute,
24
26
  deleteEntityRoute,
@@ -34,6 +36,8 @@ import {
34
36
  createEntityRoute,
35
37
  updateEntityRoute,
36
38
  setEntityAttributesRoute,
39
+ approveCustomFieldRoute,
40
+ rejectCustomFieldRoute,
37
41
  createCategoryRoute,
38
42
  archiveCategoryRoute,
39
43
  restoreCategoryRoute,
@@ -54,11 +58,58 @@ import {
54
58
  parseInclude,
55
59
  parsePagination,
56
60
  parseSort,
61
+ requirePerm,
57
62
  } from "../utils.js";
58
63
 
59
64
  export function catalogRoutes(kernel: Kernel) {
60
65
  const router = new OpenAPIHono<AppEnv>();
61
66
 
67
+ router.use("/entities/:id/custom-fields/:fieldName/approve", requirePerm("catalog:update"));
68
+ router.use("/entities/:id/custom-fields/:fieldName/reject", requirePerm("catalog:update"));
69
+
70
+ // @ts-expect-error -- openapi handler union return type
71
+ router.openapi(listFieldOwnershipRoute, async (c) => {
72
+ const result = await kernel.services.catalog.listFieldOwnership(
73
+ c.req.param("id"),
74
+ c.get("actor"),
75
+ undefined,
76
+ c.req.query("storeId"),
77
+ );
78
+ if (!result.ok) {
79
+ return c.json(
80
+ mapErrorToResponse(result.error),
81
+ mapErrorToStatus(result.error),
82
+ );
83
+ }
84
+ return c.json({ data: result.value });
85
+ });
86
+
87
+ // @ts-expect-error -- openapi handler union return type
88
+ router.openapi(setFieldOwnershipRoute, async (c) => {
89
+ const body = c.req.valid("json") as {
90
+ fieldPath: string;
91
+ owner: "platform" | "store" | "shared";
92
+ storeId?: string | null;
93
+ variantId?: string | null;
94
+ };
95
+ const result = await kernel.services.catalog.setFieldOwner(
96
+ c.req.param("id"),
97
+ body.fieldPath,
98
+ body.storeId ?? null,
99
+ body.owner,
100
+ c.get("actor"),
101
+ undefined,
102
+ body.variantId ?? null,
103
+ );
104
+ if (!result.ok) {
105
+ return c.json(
106
+ mapErrorToResponse(result.error),
107
+ mapErrorToStatus(result.error),
108
+ );
109
+ }
110
+ return c.json({ data: { updated: true } });
111
+ });
112
+
62
113
  // @ts-expect-error -- openapi handler union return type
63
114
  router.openapi(listEntitiesRoute, async (c) => {
64
115
  try {
@@ -101,6 +152,7 @@ export function catalogRoutes(kernel: Kernel) {
101
152
  includeMedia: include.has("media"),
102
153
  includeInventory: include.has("inventory"),
103
154
  includePricing: include.has("pricing"),
155
+ includeCustomFields: include.has("customFields"),
104
156
  };
105
157
  const hydrated = await Promise.all(
106
158
  result.value.items.map(async (item) => {
@@ -143,6 +195,7 @@ export function catalogRoutes(kernel: Kernel) {
143
195
  includeMedia: include.has("media"),
144
196
  includeInventory: include.has("inventory"),
145
197
  includePricing: include.has("pricing"),
198
+ includeCustomFields: include.has("customFields"),
146
199
  };
147
200
 
148
201
  const actor = c.get("actor");
@@ -268,6 +321,38 @@ export function catalogRoutes(kernel: Kernel) {
268
321
  return c.json({ data: { updated: true } });
269
322
  });
270
323
 
324
+ // @ts-expect-error -- openapi handler union return type
325
+ router.openapi(approveCustomFieldRoute, async (c) => {
326
+ const result = await kernel.services.catalog.approveCustomField(
327
+ c.req.param("id"),
328
+ c.req.param("fieldName"),
329
+ c.req.query("locale") ?? "en",
330
+ c.get("actor"),
331
+ );
332
+ if (!result.ok)
333
+ return c.json(
334
+ mapErrorToResponse(result.error),
335
+ mapErrorToStatus(result.error),
336
+ );
337
+ return c.json({ data: result.value });
338
+ });
339
+
340
+ // @ts-expect-error -- openapi handler union return type
341
+ router.openapi(rejectCustomFieldRoute, async (c) => {
342
+ const result = await kernel.services.catalog.rejectCustomField(
343
+ c.req.param("id"),
344
+ c.req.param("fieldName"),
345
+ c.req.query("locale") ?? "en",
346
+ c.get("actor"),
347
+ );
348
+ if (!result.ok)
349
+ return c.json(
350
+ mapErrorToResponse(result.error),
351
+ mapErrorToStatus(result.error),
352
+ );
353
+ return c.json({ data: result.value });
354
+ });
355
+
271
356
  // @ts-expect-error -- openapi handler union return type
272
357
  router.openapi(getEntityAttributesRoute, async (c) => {
273
358
  const result = await kernel.services.catalog.getAttributes(
@@ -22,7 +22,8 @@ import type { PluginDb } from "../../../kernel/database/plugin-types.js";
22
22
  import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
23
23
  import { isCommerceError } from "../../../kernel/errors.js";
24
24
  import { assertPermission } from "../../../auth/permissions.js";
25
- import { makeId } from "../../../utils/id.js";
25
+ import { resolveOrgId } from "../../../auth/org.js";
26
+ import { makeDeterministicId, makeId } from "../../../utils/id.js";
26
27
  import type { ShippingAddress } from "../../../modules/shipping/calculator.js";
27
28
  import type { Actor } from "../../../auth/types.js";
28
29
 
@@ -126,8 +127,12 @@ export function checkoutRoutes(kernel: Kernel) {
126
127
  }
127
128
  }
128
129
 
130
+ const orderId = body.idempotencyKey
131
+ ? await makeDeterministicId(`checkout:${resolveOrgId(actor)}:${body.idempotencyKey}`)
132
+ : makeId();
129
133
  const checkoutData: CheckoutData = {
130
134
  checkoutId: makeId(),
135
+ orderId,
131
136
  cartId: body.cartId,
132
137
  currency: body.currency ?? "USD",
133
138
  paymentMethodId: body.paymentMethodId,
@@ -235,6 +240,7 @@ export function checkoutRoutes(kernel: Kernel) {
235
240
  );
236
241
 
237
242
  const orderPayload = {
243
+ id: processed.orderId ?? orderId,
238
244
  ...(body.idempotencyKey !== undefined
239
245
  ? { idempotencyKey: body.idempotencyKey }
240
246
  : {}),
@@ -3,6 +3,8 @@ import type { PgDatabase, PgQueryResultHKT } from "drizzle-orm/pg-core";
3
3
  import type { Kernel } from "../../../runtime/kernel.js";
4
4
  import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
5
5
  import { processedWebhookEvents } from "../../../modules/webhooks/schema.js";
6
+ import type { Actor } from "../../../auth/types.js";
7
+ import { toCommerceError } from "../../../kernel/errors.js";
6
8
 
7
9
  type Db = PgDatabase<PgQueryResultHKT, Record<string, unknown>>;
8
10
 
@@ -16,44 +18,62 @@ export function paymentRoutes(kernel: Kernel) {
16
18
  }
17
19
 
18
20
  const event = result.value;
19
- const db = kernel.database.db as Db;
20
-
21
- // Atomic idempotency: INSERT ... ON CONFLICT DO NOTHING ... RETURNING
22
- // If the row already exists, RETURNING yields zero rows → duplicate.
23
- // If the row is new, RETURNING yields one row process the event.
24
- // No TOCTOU race: the UNIQUE constraint on event_id is the single source of truth.
25
- const [inserted] = await db
26
- .insert(processedWebhookEvents)
27
- .values({
28
- eventId: event.id,
29
- provider: "stripe",
30
- eventType: event.type,
31
- })
32
- .onConflictDoNothing()
33
- .returning({ id: processedWebhookEvents.id });
34
-
35
- if (!inserted) {
36
- // Row already existed — this is a duplicate delivery
37
- return c.json({ data: { received: true, duplicate: true } });
38
- }
21
+ const outcome = await kernel.database.transaction(async (transaction) => {
22
+ const db = transaction as Db;
23
+
24
+ // Claim and process in one transaction. If reconciliation throws, the
25
+ // claim rolls back so Stripe can safely retry instead of receiving a
26
+ // false 2xx for an event that never changed commerce state.
27
+ const [inserted] = await db
28
+ .insert(processedWebhookEvents)
29
+ .values({
30
+ eventId: event.id,
31
+ provider: "stripe",
32
+ eventType: event.type,
33
+ })
34
+ .onConflictDoNothing()
35
+ .returning({ id: processedWebhookEvents.id });
36
+
37
+ if (!inserted) return { duplicate: true };
39
38
 
40
- // Process the event (first time only)
41
- if (event.type === "payment_intent.succeeded") {
42
- const data = event.data as Record<string, unknown> | undefined;
43
- const metadata = data?.metadata as Record<string, unknown> | undefined;
44
- if (typeof metadata?.orderId === "string") {
45
- await kernel.services.orders.changeStatus(
46
- {
47
- orderId: metadata.orderId,
48
- newStatus: "confirmed",
49
- reason: "stripe_webhook_payment_intent_succeeded",
50
- },
51
- null,
52
- );
39
+ if (event.type === "payment_intent.succeeded") {
40
+ const data = event.data as Record<string, unknown> | undefined;
41
+ const metadata = data?.metadata as Record<string, unknown> | undefined;
42
+ if (typeof metadata?.orderId === "string") {
43
+ const actor: Actor = {
44
+ type: "api_key",
45
+ userId: "stripe-webhook",
46
+ email: null,
47
+ name: "Stripe webhook",
48
+ vendorId: null,
49
+ organizationId:
50
+ typeof metadata.organizationId === "string"
51
+ ? metadata.organizationId
52
+ : null,
53
+ role: "system",
54
+ permissions: ["orders:update"],
55
+ };
56
+ const changed = await kernel.services.orders.changeStatus(
57
+ {
58
+ orderId: metadata.orderId,
59
+ newStatus: "confirmed",
60
+ reason: "stripe_webhook_payment_intent_succeeded",
61
+ },
62
+ actor,
63
+ {
64
+ tx: transaction,
65
+ actor,
66
+ requestId: c.get("requestId"),
67
+ },
68
+ );
69
+ if (!changed.ok) throw toCommerceError(changed.error);
70
+ }
53
71
  }
54
- }
55
72
 
56
- return c.json({ data: { received: true } });
73
+ return { duplicate: false };
74
+ });
75
+
76
+ return c.json({ data: { received: true, ...(outcome.duplicate ? { duplicate: true } : {}) } });
57
77
  });
58
78
 
59
79
  return router;
@@ -3,6 +3,8 @@ import type { Kernel } from "../../../runtime/kernel.js";
3
3
  import { searchRoute, suggestRoute } from "../schemas/search.js";
4
4
  import { type AppEnv, mapErrorToResponse, mapErrorToStatus } from "../utils.js";
5
5
 
6
+ const SAFE_ATTRIBUTE_NAME = /^[A-Za-z0-9_-]+$/;
7
+
6
8
  export function searchRoutes(kernel: Kernel) {
7
9
  const router = new OpenAPIHono<AppEnv>();
8
10
 
@@ -21,6 +23,19 @@ export function searchRoutes(kernel: Kernel) {
21
23
  .map((facet) => facet.trim())
22
24
  .filter(Boolean);
23
25
 
26
+ const attributes: Record<string, string | string[]> = {};
27
+ for (const [key, values] of Object.entries(c.req.queries())) {
28
+ if (!key.startsWith("attr.")) continue;
29
+ const name = key.slice("attr.".length);
30
+ if (!SAFE_ATTRIBUTE_NAME.test(name)) {
31
+ return c.json(
32
+ { error: "invalid_argument", field: key, message: `Invalid attribute filter name: ${name}` },
33
+ 422,
34
+ );
35
+ }
36
+ attributes[name] = values.length === 1 ? values[0]! : values;
37
+ }
38
+
24
39
  const result = await kernel.services.search.query({
25
40
  query: q,
26
41
  page,
@@ -30,6 +45,7 @@ export function searchRoutes(kernel: Kernel) {
30
45
  ...(category ? { category } : {}),
31
46
  ...(brand ? { brand } : {}),
32
47
  ...(status ? { status } : {}),
48
+ ...(Object.keys(attributes).length > 0 ? { attributes } : {}),
33
49
  },
34
50
  ...(facets.length > 0 ? { facets } : {}),
35
51
  }, { actor: c.get("actor"), tx: null, requestId: "" });
@@ -0,0 +1,29 @@
1
+ import { createRoute, z } from "@hono/zod-openapi";
2
+ import { errorResponses } from "./shared.js";
3
+
4
+ const ProposalResponse = z.object({
5
+ data: z.object({
6
+ items: z.array(z.any()),
7
+ total: z.number(),
8
+ page: z.number(),
9
+ limit: z.number(),
10
+ }),
11
+ });
12
+
13
+ const ListQuery = z.object({
14
+ entityType: z.string().min(1).optional(),
15
+ page: z.string().max(10).optional(),
16
+ limit: z.string().max(10).optional(),
17
+ });
18
+
19
+ export const listCustomFieldProposalsRoute = createRoute({
20
+ method: "get",
21
+ path: "/custom-field-proposals",
22
+ tags: ["Admin"],
23
+ summary: "List proposed custom-field values",
24
+ request: { query: ListQuery },
25
+ responses: {
26
+ 200: { content: { "application/json": { schema: ProposalResponse } }, description: "Proposed custom fields." },
27
+ ...errorResponses,
28
+ },
29
+ });
@@ -0,0 +1,120 @@
1
+ import { createRoute, z } from "@hono/zod-openapi";
2
+ import { errorResponses } from "./shared.js";
3
+
4
+ const FieldType = z.enum([
5
+ "text",
6
+ "number",
7
+ "boolean",
8
+ "date",
9
+ "json",
10
+ "relation",
11
+ "select",
12
+ ]);
13
+
14
+ const CreateBody = z
15
+ .object({
16
+ entityType: z.string().min(1),
17
+ name: z.string().min(1),
18
+ type: FieldType,
19
+ unit: z.string().nullable().optional(),
20
+ options: z.array(z.string()).nullable().optional(),
21
+ target: z.string().nullable().optional(),
22
+ filterable: z.boolean().optional(),
23
+ localized: z.boolean().optional(),
24
+ sortOrder: z.number().int().optional(),
25
+ })
26
+ .openapi("CreateEntityFieldDefinitionRequest");
27
+
28
+ const UpdateBody = z
29
+ .object({
30
+ entityType: z.string().min(1).optional(),
31
+ name: z.string().min(1).optional(),
32
+ type: FieldType.optional(),
33
+ unit: z.string().nullable().optional(),
34
+ options: z.array(z.string()).nullable().optional(),
35
+ target: z.string().nullable().optional(),
36
+ filterable: z.boolean().optional(),
37
+ localized: z.boolean().optional(),
38
+ sortOrder: z.number().int().optional(),
39
+ })
40
+ .openapi("UpdateEntityFieldDefinitionRequest");
41
+
42
+ const IdParam = z.object({ id: z.uuid() });
43
+ const ListQuery = z.object({ entityType: z.string().min(1).optional() });
44
+ const DataResponse = z
45
+ .object({ data: z.any() })
46
+ .openapi("EntityFieldDefinitionResponse");
47
+
48
+ export const createEntityFieldDefinitionRoute = createRoute({
49
+ method: "post",
50
+ path: "/entity-field-definitions",
51
+ tags: ["Admin"],
52
+ summary: "Create a runtime entity field definition",
53
+ request: {
54
+ body: {
55
+ content: { "application/json": { schema: CreateBody } },
56
+ required: true,
57
+ },
58
+ },
59
+ responses: {
60
+ 201: {
61
+ content: { "application/json": { schema: DataResponse } },
62
+ description: "Definition created.",
63
+ },
64
+ ...errorResponses,
65
+ },
66
+ });
67
+
68
+ export const listEntityFieldDefinitionsRoute = createRoute({
69
+ method: "get",
70
+ path: "/entity-field-definitions",
71
+ tags: ["Admin"],
72
+ summary: "List runtime entity field definitions",
73
+ request: { query: ListQuery },
74
+ responses: {
75
+ 200: {
76
+ content: { "application/json": { schema: DataResponse } },
77
+ description: "Definitions.",
78
+ },
79
+ ...errorResponses,
80
+ },
81
+ });
82
+
83
+ export const updateEntityFieldDefinitionRoute = createRoute({
84
+ method: "patch",
85
+ path: "/entity-field-definitions/{id}",
86
+ tags: ["Admin"],
87
+ summary: "Update a runtime entity field definition",
88
+ request: {
89
+ params: IdParam,
90
+ body: {
91
+ content: { "application/json": { schema: UpdateBody } },
92
+ required: true,
93
+ },
94
+ },
95
+ responses: {
96
+ 200: {
97
+ content: { "application/json": { schema: DataResponse } },
98
+ description: "Definition updated.",
99
+ },
100
+ ...errorResponses,
101
+ },
102
+ });
103
+
104
+ export const archiveEntityFieldDefinitionRoute = createRoute({
105
+ method: "post",
106
+ path: "/entity-field-definitions/{id}/archive",
107
+ tags: ["Admin"],
108
+ summary: "Archive a runtime entity field definition",
109
+ request: { params: IdParam },
110
+ responses: {
111
+ 200: {
112
+ content: { "application/json": { schema: DataResponse } },
113
+ description: "Definition archived.",
114
+ },
115
+ ...errorResponses,
116
+ },
117
+ });
118
+
119
+ export type CreateEntityFieldDefinitionBody = z.infer<typeof CreateBody>;
120
+ export type UpdateEntityFieldDefinitionBody = z.infer<typeof UpdateBody>;
@@ -19,6 +19,17 @@ import {
19
19
 
20
20
  const DataResponse = CatalogEntityResponse;
21
21
  const DataWithPaginationResponse = CatalogEntityListResponse;
22
+ const FieldOwnershipSchema = z.object({
23
+ id: z.string(),
24
+ organizationId: z.string(),
25
+ entityId: z.string(),
26
+ variantId: z.string().nullable(),
27
+ storeId: z.string().nullable(),
28
+ fieldPath: z.string(),
29
+ owner: z.enum(["platform", "store", "shared"]),
30
+ updatedAt: z.string(),
31
+ }).openapi("CatalogFieldOwnership");
32
+ const FieldOwnershipListResponse = z.object({ data: z.array(FieldOwnershipSchema) }).openapi("CatalogFieldOwnershipListResponse");
22
33
 
23
34
  // ─── Path Params ────────────────────────────────────────────────────────────
24
35
 
@@ -35,6 +46,15 @@ const EntityIdLocaleParam = z.object({
35
46
  locale: z.string().min(1).openapi({ example: "en" }),
36
47
  });
37
48
 
49
+ const EntityCustomFieldParam = z.object({
50
+ id: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
51
+ fieldName: z.string().min(1).openapi({ example: "warranty" }),
52
+ });
53
+
54
+ const CustomFieldReviewQuery = z.object({
55
+ locale: z.string().min(1).optional().openapi({ example: "en" }),
56
+ });
57
+
38
58
  const CategoryIdParam = z.object({
39
59
  categoryId: z.uuid().openapi({ example: "550e8400-e29b-41d4-a716-446655440000" }),
40
60
  });
@@ -104,6 +124,48 @@ export const getEntityAttributesRoute = createRoute({
104
124
  },
105
125
  });
106
126
 
127
+ export const listFieldOwnershipRoute = createRoute({
128
+ method: "get",
129
+ path: "/entities/{id}/field-ownership",
130
+ tags: ["Catalog"],
131
+ summary: "List field ownership for a catalog entity",
132
+ request: {
133
+ params: EntityIdParam,
134
+ query: z.object({ storeId: z.string().optional() }),
135
+ },
136
+ responses: {
137
+ 200: { content: { "application/json": { schema: FieldOwnershipListResponse } }, description: "Success" },
138
+ ...errorResponses,
139
+ },
140
+ });
141
+
142
+ export const setFieldOwnershipRoute = createRoute({
143
+ method: "put",
144
+ path: "/entities/{id}/field-ownership",
145
+ tags: ["Catalog"],
146
+ summary: "Set field ownership for a catalog entity",
147
+ request: {
148
+ params: EntityIdParam,
149
+ body: {
150
+ content: {
151
+ "application/json": {
152
+ schema: z.object({
153
+ fieldPath: z.string().min(1),
154
+ owner: z.enum(["platform", "store", "shared"]),
155
+ storeId: z.string().min(1).nullable().optional(),
156
+ variantId: z.uuid().nullable().optional(),
157
+ }),
158
+ },
159
+ },
160
+ required: true,
161
+ },
162
+ },
163
+ responses: {
164
+ 200: { content: { "application/json": { schema: z.object({ data: z.object({ updated: z.literal(true) }) }) } }, description: "Updated" },
165
+ ...errorResponses,
166
+ },
167
+ });
168
+
107
169
  export const listCategoriesRoute = createRoute({
108
170
  method: "get",
109
171
  path: "/categories",
@@ -312,6 +374,36 @@ export const setEntityAttributesRoute = createRoute({
312
374
  },
313
375
  });
314
376
 
377
+ export const approveCustomFieldRoute = createRoute({
378
+ method: "post",
379
+ path: "/entities/{id}/custom-fields/{fieldName}/approve",
380
+ tags: ["Catalog"],
381
+ summary: "Approve a proposed custom-field value",
382
+ request: {
383
+ params: EntityCustomFieldParam,
384
+ query: CustomFieldReviewQuery,
385
+ },
386
+ responses: {
387
+ 200: { content: { "application/json": { schema: DataResponse } }, description: "Approved" },
388
+ ...errorResponses,
389
+ },
390
+ });
391
+
392
+ export const rejectCustomFieldRoute = createRoute({
393
+ method: "post",
394
+ path: "/entities/{id}/custom-fields/{fieldName}/reject",
395
+ tags: ["Catalog"],
396
+ summary: "Reject a proposed custom-field value",
397
+ request: {
398
+ params: EntityCustomFieldParam,
399
+ query: CustomFieldReviewQuery,
400
+ },
401
+ responses: {
402
+ 200: { content: { "application/json": { schema: DataResponse } }, description: "Rejected" },
403
+ ...errorResponses,
404
+ },
405
+ });
406
+
315
407
  export const createCategoryRoute = createRoute({
316
408
  method: "post",
317
409
  path: "/categories",
@@ -8,6 +8,8 @@ export const searchRoute = createRoute({
8
8
  path: "/",
9
9
  tags: ["Search"],
10
10
  summary: "Search catalog entities",
11
+ description:
12
+ "Attribute filters use repeatable `attr.<name>=<value>` query parameters: repeated values for one name are ORed, distinct names are ANDed. Attribute names must match `^[A-Za-z0-9_-]+$`; anything else returns 422. Request attribute facet counts by naming the attribute in `facets`.",
11
13
  request: {
12
14
  query: z.object({
13
15
  q: z.string().max(500).optional(),
@@ -11,6 +11,9 @@ const statusByCode: Record<string, ContentfulStatusCode> = {
11
11
  CONFLICT: 409,
12
12
  INVALID_TRANSITION: 422,
13
13
  ORG_RESOLUTION_FAILED: 503,
14
+ WEBHOOK_SIGNATURE_MISSING: 400,
15
+ WEBHOOK_VERIFICATION_FAILED: 400,
16
+ WEBHOOK_SECRET_MISSING: 503,
14
17
  };
15
18
 
16
19
  export function mapErrorToStatus(error: unknown): ContentfulStatusCode {