@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.
- package/dist/config/types.d.ts +3 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/interfaces/rest/index.d.ts.map +1 -1
- package/dist/interfaces/rest/index.js +4 -0
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts +5 -0
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/custom-field-proposals.js +22 -0
- package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts +5 -0
- package/dist/interfaces/rest/routes/admin/entity-field-definitions.d.ts.map +1 -0
- package/dist/interfaces/rest/routes/admin/entity-field-definitions.js +39 -0
- package/dist/interfaces/rest/routes/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/catalog.js +37 -2
- package/dist/interfaces/rest/routes/search.d.ts.map +1 -1
- package/dist/interfaces/rest/routes/search.js +12 -0
- package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts +134 -0
- package/dist/interfaces/rest/schemas/admin-custom-field-proposals.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-custom-field-proposals.js +26 -0
- package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts +592 -0
- package/dist/interfaces/rest/schemas/admin-entity-field-definitions.d.ts.map +1 -0
- package/dist/interfaces/rest/schemas/admin-entity-field-definitions.js +109 -0
- package/dist/interfaces/rest/schemas/catalog.d.ts +1364 -401
- package/dist/interfaces/rest/schemas/catalog.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/catalog.js +86 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts +1 -0
- package/dist/interfaces/rest/schemas/pricing.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.d.ts +1 -0
- package/dist/interfaces/rest/schemas/search.d.ts.map +1 -1
- package/dist/interfaces/rest/schemas/search.js +1 -0
- package/dist/modules/catalog/entity-service.d.ts +5 -3
- package/dist/modules/catalog/entity-service.d.ts.map +1 -1
- package/dist/modules/catalog/entity-service.js +73 -22
- package/dist/modules/catalog/module.d.ts +6 -1
- package/dist/modules/catalog/module.d.ts.map +1 -1
- package/dist/modules/catalog/module.js +6 -1
- package/dist/modules/catalog/ownership.d.ts +5 -0
- package/dist/modules/catalog/ownership.d.ts.map +1 -0
- package/dist/modules/catalog/ownership.js +11 -0
- package/dist/modules/catalog/repository/index.d.ts +52 -1
- package/dist/modules/catalog/repository/index.d.ts.map +1 -1
- package/dist/modules/catalog/repository/index.js +321 -2
- package/dist/modules/catalog/revision.d.ts +4 -0
- package/dist/modules/catalog/revision.d.ts.map +1 -0
- package/dist/modules/catalog/revision.js +33 -0
- package/dist/modules/catalog/schema.d.ts +979 -110
- package/dist/modules/catalog/schema.d.ts.map +1 -1
- package/dist/modules/catalog/schema.js +112 -2
- package/dist/modules/catalog/schemas.d.ts +2 -0
- package/dist/modules/catalog/schemas.d.ts.map +1 -1
- package/dist/modules/catalog/schemas.js +2 -0
- package/dist/modules/catalog/service.d.ts +66 -3
- package/dist/modules/catalog/service.d.ts.map +1 -1
- package/dist/modules/catalog/service.js +512 -11
- package/dist/modules/channels/adapter.d.ts +85 -0
- package/dist/modules/channels/adapter.d.ts.map +1 -1
- package/dist/modules/channels/adapter.js +9 -1
- package/dist/modules/media/module.d.ts +68 -0
- package/dist/modules/media/module.d.ts.map +1 -1
- package/dist/modules/media/module.js +2 -0
- package/dist/modules/media/schema.d.ts +70 -0
- package/dist/modules/media/schema.d.ts.map +1 -1
- package/dist/modules/media/schema.js +20 -2
- package/dist/modules/media/service.d.ts +8 -1
- package/dist/modules/media/service.d.ts.map +1 -1
- package/dist/modules/media/service.js +66 -18
- package/dist/modules/pricing/schema.d.ts +17 -0
- package/dist/modules/pricing/schema.d.ts.map +1 -1
- package/dist/modules/pricing/schema.js +1 -0
- package/dist/modules/pricing/schemas.d.ts +1 -0
- package/dist/modules/pricing/schemas.d.ts.map +1 -1
- package/dist/modules/pricing/schemas.js +1 -0
- package/dist/modules/pricing/service.d.ts.map +1 -1
- package/dist/modules/pricing/service.js +6 -1
- package/dist/modules/search/adapter.d.ts +2 -0
- package/dist/modules/search/adapter.d.ts.map +1 -1
- package/dist/modules/search/module.d.ts.map +1 -1
- package/dist/modules/search/module.js +2 -0
- package/dist/modules/search/service.d.ts +4 -0
- package/dist/modules/search/service.d.ts.map +1 -1
- package/dist/modules/search/service.js +74 -5
- package/dist/runtime/kernel-modules.d.ts +73 -0
- package/dist/runtime/kernel-modules.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config/types.ts +3 -0
- package/src/index.ts +14 -0
- package/src/interfaces/rest/index.ts +4 -0
- package/src/interfaces/rest/routes/admin/custom-field-proposals.ts +37 -0
- package/src/interfaces/rest/routes/admin/entity-field-definitions.ts +89 -0
- package/src/interfaces/rest/routes/catalog.ts +85 -0
- package/src/interfaces/rest/routes/search.ts +16 -0
- package/src/interfaces/rest/schemas/admin-custom-field-proposals.ts +29 -0
- package/src/interfaces/rest/schemas/admin-entity-field-definitions.ts +120 -0
- package/src/interfaces/rest/schemas/catalog.ts +92 -0
- package/src/interfaces/rest/schemas/search.ts +2 -0
- package/src/modules/catalog/entity-service.ts +73 -20
- package/src/modules/catalog/module.ts +15 -0
- package/src/modules/catalog/ownership.ts +17 -0
- package/src/modules/catalog/repository/index.ts +547 -1
- package/src/modules/catalog/revision.ts +35 -0
- package/src/modules/catalog/schema.ts +169 -1
- package/src/modules/catalog/schemas.ts +2 -0
- package/src/modules/catalog/service.ts +738 -13
- package/src/modules/channels/adapter.ts +107 -1
- package/src/modules/media/module.ts +2 -0
- package/src/modules/media/schema.ts +23 -2
- package/src/modules/media/service.ts +84 -23
- package/src/modules/pricing/schema.ts +1 -0
- package/src/modules/pricing/schemas.ts +1 -0
- package/src/modules/pricing/service.ts +7 -2
- package/src/modules/search/adapter.ts +2 -0
- package/src/modules/search/module.ts +2 -0
- 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(
|
|
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
|
|
106
|
-
if (!customFields) return Ok(
|
|
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
|
-
|
|
109
|
-
|
|
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 =
|
|
138
|
-
const insertData:
|
|
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
|
-
|
|
174
|
+
case "select":
|
|
175
|
+
insertData.textValue = normalized as string;
|
|
143
176
|
break;
|
|
144
177
|
case "number":
|
|
145
|
-
insertData.numberValue =
|
|
178
|
+
insertData.numberValue = normalized as number;
|
|
146
179
|
break;
|
|
147
180
|
case "boolean":
|
|
148
|
-
insertData.booleanValue =
|
|
181
|
+
insertData.booleanValue = normalized as boolean;
|
|
149
182
|
break;
|
|
150
183
|
case "date":
|
|
151
|
-
insertData.dateValue =
|
|
184
|
+
insertData.dateValue = normalized instanceof Date ? normalized : new Date(normalized as string);
|
|
152
185
|
break;
|
|
153
186
|
case "json":
|
|
154
|
-
insertData.jsonValue =
|
|
187
|
+
insertData.jsonValue = normalized;
|
|
155
188
|
break;
|
|
156
189
|
}
|
|
157
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|