@voyant-travel/custom-fields 0.2.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/api-runtime.d.ts +10 -0
- package/dist/api-runtime.js +13 -0
- package/dist/contracts.d.ts +81 -0
- package/dist/contracts.js +54 -0
- package/dist/contracts.test.d.ts +1 -0
- package/dist/contracts.test.js +34 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/registry.d.ts +8 -0
- package/dist/registry.js +61 -0
- package/dist/registry.test.d.ts +1 -0
- package/dist/registry.test.js +152 -0
- package/dist/routes.d.ts +15 -0
- package/dist/routes.js +210 -0
- package/dist/runtime-contributor.d.ts +10 -0
- package/dist/runtime-contributor.js +22 -0
- package/dist/runtime-contributor.test.d.ts +1 -0
- package/dist/runtime-contributor.test.js +30 -0
- package/dist/schema.d.ts +316 -0
- package/dist/schema.js +72 -0
- package/dist/service.d.ts +236 -0
- package/dist/service.js +304 -0
- package/dist/service.test.d.ts +1 -0
- package/dist/service.test.js +251 -0
- package/dist/target-capabilities.d.ts +12 -0
- package/dist/target-capabilities.js +11 -0
- package/dist/target-capabilities.test.d.ts +1 -0
- package/dist/target-capabilities.test.js +10 -0
- package/dist/targets.d.ts +10 -0
- package/dist/targets.js +17 -0
- package/dist/targets.test.d.ts +1 -0
- package/dist/targets.test.js +35 -0
- package/dist/value-contracts.d.ts +37 -0
- package/dist/value-contracts.js +36 -0
- package/dist/value-contracts.test.d.ts +1 -0
- package/dist/value-contracts.test.js +24 -0
- package/dist/value-mapping.d.ts +20 -0
- package/dist/value-mapping.js +68 -0
- package/dist/value-mapping.test.d.ts +1 -0
- package/dist/value-mapping.test.js +43 -0
- package/dist/value-service.d.ts +29 -0
- package/dist/value-service.js +208 -0
- package/dist/value-service.test.d.ts +1 -0
- package/dist/value-service.test.js +155 -0
- package/dist/voyant.d.ts +2 -0
- package/dist/voyant.js +104 -0
- package/migrations/0000_custom_fields_authority.sql +32 -0
- package/migrations/20260716000100_custom_field_namespace_ownership.sql +32 -0
- package/migrations/meta/_journal.json +20 -0
- package/openapi/admin/custom-fields.json +31 -0
- package/package.json +123 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createCustomFieldRegistry, validateCustomFields } from "@voyant-travel/core/custom-fields";
|
|
2
|
+
import { ApiHttpError, RequestValidationError } from "@voyant-travel/hono";
|
|
3
|
+
import { and, eq, isNull } from "drizzle-orm";
|
|
4
|
+
import { customFieldDefinitionFromRow } from "./registry.js";
|
|
5
|
+
import { customFieldDefinitions } from "./schema.js";
|
|
6
|
+
import { assertCustomFieldDefinitionOwner } from "./service.js";
|
|
7
|
+
import { jsonbValueFromTypedCustomFieldValue, parseSyntheticCustomFieldValueId, syntheticCustomFieldValueId, typedCustomFieldValueFromJsonb, } from "./value-mapping.js";
|
|
8
|
+
function ownerWhere(owner) {
|
|
9
|
+
return owner.kind === "operator"
|
|
10
|
+
? and(eq(customFieldDefinitions.ownerKind, "operator"), eq(customFieldDefinitions.namespace, "custom"), isNull(customFieldDefinitions.ownerId))
|
|
11
|
+
: and(eq(customFieldDefinitions.ownerKind, owner.kind), eq(customFieldDefinitions.namespace, owner.namespace), eq(customFieldDefinitions.ownerId, owner.ownerId));
|
|
12
|
+
}
|
|
13
|
+
function ownerContext(owner) {
|
|
14
|
+
return {
|
|
15
|
+
kind: owner.kind,
|
|
16
|
+
namespace: owner.namespace,
|
|
17
|
+
...(owner.ownerId ? { ownerId: owner.ownerId } : {}),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
function definitionContext(definition) {
|
|
21
|
+
return {
|
|
22
|
+
id: definition.id,
|
|
23
|
+
entityType: definition.entityType,
|
|
24
|
+
namespace: definition.namespace,
|
|
25
|
+
key: definition.key,
|
|
26
|
+
fieldType: definition.fieldType,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function operationFor(operations, entityType) {
|
|
30
|
+
const matches = operations.filter((operation) => operation.supports(entityType));
|
|
31
|
+
if (matches.length !== 1) {
|
|
32
|
+
throw new ApiHttpError(`No unique custom-field value provider owns target "${entityType}".`, {
|
|
33
|
+
status: 400,
|
|
34
|
+
code: "unsupported_custom_field_target",
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return matches[0];
|
|
38
|
+
}
|
|
39
|
+
function namespaceValues(value) {
|
|
40
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
41
|
+
? value
|
|
42
|
+
: null;
|
|
43
|
+
}
|
|
44
|
+
const typedValueColumns = [
|
|
45
|
+
"textValue",
|
|
46
|
+
"numberValue",
|
|
47
|
+
"dateValue",
|
|
48
|
+
"booleanValue",
|
|
49
|
+
"monetaryValueCents",
|
|
50
|
+
"currencyCode",
|
|
51
|
+
"jsonValue",
|
|
52
|
+
];
|
|
53
|
+
function validatedJsonbValue(definition, input) {
|
|
54
|
+
const value = jsonbValueFromTypedCustomFieldValue(definition.fieldType, input);
|
|
55
|
+
const expectedColumns = definition.fieldType === "monetary"
|
|
56
|
+
? new Set(["monetaryValueCents", "currencyCode"])
|
|
57
|
+
: new Set([
|
|
58
|
+
definition.fieldType === "double"
|
|
59
|
+
? "numberValue"
|
|
60
|
+
: definition.fieldType === "date"
|
|
61
|
+
? "dateValue"
|
|
62
|
+
: definition.fieldType === "boolean"
|
|
63
|
+
? "booleanValue"
|
|
64
|
+
: ["set", "json", "address"].includes(definition.fieldType)
|
|
65
|
+
? "jsonValue"
|
|
66
|
+
: "textValue",
|
|
67
|
+
]);
|
|
68
|
+
const unexpected = typedValueColumns.filter((column) => !expectedColumns.has(column) && input[column] != null);
|
|
69
|
+
const coreDefinition = customFieldDefinitionFromRow(definition);
|
|
70
|
+
if (!coreDefinition) {
|
|
71
|
+
throw new RequestValidationError(`custom field "${definition.key}" is not active`);
|
|
72
|
+
}
|
|
73
|
+
const result = validateCustomFields(createCustomFieldRegistry([coreDefinition]), definition.entityType, { [definition.namespace]: { [definition.key]: value } });
|
|
74
|
+
const validated = result.value[definition.namespace]?.[definition.key];
|
|
75
|
+
if (!result.ok || validated === undefined || unexpected.length > 0) {
|
|
76
|
+
const messages = [
|
|
77
|
+
...result.errors.map((error) => error.message),
|
|
78
|
+
...(validated === undefined ? ["must provide a non-null value"] : []),
|
|
79
|
+
...(unexpected.length > 0
|
|
80
|
+
? [`unexpected typed value column(s): ${unexpected.join(", ")}`]
|
|
81
|
+
: []),
|
|
82
|
+
];
|
|
83
|
+
throw new RequestValidationError(`Invalid value for custom field "${definition.key}"`, {
|
|
84
|
+
fields: {
|
|
85
|
+
fieldErrors: { [`${definition.namespace}.${definition.key}`]: messages },
|
|
86
|
+
formErrors: [],
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return validated;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Generic value orchestration. It owns definition authorization and locking,
|
|
94
|
+
* while additive runtime providers own only queries against their entity tables.
|
|
95
|
+
*/
|
|
96
|
+
export function createCustomFieldValueService(operations) {
|
|
97
|
+
const listForOwner = async (db, owner, query) => {
|
|
98
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
99
|
+
if (!query.entityType) {
|
|
100
|
+
return {
|
|
101
|
+
data: [],
|
|
102
|
+
total: 0,
|
|
103
|
+
limit: query.limit,
|
|
104
|
+
offset: query.offset,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
const operation = operationFor(operations, query.entityType);
|
|
108
|
+
const definitions = await db
|
|
109
|
+
.select()
|
|
110
|
+
.from(customFieldDefinitions)
|
|
111
|
+
.where(and(eq(customFieldDefinitions.entityType, query.entityType), eq(customFieldDefinitions.lifecycleState, "active"), ownerWhere(owner)));
|
|
112
|
+
const definitionsByIdentity = new Map(definitions.map((definition) => [
|
|
113
|
+
`${definition.namespace}\u0000${definition.key}`,
|
|
114
|
+
definition,
|
|
115
|
+
]));
|
|
116
|
+
const entities = await operation.list(db, ownerContext(owner), {
|
|
117
|
+
entityType: query.entityType,
|
|
118
|
+
...(query.entityId ? { entityId: query.entityId } : {}),
|
|
119
|
+
});
|
|
120
|
+
const data = [];
|
|
121
|
+
for (const entity of entities) {
|
|
122
|
+
for (const [namespace, rawValues] of Object.entries(entity.customFields)) {
|
|
123
|
+
const values = namespaceValues(rawValues);
|
|
124
|
+
if (!values)
|
|
125
|
+
continue;
|
|
126
|
+
for (const [key, value] of Object.entries(values)) {
|
|
127
|
+
const definition = definitionsByIdentity.get(`${namespace}\u0000${key}`);
|
|
128
|
+
if (!definition || (query.definitionId && definition.id !== query.definitionId))
|
|
129
|
+
continue;
|
|
130
|
+
data.push({
|
|
131
|
+
id: syntheticCustomFieldValueId(definition.entityType, entity.entityId, definition.namespace, definition.id),
|
|
132
|
+
definitionId: definition.id,
|
|
133
|
+
entityType: definition.entityType,
|
|
134
|
+
entityId: entity.entityId,
|
|
135
|
+
namespace: definition.namespace,
|
|
136
|
+
key: definition.key,
|
|
137
|
+
...typedCustomFieldValueFromJsonb(definition.fieldType, value),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
data: data.slice(query.offset, query.offset + query.limit),
|
|
144
|
+
total: data.length,
|
|
145
|
+
limit: query.limit,
|
|
146
|
+
offset: query.offset,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
const upsertForOwner = async (db, owner, definitionId, input) => {
|
|
150
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
151
|
+
return db.transaction(async (tx) => {
|
|
152
|
+
const [definition] = await tx
|
|
153
|
+
.select()
|
|
154
|
+
.from(customFieldDefinitions)
|
|
155
|
+
.where(and(eq(customFieldDefinitions.id, definitionId), eq(customFieldDefinitions.lifecycleState, "active"), ownerWhere(owner)))
|
|
156
|
+
.for("update")
|
|
157
|
+
.limit(1);
|
|
158
|
+
if (!definition) {
|
|
159
|
+
throw new ApiHttpError(`no custom-field definition "${definitionId}"`, {
|
|
160
|
+
status: 404,
|
|
161
|
+
code: "not_found",
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (input.entityType !== definition.entityType) {
|
|
165
|
+
throw new RequestValidationError(`custom field "${definition.key}" belongs to ${definition.entityType}, not ${input.entityType}`);
|
|
166
|
+
}
|
|
167
|
+
const value = validatedJsonbValue(definition, input);
|
|
168
|
+
const updated = await operationFor(operations, definition.entityType).upsert(tx, ownerContext(owner), { definition: definitionContext(definition), entityId: input.entityId, value });
|
|
169
|
+
if (!updated) {
|
|
170
|
+
throw new ApiHttpError(`${definition.entityType} "${input.entityId}" not found`, {
|
|
171
|
+
status: 404,
|
|
172
|
+
code: "not_found",
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
id: syntheticCustomFieldValueId(definition.entityType, input.entityId, definition.namespace, definition.id),
|
|
177
|
+
definitionId: definition.id,
|
|
178
|
+
entityType: definition.entityType,
|
|
179
|
+
entityId: input.entityId,
|
|
180
|
+
namespace: definition.namespace,
|
|
181
|
+
key: definition.key,
|
|
182
|
+
...typedCustomFieldValueFromJsonb(definition.fieldType, value),
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
};
|
|
186
|
+
const deleteForOwner = async (db, owner, id) => {
|
|
187
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
188
|
+
const parsed = parseSyntheticCustomFieldValueId(id);
|
|
189
|
+
if (!parsed || parsed.namespace !== owner.namespace)
|
|
190
|
+
return null;
|
|
191
|
+
return db.transaction(async (tx) => {
|
|
192
|
+
const [definition] = await tx
|
|
193
|
+
.select()
|
|
194
|
+
.from(customFieldDefinitions)
|
|
195
|
+
.where(and(eq(customFieldDefinitions.id, parsed.definitionId), eq(customFieldDefinitions.lifecycleState, "active"), ownerWhere(owner)))
|
|
196
|
+
.for("update")
|
|
197
|
+
.limit(1);
|
|
198
|
+
if (!definition ||
|
|
199
|
+
definition.entityType !== parsed.entityType ||
|
|
200
|
+
definition.namespace !== parsed.namespace) {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
const deleted = await operationFor(operations, definition.entityType).delete(tx, ownerContext(owner), { definition: definitionContext(definition), entityId: parsed.entityId });
|
|
204
|
+
return deleted ? { id } : null;
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
return { listForOwner, upsertForOwner, deleteForOwner };
|
|
208
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createAppCustomFieldDefinitionOwner, createCustomFieldsService, operatorCustomFieldDefinitionOwner, } from "./service.js";
|
|
3
|
+
import { createCustomFieldTargetRegistry } from "./targets.js";
|
|
4
|
+
const targets = createCustomFieldTargetRegistry([
|
|
5
|
+
{
|
|
6
|
+
id: "person",
|
|
7
|
+
namespace: "relationships",
|
|
8
|
+
label: "Person",
|
|
9
|
+
fieldTypes: ["text"],
|
|
10
|
+
capabilities: ["read", "write"],
|
|
11
|
+
ownerUnitId: "@voyant-travel/relationships",
|
|
12
|
+
},
|
|
13
|
+
]);
|
|
14
|
+
const appDefinition = {
|
|
15
|
+
id: "cfd_app",
|
|
16
|
+
entityType: "person",
|
|
17
|
+
namespace: "app--acme-7f3",
|
|
18
|
+
key: "external_id",
|
|
19
|
+
fieldType: "text",
|
|
20
|
+
lifecycleState: "active",
|
|
21
|
+
label: "External ID",
|
|
22
|
+
isRequired: false,
|
|
23
|
+
isSearchable: false,
|
|
24
|
+
isExportable: true,
|
|
25
|
+
isInvoiceable: false,
|
|
26
|
+
options: null,
|
|
27
|
+
};
|
|
28
|
+
function listDb(definitions) {
|
|
29
|
+
return {
|
|
30
|
+
select: () => ({
|
|
31
|
+
from: () => ({
|
|
32
|
+
where: async () => definitions,
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function lockedDefinitionDb(definition) {
|
|
38
|
+
const tx = {
|
|
39
|
+
select: () => ({
|
|
40
|
+
from: () => ({
|
|
41
|
+
where: () => ({
|
|
42
|
+
for: () => ({ limit: async () => [definition] }),
|
|
43
|
+
}),
|
|
44
|
+
}),
|
|
45
|
+
}),
|
|
46
|
+
};
|
|
47
|
+
return {
|
|
48
|
+
transaction: async (callback) => callback(tx),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
describe("generic custom-field value orchestration", () => {
|
|
52
|
+
it("keeps same-key values independent by trusted owner namespace", async () => {
|
|
53
|
+
const list = vi.fn(async () => [
|
|
54
|
+
{
|
|
55
|
+
entityId: "pers_1",
|
|
56
|
+
customFields: {
|
|
57
|
+
custom: { external_id: "operator-value" },
|
|
58
|
+
"app--acme-7f3": { external_id: "app-value" },
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
]);
|
|
62
|
+
const operations = {
|
|
63
|
+
supports: (entityType) => entityType === "person",
|
|
64
|
+
list,
|
|
65
|
+
upsert: async () => true,
|
|
66
|
+
delete: async () => true,
|
|
67
|
+
};
|
|
68
|
+
const appOwner = createAppCustomFieldDefinitionOwner({
|
|
69
|
+
appId: "app_acme",
|
|
70
|
+
namespace: "app--acme-7f3",
|
|
71
|
+
});
|
|
72
|
+
const service = createCustomFieldsService(targets, [], [operations]);
|
|
73
|
+
await expect(service.values.listForOwner(listDb([appDefinition]), appOwner, {
|
|
74
|
+
entityType: "person",
|
|
75
|
+
entityId: "pers_1",
|
|
76
|
+
limit: 50,
|
|
77
|
+
offset: 0,
|
|
78
|
+
})).resolves.toMatchObject({
|
|
79
|
+
total: 1,
|
|
80
|
+
data: [
|
|
81
|
+
{
|
|
82
|
+
id: "person::pers_1::app--acme-7f3::cfd_app",
|
|
83
|
+
namespace: "app--acme-7f3",
|
|
84
|
+
key: "external_id",
|
|
85
|
+
textValue: "app-value",
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
});
|
|
89
|
+
expect(list).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ kind: "app", namespace: "app--acme-7f3", ownerId: "app_acme" }), { entityType: "person", entityId: "pers_1" });
|
|
90
|
+
});
|
|
91
|
+
it("fails closed when no selected provider owns the requested target", async () => {
|
|
92
|
+
const service = createCustomFieldsService(targets);
|
|
93
|
+
await expect(service.values.listForOwner({}, operatorCustomFieldDefinitionOwner, {
|
|
94
|
+
entityType: "person",
|
|
95
|
+
limit: 50,
|
|
96
|
+
offset: 0,
|
|
97
|
+
})).rejects.toMatchObject({ status: 400, code: "unsupported_custom_field_target" });
|
|
98
|
+
});
|
|
99
|
+
it("validates values against the locked persisted definition before writing", async () => {
|
|
100
|
+
const upsert = vi.fn(async () => true);
|
|
101
|
+
const operations = {
|
|
102
|
+
supports: (entityType) => entityType === "person",
|
|
103
|
+
list: async () => [],
|
|
104
|
+
upsert,
|
|
105
|
+
delete: async () => true,
|
|
106
|
+
};
|
|
107
|
+
const enumDefinition = {
|
|
108
|
+
...appDefinition,
|
|
109
|
+
fieldType: "enum",
|
|
110
|
+
options: [
|
|
111
|
+
{ label: "Gold", value: "gold" },
|
|
112
|
+
{ label: "Silver", value: "silver" },
|
|
113
|
+
],
|
|
114
|
+
};
|
|
115
|
+
const service = createCustomFieldsService(targets, [], [operations]);
|
|
116
|
+
const owner = createAppCustomFieldDefinitionOwner({
|
|
117
|
+
appId: "app_acme",
|
|
118
|
+
namespace: "app--acme-7f3",
|
|
119
|
+
});
|
|
120
|
+
await expect(service.values.upsertForOwner(lockedDefinitionDb(enumDefinition), owner, enumDefinition.id, {
|
|
121
|
+
entityType: "person",
|
|
122
|
+
entityId: "pers_1",
|
|
123
|
+
textValue: "bronze",
|
|
124
|
+
})).rejects.toMatchObject({ status: 400 });
|
|
125
|
+
expect(upsert).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
it("persists valid fractional double values and rejects null or stray columns", async () => {
|
|
128
|
+
const upsert = vi.fn(async () => true);
|
|
129
|
+
const operations = {
|
|
130
|
+
supports: (entityType) => entityType === "person",
|
|
131
|
+
list: async () => [],
|
|
132
|
+
upsert,
|
|
133
|
+
delete: async () => true,
|
|
134
|
+
};
|
|
135
|
+
const doubleDefinition = { ...appDefinition, fieldType: "double" };
|
|
136
|
+
const service = createCustomFieldsService(targets, [], [operations]);
|
|
137
|
+
const owner = createAppCustomFieldDefinitionOwner({
|
|
138
|
+
appId: "app_acme",
|
|
139
|
+
namespace: "app--acme-7f3",
|
|
140
|
+
});
|
|
141
|
+
await service.values.upsertForOwner(lockedDefinitionDb(doubleDefinition), owner, doubleDefinition.id, {
|
|
142
|
+
entityType: "person",
|
|
143
|
+
entityId: "pers_1",
|
|
144
|
+
numberValue: 1.25,
|
|
145
|
+
});
|
|
146
|
+
expect(upsert).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ value: 1.25 }));
|
|
147
|
+
for (const invalid of [{ numberValue: null }, { numberValue: 1.25, textValue: "stray" }]) {
|
|
148
|
+
await expect(service.values.upsertForOwner(lockedDefinitionDb(doubleDefinition), owner, doubleDefinition.id, {
|
|
149
|
+
entityType: "person",
|
|
150
|
+
entityId: "pers_1",
|
|
151
|
+
...invalid,
|
|
152
|
+
})).rejects.toMatchObject({ status: 400 });
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
package/dist/voyant.d.ts
ADDED
package/dist/voyant.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { defineModule, providePort, requirePort } from "@voyant-travel/core/project";
|
|
2
|
+
import { customFieldsRuntimePort, customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, customFieldValueReaderRuntimePort, } from "@voyant-travel/core/runtime-port";
|
|
3
|
+
export const customFieldsVoyantModule = defineModule({
|
|
4
|
+
id: "@voyant-travel/custom-fields",
|
|
5
|
+
packageName: "@voyant-travel/custom-fields",
|
|
6
|
+
localId: "custom-fields",
|
|
7
|
+
provides: { ports: [providePort(customFieldsRuntimePort)] },
|
|
8
|
+
runtimePorts: [
|
|
9
|
+
requirePort(customFieldValueReaderRuntimePort, {
|
|
10
|
+
optional: true,
|
|
11
|
+
cardinality: "many",
|
|
12
|
+
}),
|
|
13
|
+
requirePort(customFieldValueLifecycleRuntimePort, {
|
|
14
|
+
optional: true,
|
|
15
|
+
cardinality: "many",
|
|
16
|
+
}),
|
|
17
|
+
requirePort(customFieldValueOperationsRuntimePort, {
|
|
18
|
+
optional: true,
|
|
19
|
+
cardinality: "many",
|
|
20
|
+
}),
|
|
21
|
+
],
|
|
22
|
+
api: [
|
|
23
|
+
{
|
|
24
|
+
id: "@voyant-travel/custom-fields#api.admin",
|
|
25
|
+
surface: "admin",
|
|
26
|
+
mount: "custom-fields",
|
|
27
|
+
openapi: { document: "custom-fields" },
|
|
28
|
+
resource: "custom-fields",
|
|
29
|
+
transactional: true,
|
|
30
|
+
runtime: {
|
|
31
|
+
entry: "@voyant-travel/custom-fields/api-runtime",
|
|
32
|
+
export: "createCustomFieldsApiModule",
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
schema: [
|
|
37
|
+
{
|
|
38
|
+
id: "@voyant-travel/custom-fields#schema",
|
|
39
|
+
source: "@voyant-travel/custom-fields/schema",
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
migrations: [
|
|
43
|
+
{
|
|
44
|
+
id: "@voyant-travel/custom-fields#migrations",
|
|
45
|
+
source: "./migrations",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
access: {
|
|
49
|
+
resources: [
|
|
50
|
+
{
|
|
51
|
+
id: "@voyant-travel/custom-fields#access.custom-fields",
|
|
52
|
+
resource: "custom-fields",
|
|
53
|
+
label: "Custom fields",
|
|
54
|
+
description: "Manage database-owned custom-field definitions.",
|
|
55
|
+
actions: [
|
|
56
|
+
{
|
|
57
|
+
action: "read",
|
|
58
|
+
label: "View custom fields",
|
|
59
|
+
description: "View custom-field definitions and supported targets.",
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
action: "write",
|
|
63
|
+
label: "Manage custom fields",
|
|
64
|
+
description: "Create and update custom-field definitions.",
|
|
65
|
+
sensitive: true,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
action: "delete",
|
|
69
|
+
label: "Delete custom fields",
|
|
70
|
+
description: "Delete custom-field definitions.",
|
|
71
|
+
sensitive: true,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
},
|
|
77
|
+
admin: {
|
|
78
|
+
compositionOrder: 15,
|
|
79
|
+
runtime: {
|
|
80
|
+
entry: "@voyant-travel/custom-fields-react/admin",
|
|
81
|
+
export: "createSelectedCustomFieldsAdminExtension",
|
|
82
|
+
},
|
|
83
|
+
routes: [
|
|
84
|
+
{
|
|
85
|
+
id: "@voyant-travel/custom-fields#admin.route.settings",
|
|
86
|
+
path: "/settings/custom-fields",
|
|
87
|
+
requiredScopes: ["custom-fields:read"],
|
|
88
|
+
runtime: {
|
|
89
|
+
entry: "@voyant-travel/custom-fields-react/admin",
|
|
90
|
+
export: "createSelectedCustomFieldsAdminExtension",
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
lifecycle: { uninstall: { default: "retain-data", purge: "not-supported" } },
|
|
96
|
+
meta: {
|
|
97
|
+
ownership: "package",
|
|
98
|
+
agentTools: {
|
|
99
|
+
posture: "not-applicable",
|
|
100
|
+
rationale: "Custom-field definition management is an authenticated Settings and domain API surface; this module does not expose agent Tools.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
export default customFieldsVoyantModule;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
DO $$ BEGIN
|
|
2
|
+
CREATE TYPE "public"."custom_field_type" AS ENUM('varchar', 'text', 'double', 'monetary', 'date', 'boolean', 'enum', 'set', 'json', 'address', 'phone');
|
|
3
|
+
EXCEPTION WHEN duplicate_object THEN null;
|
|
4
|
+
END $$;--> statement-breakpoint
|
|
5
|
+
CREATE TABLE IF NOT EXISTS "custom_field_definitions" (
|
|
6
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
7
|
+
"entity_type" text NOT NULL,
|
|
8
|
+
"key" text NOT NULL,
|
|
9
|
+
"label" text NOT NULL,
|
|
10
|
+
"field_type" "custom_field_type" NOT NULL,
|
|
11
|
+
"is_required" boolean DEFAULT false NOT NULL,
|
|
12
|
+
"is_searchable" boolean DEFAULT false NOT NULL,
|
|
13
|
+
"is_exportable" boolean DEFAULT true NOT NULL,
|
|
14
|
+
"is_invoiceable" boolean DEFAULT false NOT NULL,
|
|
15
|
+
"options" jsonb,
|
|
16
|
+
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
|
17
|
+
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
|
18
|
+
);--> statement-breakpoint
|
|
19
|
+
ALTER TABLE IF EXISTS "custom_field_definitions"
|
|
20
|
+
ALTER COLUMN "entity_type" SET DATA TYPE text
|
|
21
|
+
USING "entity_type"::text;--> statement-breakpoint
|
|
22
|
+
ALTER TABLE IF EXISTS "custom_field_definitions"
|
|
23
|
+
ADD COLUMN IF NOT EXISTS "is_exportable" boolean DEFAULT true NOT NULL;--> statement-breakpoint
|
|
24
|
+
ALTER TABLE IF EXISTS "custom_field_definitions"
|
|
25
|
+
ADD COLUMN IF NOT EXISTS "is_invoiceable" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
|
26
|
+
DROP TYPE IF EXISTS "public"."custom_field_target";--> statement-breakpoint
|
|
27
|
+
CREATE INDEX IF NOT EXISTS "idx_custom_field_definitions_entity"
|
|
28
|
+
ON "custom_field_definitions" USING btree ("entity_type");--> statement-breakpoint
|
|
29
|
+
CREATE INDEX IF NOT EXISTS "idx_custom_field_definitions_entity_label"
|
|
30
|
+
ON "custom_field_definitions" USING btree ("entity_type","label");--> statement-breakpoint
|
|
31
|
+
CREATE UNIQUE INDEX IF NOT EXISTS "uidx_custom_field_definitions_key"
|
|
32
|
+
ON "custom_field_definitions" USING btree ("entity_type","key");
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
DELETE FROM "custom_field_definitions";--> statement-breakpoint
|
|
2
|
+
CREATE TYPE "public"."custom_field_owner_kind" AS ENUM('platform', 'operator', 'app');--> statement-breakpoint
|
|
3
|
+
CREATE TYPE "public"."custom_field_lifecycle_state" AS ENUM('active', 'inactive', 'deprecated');--> statement-breakpoint
|
|
4
|
+
ALTER TABLE "custom_field_definitions"
|
|
5
|
+
ADD COLUMN "namespace" text NOT NULL,
|
|
6
|
+
ADD COLUMN "owner_kind" "custom_field_owner_kind" NOT NULL,
|
|
7
|
+
ADD COLUMN "owner_id" text,
|
|
8
|
+
ADD COLUMN "lifecycle_state" "custom_field_lifecycle_state" NOT NULL,
|
|
9
|
+
ADD COLUMN "provenance" jsonb NOT NULL;--> statement-breakpoint
|
|
10
|
+
ALTER TABLE "custom_field_definitions"
|
|
11
|
+
ADD CONSTRAINT "custom_field_definitions_owner_identity"
|
|
12
|
+
CHECK (
|
|
13
|
+
("owner_kind" = 'operator' AND "owner_id" IS NULL AND "namespace" = 'custom')
|
|
14
|
+
OR (
|
|
15
|
+
"owner_kind" = 'platform'
|
|
16
|
+
AND "owner_id" IS NOT NULL
|
|
17
|
+
AND "namespace" <> 'custom'
|
|
18
|
+
AND "namespace" NOT LIKE 'app--%'
|
|
19
|
+
)
|
|
20
|
+
OR (
|
|
21
|
+
"owner_kind" = 'app'
|
|
22
|
+
AND "owner_id" IS NOT NULL
|
|
23
|
+
AND "namespace" LIKE 'app--%'
|
|
24
|
+
)
|
|
25
|
+
);--> statement-breakpoint
|
|
26
|
+
DROP INDEX "uidx_custom_field_definitions_key";--> statement-breakpoint
|
|
27
|
+
CREATE INDEX "idx_custom_field_definitions_owner"
|
|
28
|
+
ON "custom_field_definitions" USING btree ("owner_kind", "owner_id", "lifecycle_state");--> statement-breakpoint
|
|
29
|
+
CREATE INDEX "idx_custom_field_definitions_namespace"
|
|
30
|
+
ON "custom_field_definitions" USING btree ("namespace", "entity_type");--> statement-breakpoint
|
|
31
|
+
CREATE UNIQUE INDEX "uidx_custom_field_definitions_namespace_key"
|
|
32
|
+
ON "custom_field_definitions" USING btree ("entity_type", "namespace", "key");
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "7",
|
|
3
|
+
"dialect": "postgresql",
|
|
4
|
+
"entries": [
|
|
5
|
+
{
|
|
6
|
+
"idx": 0,
|
|
7
|
+
"version": "7",
|
|
8
|
+
"when": 1784192400000,
|
|
9
|
+
"tag": "0000_custom_fields_authority",
|
|
10
|
+
"breakpoints": true
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"idx": 1,
|
|
14
|
+
"version": "7",
|
|
15
|
+
"when": 1784192460000,
|
|
16
|
+
"tag": "20260716000100_custom_field_namespace_ownership",
|
|
17
|
+
"breakpoints": true
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"openapi": "3.1.0",
|
|
3
|
+
"info": {
|
|
4
|
+
"title": "Voyant Custom Fields API",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"description": "Definition identity is target, physical namespace, and key. Operator Settings creates only server-assigned custom-namespace definitions."
|
|
7
|
+
},
|
|
8
|
+
"paths": {
|
|
9
|
+
"/v1/admin/custom-fields/targets": {
|
|
10
|
+
"get": { "summary": "List selected target and field-type capabilities" }
|
|
11
|
+
},
|
|
12
|
+
"/v1/admin/custom-fields": {
|
|
13
|
+
"get": { "summary": "List selected-target custom-field definitions" },
|
|
14
|
+
"post": { "summary": "Create an operator-owned custom-field definition" }
|
|
15
|
+
},
|
|
16
|
+
"/v1/admin/custom-fields/{id}": {
|
|
17
|
+
"get": { "summary": "Get a custom-field definition" },
|
|
18
|
+
"patch": { "summary": "Update a custom-field definition" },
|
|
19
|
+
"delete": { "summary": "Delete a custom-field definition" }
|
|
20
|
+
},
|
|
21
|
+
"/v1/admin/custom-fields/values": {
|
|
22
|
+
"get": { "summary": "List custom-field values" }
|
|
23
|
+
},
|
|
24
|
+
"/v1/admin/custom-fields/{id}/value": {
|
|
25
|
+
"put": { "summary": "Upsert a custom-field value" }
|
|
26
|
+
},
|
|
27
|
+
"/v1/admin/custom-fields/values/{id}": {
|
|
28
|
+
"delete": { "summary": "Delete a custom-field value" }
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|