@porulle/core 0.10.8 → 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 (114) hide show
  1. package/dist/config/types.d.ts +3 -0
  2. package/dist/config/types.d.ts.map +1 -1
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -0
  6. package/dist/interfaces/rest/index.d.ts.map +1 -1
  7. package/dist/interfaces/rest/index.js +4 -0
  8. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts +5 -0
  9. package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts.map +1 -0
  10. package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +22 -0
  11. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts +5 -0
  12. package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts.map +1 -0
  13. package/dist/interfaces/rest/routes/admin/entity-field-definitions.js +39 -0
  14. package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
  15. package/dist/interfaces/rest/routes/catalog.js +37 -2
  16. package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
  17. package/dist/interfaces/rest/routes/search.js +12 -0
  18. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts +134 -0
  19. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts.map +1 -0
  20. package/dist/interfaces/rest/schemas/admin-custom-field-proposals.js +26 -0
  21. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts +592 -0
  22. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts.map +1 -0
  23. package/dist/interfaces/rest/schemas/admin-entity-field-definitions.js +109 -0
  24. package/dist/interfaces/rest/schemas/catalog.d.ts +1364 -401
  25. package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
  26. package/dist/interfaces/rest/schemas/catalog.js +86 -0
  27. package/dist/interfaces/rest/schemas/pricing.d.ts +1 -0
  28. package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
  29. package/dist/interfaces/rest/schemas/search.d.ts +1 -0
  30. package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
  31. package/dist/interfaces/rest/schemas/search.js +1 -0
  32. package/dist/modules/catalog/entity-service.d.ts +5 -3
  33. package/dist/modules/catalog/entity-service.d.ts.map +1 -1
  34. package/dist/modules/catalog/entity-service.js +73 -22
  35. package/dist/modules/catalog/module.d.ts +6 -1
  36. package/dist/modules/catalog/module.d.ts.map +1 -1
  37. package/dist/modules/catalog/module.js +6 -1
  38. package/dist/modules/catalog/ownership.d.ts +5 -0
  39. package/dist/modules/catalog/ownership.d.ts.map +1 -0
  40. package/dist/modules/catalog/ownership.js +11 -0
  41. package/dist/modules/catalog/repository/index.d.ts +52 -1
  42. package/dist/modules/catalog/repository/index.d.ts.map +1 -1
  43. package/dist/modules/catalog/repository/index.js +321 -2
  44. package/dist/modules/catalog/revision.d.ts +4 -0
  45. package/dist/modules/catalog/revision.d.ts.map +1 -0
  46. package/dist/modules/catalog/revision.js +33 -0
  47. package/dist/modules/catalog/schema.d.ts +979 -110
  48. package/dist/modules/catalog/schema.d.ts.map +1 -1
  49. package/dist/modules/catalog/schema.js +112 -2
  50. package/dist/modules/catalog/schemas.d.ts +2 -0
  51. package/dist/modules/catalog/schemas.d.ts.map +1 -1
  52. package/dist/modules/catalog/schemas.js +2 -0
  53. package/dist/modules/catalog/service.d.ts +66 -3
  54. package/dist/modules/catalog/service.d.ts.map +1 -1
  55. package/dist/modules/catalog/service.js +512 -11
  56. package/dist/modules/channels/adapter.d.ts +85 -0
  57. package/dist/modules/channels/adapter.d.ts.map +1 -1
  58. package/dist/modules/channels/adapter.js +9 -1
  59. package/dist/modules/media/module.d.ts +68 -0
  60. package/dist/modules/media/module.d.ts.map +1 -1
  61. package/dist/modules/media/module.js +2 -0
  62. package/dist/modules/media/schema.d.ts +70 -0
  63. package/dist/modules/media/schema.d.ts.map +1 -1
  64. package/dist/modules/media/schema.js +20 -2
  65. package/dist/modules/media/service.d.ts +8 -1
  66. package/dist/modules/media/service.d.ts.map +1 -1
  67. package/dist/modules/media/service.js +66 -18
  68. package/dist/modules/pricing/schema.d.ts +17 -0
  69. package/dist/modules/pricing/schema.d.ts.map +1 -1
  70. package/dist/modules/pricing/schema.js +1 -0
  71. package/dist/modules/pricing/schemas.d.ts +1 -0
  72. package/dist/modules/pricing/schemas.d.ts.map +1 -1
  73. package/dist/modules/pricing/schemas.js +1 -0
  74. package/dist/modules/pricing/service.d.ts.map +1 -1
  75. package/dist/modules/pricing/service.js +6 -1
  76. package/dist/modules/search/adapter.d.ts +2 -0
  77. package/dist/modules/search/adapter.d.ts.map +1 -1
  78. package/dist/modules/search/module.d.ts.map +1 -1
  79. package/dist/modules/search/module.js +2 -0
  80. package/dist/modules/search/service.d.ts +4 -0
  81. package/dist/modules/search/service.d.ts.map +1 -1
  82. package/dist/modules/search/service.js +74 -5
  83. package/dist/runtime/kernel-modules.d.ts +73 -0
  84. package/dist/runtime/kernel-modules.d.ts.map +1 -1
  85. package/package.json +1 -1
  86. package/src/config/types.ts +3 -0
  87. package/src/index.ts +14 -0
  88. package/src/interfaces/rest/index.ts +4 -0
  89. package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +37 -0
  90. package/src/interfaces/rest/routes/admin/entity-field-definitions.ts +89 -0
  91. package/src/interfaces/rest/routes/catalog.ts +85 -0
  92. package/src/interfaces/rest/routes/search.ts +16 -0
  93. package/src/interfaces/rest/schemas/admin-custom-field-proposals.ts +29 -0
  94. package/src/interfaces/rest/schemas/admin-entity-field-definitions.ts +120 -0
  95. package/src/interfaces/rest/schemas/catalog.ts +92 -0
  96. package/src/interfaces/rest/schemas/search.ts +2 -0
  97. package/src/modules/catalog/entity-service.ts +73 -20
  98. package/src/modules/catalog/module.ts +15 -0
  99. package/src/modules/catalog/ownership.ts +17 -0
  100. package/src/modules/catalog/repository/index.ts +547 -1
  101. package/src/modules/catalog/revision.ts +35 -0
  102. package/src/modules/catalog/schema.ts +169 -1
  103. package/src/modules/catalog/schemas.ts +2 -0
  104. package/src/modules/catalog/service.ts +738 -13
  105. package/src/modules/channels/adapter.ts +107 -1
  106. package/src/modules/media/module.ts +2 -0
  107. package/src/modules/media/schema.ts +23 -2
  108. package/src/modules/media/service.ts +84 -23
  109. package/src/modules/pricing/schema.ts +1 -0
  110. package/src/modules/pricing/schemas.ts +1 -0
  111. package/src/modules/pricing/service.ts +7 -2
  112. package/src/modules/search/adapter.ts +2 -0
  113. package/src/modules/search/module.ts +2 -0
  114. package/src/modules/search/service.ts +87 -6
@@ -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(
@@ -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(),
@@ -36,6 +36,7 @@ import type {
36
36
  CreateOptionTypeInput,
37
37
  CreateOptionValueInput,
38
38
  CreateVariantInput,
39
+ EntityFieldDefinitionResolver,
39
40
  } from "./service.js";
40
41
  import type {
41
42
  SellableEntity,
@@ -69,6 +70,7 @@ function getCustomFieldValue(field: SellableCustomField): unknown {
69
70
  switch (field.fieldType) {
70
71
  case "text":
71
72
  case "relation":
73
+ case "select":
72
74
  return field.textValue;
73
75
  case "number":
74
76
  return field.numberValue;
@@ -83,8 +85,14 @@ function getCustomFieldValue(field: SellableCustomField): unknown {
83
85
  }
84
86
  }
85
87
 
88
+ type CustomFieldData = Omit<SellableCustomFieldInsert, "entityId" | "fieldName" | "locale">;
89
+ type ValidatedCustomField = { fieldName: string; locale: string; data: CustomFieldData | null };
90
+
86
91
  export class EntityService {
87
- constructor(private readonly deps: CatalogServiceDeps) {}
92
+ constructor(
93
+ private readonly deps: CatalogServiceDeps,
94
+ private readonly resolveEntityFieldDefinitions: EntityFieldDefinitionResolver,
95
+ ) {}
88
96
 
89
97
  private get repo() {
90
98
  return this.deps.repository;
@@ -102,22 +110,38 @@ export class EntityService {
102
110
  if (entity.sourceStoreId != null) assertPermission(actor, "catalog:sync");
103
111
  }
104
112
 
105
- private async validateAndCreateCustomFields(entityId: string, entityType: string, customFields: Record<string, unknown> | undefined, ctx?: TxContext): Promise<Result<void>> {
106
- if (!customFields) return Ok(undefined);
113
+ private async validateCustomFields(entityType: string, customFields: Record<string, unknown> | undefined, actor: Actor | null, ctx?: TxContext): Promise<Result<ValidatedCustomField[]>> {
114
+ if (!customFields) return Ok([]);
107
115
  const entityConfig = this.deps.config.entities?.[entityType];
108
- if (!entityConfig) return Ok(undefined);
109
- const definitionMap = new Map(entityConfig.fields.map((f) => [f.name, f]));
116
+ const definitions = await this.resolveEntityFieldDefinitions(entityType, actor ?? ctx?.actor ?? null, ctx);
117
+ if (!entityConfig && definitions.length === 0) return Ok([]);
118
+ const definitionMap = new Map(definitions.map((f) => [f.name, f]));
119
+ const validated: ValidatedCustomField[] = [];
110
120
  for (const [name, value] of Object.entries(customFields)) {
111
121
  const def = definitionMap.get(name);
112
122
  if (!def) return Err(new CommerceValidationError(`Unknown custom field: ${name}`));
123
+ if (value === null) {
124
+ validated.push({ fieldName: name, locale: "en", data: null });
125
+ continue;
126
+ }
113
127
  const type = def.type;
128
+ let normalized = value;
114
129
  let valid = false;
115
130
  switch (type) {
116
131
  case "text":
117
132
  case "relation":
118
- case "select":
119
133
  valid = typeof value === "string";
120
134
  break;
135
+ case "select": {
136
+ if (typeof value !== "string") break;
137
+ const trimmed = value.trim();
138
+ if (def.options && !def.options.includes(trimmed)) {
139
+ return Err(new CommerceValidationError(`Value "${trimmed}" is not permitted for field ${name}. Allowed: ${def.options.join(", ")}`));
140
+ }
141
+ normalized = trimmed;
142
+ valid = true;
143
+ break;
144
+ }
121
145
  case "number":
122
146
  valid = typeof value === "number";
123
147
  break;
@@ -134,29 +158,50 @@ export class EntityService {
134
158
  valid = false;
135
159
  }
136
160
  if (!valid) return Err(new CommerceValidationError(`Custom field ${name} expected type ${type}.`));
137
- const fieldType = (type === "select" ? "text" : type) as SellableCustomField["fieldType"];
138
- const insertData: SellableCustomFieldInsert = { entityId, fieldName: name, fieldType };
161
+ const fieldType = type as SellableCustomField["fieldType"];
162
+ const insertData: CustomFieldData = {
163
+ fieldType,
164
+ source: "merchant",
165
+ status: "approved",
166
+ confidence: null,
167
+ evidence: null,
168
+ approvedAt: null,
169
+ approvedBy: null,
170
+ };
139
171
  switch (fieldType) {
140
172
  case "text":
141
173
  case "relation":
142
- insertData.textValue = value as string;
174
+ case "select":
175
+ insertData.textValue = normalized as string;
143
176
  break;
144
177
  case "number":
145
- insertData.numberValue = value as number;
178
+ insertData.numberValue = normalized as number;
146
179
  break;
147
180
  case "boolean":
148
- insertData.booleanValue = value as boolean;
181
+ insertData.booleanValue = normalized as boolean;
149
182
  break;
150
183
  case "date":
151
- insertData.dateValue = value instanceof Date ? value : new Date(value as string);
184
+ insertData.dateValue = normalized instanceof Date ? normalized : new Date(normalized as string);
152
185
  break;
153
186
  case "json":
154
- insertData.jsonValue = value;
187
+ insertData.jsonValue = normalized;
155
188
  break;
156
189
  }
157
- await this.repo.createCustomField(insertData, ctx);
190
+ validated.push({ fieldName: name, locale: "en", data: insertData });
191
+ }
192
+ return Ok(validated);
193
+ }
194
+
195
+ private async writeCustomFields(entityId: string, customFields: ValidatedCustomField[], ctx?: TxContext, upsert = false): Promise<void> {
196
+ for (const customField of customFields) {
197
+ if (customField.data === null) {
198
+ await this.repo.deleteCustomField(entityId, customField.fieldName, customField.locale, ctx);
199
+ } else if (upsert) {
200
+ await this.repo.upsertCustomField(entityId, customField.fieldName, customField.locale, customField.data, ctx);
201
+ } else {
202
+ await this.repo.createCustomField({ ...customField.data, entityId, fieldName: customField.fieldName, locale: customField.locale }, ctx);
203
+ }
158
204
  }
159
- return Ok(undefined);
160
205
  }
161
206
 
162
207
  private async hydrateEntity(entity: SellableEntity, options?: GetOptions, ctx?: TxContext): Promise<CatalogEntityHydrated> {
@@ -185,14 +230,16 @@ export class EntityService {
185
230
  }
186
231
  if (options?.includeCategories) hydrated.categories = await this.repo.findEntityCategories(entity.id, ctx);
187
232
  if (options?.includeBrands) hydrated.brands = await this.repo.findEntityBrands(entity.id, ctx);
233
+ if (options?.includeCustomFields) hydrated.customFields = await this.repo.findCustomFieldsByEntityId(entity.id, ctx);
188
234
  if (options?.includeMedia) {
189
235
  const mediaService = this.deps.services.media as {
190
236
  listEntityMedia?: (
191
237
  entityId: string,
192
238
  opts?: { orgId?: string },
239
+ ctx?: TxContext,
193
240
  ) => Promise<{ ok: boolean; value?: CatalogEntityHydrated["media"] }>;
194
241
  } | undefined;
195
- const mediaResult = await mediaService?.listEntityMedia?.(entity.id, { orgId: entity.organizationId });
242
+ const mediaResult = await mediaService?.listEntityMedia?.(entity.id, { orgId: entity.organizationId }, ctx);
196
243
  hydrated.media = mediaResult?.ok && mediaResult.value ? mediaResult.value : [];
197
244
  }
198
245
  if (options?.includePricing) {
@@ -217,12 +264,15 @@ export class EntityService {
217
264
  const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.create"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
218
265
  const processedInput = await runBeforeHooks(beforeHooks, input, "create", context);
219
266
  if (processedInput.sourceStoreId != null) assertPermission(actor, "catalog:sync");
220
- const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status: "draft", isVisible: false, ...(processedInput.sourceStoreId !== undefined ? { sourceStoreId: processedInput.sourceStoreId } : {}), ...(processedInput.taxClass !== undefined ? { taxClass: processedInput.taxClass } : {}), metadata: processedInput.metadata ?? {} }, ctx);
267
+ const customFieldsResult = await this.validateCustomFields(processedInput.type, processedInput.customFields, actor, ctx);
268
+ if (!customFieldsResult.ok) return customFieldsResult;
269
+ const statuses = ["draft", "active", "archived", "discontinued"] as const;
270
+ const status = statuses.find((value) => value === processedInput.status) ?? "draft";
271
+ const entity = await this.repo.createEntity({ organizationId: orgId, type: processedInput.type, slug: processedInput.slug, status, isVisible: status === "active", ...(processedInput.sourceStoreId !== undefined ? { sourceStoreId: processedInput.sourceStoreId } : {}), ...(processedInput.taxClass !== undefined ? { taxClass: processedInput.taxClass } : {}), metadata: processedInput.metadata ?? {} }, ctx);
221
272
  if (processedInput.attributes) {
222
273
  await this.repo.createAttribute({ entityId: entity.id, locale: processedInput.attributes.locale ?? "en", title: processedInput.attributes.title, subtitle: processedInput.attributes.subtitle, description: processedInput.attributes.description, richDescription: processedInput.attributes.richDescription, seoTitle: processedInput.attributes.seoTitle, seoDescription: processedInput.attributes.seoDescription }, ctx);
223
274
  }
224
- const customFieldsResult = await this.validateAndCreateCustomFields(entity.id, entity.type, processedInput.customFields, ctx);
225
- if (!customFieldsResult.ok) return customFieldsResult;
275
+ await this.writeCustomFields(entity.id, customFieldsResult.value, ctx);
226
276
  const hookReport = await runAfterHooks(afterHooks, null, entity, "create", context);
227
277
  const hydrated = await this.hydrateEntity(entity, undefined, ctx);
228
278
  return Ok(hydrated, hookReport.hasErrors ? { hookErrors: hookReport.errors } : undefined);
@@ -238,8 +288,11 @@ export class EntityService {
238
288
  const afterHooks = this.deps.hooks.resolve("catalog.afterUpdate") as CatalogUpdateAfterHook[];
239
289
  const context: HookContext = createHookContext({ actor, tx: ctx?.tx ?? null, logger: createLogger("catalog.update"), services: this.deps.services, context: { moduleName: "catalog" }, ...hookDatabaseArg(this.deps.database) });
240
290
  const processed = await runBeforeHooks(beforeHooks, input, "update", context);
291
+ const customFieldsResult = await this.validateCustomFields(existing.type, processed.customFields, actor, ctx);
292
+ if (!customFieldsResult.ok) return customFieldsResult;
241
293
  const updated = await this.repo.updateEntity(id, { ...(processed.slug !== undefined ? { slug: processed.slug } : {}), ...(processed.status !== undefined ? { status: processed.status as SellableEntity["status"] } : {}), ...(processed.taxClass !== undefined ? { taxClass: processed.taxClass } : {}), ...(processed.metadata !== undefined ? { metadata: processed.metadata } : {}), ...(processed.isVisible !== undefined ? { isVisible: processed.isVisible } : {}) }, ctx);
242
294
  if (!updated) return Err(new CommerceNotFoundError("Entity not found."));
295
+ await this.writeCustomFields(existing.id, customFieldsResult.value, ctx, true);
243
296
  const hookReport = await runAfterHooks(afterHooks, existing, updated, "update", context);
244
297
  const hydrated = await this.hydrateEntity(updated, undefined, ctx);
245
298
  return Ok(hydrated, hookReport.hasErrors ? { hookErrors: hookReport.errors } : undefined);
@@ -467,7 +520,7 @@ export class EntityService {
467
520
  if (!ov) return Err(new CommerceValidationError(`Option value "${optVal}" does not exist for option type "${optName}".`));
468
521
  optionValueIds.push(ov.id);
469
522
  }
470
- const variant = await this.repo.createVariant({ entityId: input.entityId, status: "active", sortOrder: 0, metadata: {}, ...(input.sku !== undefined ? { sku: input.sku } : {}), ...(input.taxClass !== undefined ? { taxClass: input.taxClass } : {}) }, ctx);
523
+ const variant = await this.repo.createVariant({ entityId: input.entityId, status: "active", sortOrder: 0, metadata: {}, ...(input.sku !== undefined ? { sku: input.sku } : {}), ...(input.barcode !== undefined ? { barcode: input.barcode } : {}), ...(input.taxClass !== undefined ? { taxClass: input.taxClass } : {}) }, ctx);
471
524
  await this.repo.createVariantOptionValues(optionValueIds.map((optionValueId) => ({ variantId: variant.id, optionValueId })), ctx);
472
525
  return Ok(variant);
473
526
  }