@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,251 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { customFieldDefinitionInputSchema } from "./contracts.js";
|
|
3
|
+
import { createAppCustomFieldDefinitionOwner, createCustomFieldsService, createPlatformCustomFieldDefinitionOwner, } from "./service.js";
|
|
4
|
+
import { createCustomFieldTargetRegistry } from "./targets.js";
|
|
5
|
+
const targets = createCustomFieldTargetRegistry([
|
|
6
|
+
{
|
|
7
|
+
id: "booking",
|
|
8
|
+
namespace: "bookings",
|
|
9
|
+
label: "Booking",
|
|
10
|
+
fieldTypes: ["text"],
|
|
11
|
+
capabilities: ["read", "write"],
|
|
12
|
+
ownerUnitId: "@voyant-travel/bookings",
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
15
|
+
const input = customFieldDefinitionInputSchema.parse({
|
|
16
|
+
entityType: "booking",
|
|
17
|
+
key: "external_id",
|
|
18
|
+
label: "External ID",
|
|
19
|
+
fieldType: "text",
|
|
20
|
+
});
|
|
21
|
+
function postgresStub(implementation) {
|
|
22
|
+
const db = Object.create(null);
|
|
23
|
+
Object.assign(db, implementation);
|
|
24
|
+
if (!("transaction" in db)) {
|
|
25
|
+
Object.assign(db, {
|
|
26
|
+
transaction: async (callback) => callback(db),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return db;
|
|
30
|
+
}
|
|
31
|
+
function insertDb() {
|
|
32
|
+
const rows = [];
|
|
33
|
+
return {
|
|
34
|
+
rows,
|
|
35
|
+
db: postgresStub({
|
|
36
|
+
insert: () => ({
|
|
37
|
+
values: (value) => ({
|
|
38
|
+
onConflictDoNothing: () => ({
|
|
39
|
+
returning: async () => {
|
|
40
|
+
const duplicate = rows.some((row) => row.entityType === value.entityType &&
|
|
41
|
+
row.namespace === value.namespace &&
|
|
42
|
+
row.key === value.key);
|
|
43
|
+
if (duplicate)
|
|
44
|
+
return [];
|
|
45
|
+
const row = { id: `definition_${rows.length + 1}`, ...value };
|
|
46
|
+
rows.push(row);
|
|
47
|
+
return [row];
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
}),
|
|
51
|
+
}),
|
|
52
|
+
}),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
describe("custom-field definition ownership", () => {
|
|
56
|
+
it("allows the same key in operator and app namespaces", async () => {
|
|
57
|
+
const { db, rows } = insertDb();
|
|
58
|
+
const service = createCustomFieldsService(targets);
|
|
59
|
+
const app = createAppCustomFieldDefinitionOwner({
|
|
60
|
+
appId: "app_acme",
|
|
61
|
+
namespace: "app--acme-7f3",
|
|
62
|
+
});
|
|
63
|
+
await service.create(db, input);
|
|
64
|
+
await service.createForOwner(db, app, input);
|
|
65
|
+
expect(rows).toEqual(expect.arrayContaining([
|
|
66
|
+
expect.objectContaining({ namespace: "custom", ownerKind: "operator", ownerId: null }),
|
|
67
|
+
expect.objectContaining({
|
|
68
|
+
namespace: "app--acme-7f3",
|
|
69
|
+
ownerKind: "app",
|
|
70
|
+
ownerId: "app_acme",
|
|
71
|
+
}),
|
|
72
|
+
]));
|
|
73
|
+
});
|
|
74
|
+
it("rejects cross-owner mutation before issuing an update", async () => {
|
|
75
|
+
const service = createCustomFieldsService(targets);
|
|
76
|
+
const app = createAppCustomFieldDefinitionOwner({
|
|
77
|
+
appId: "app_acme",
|
|
78
|
+
namespace: "app--acme-7f3",
|
|
79
|
+
});
|
|
80
|
+
const db = postgresStub({
|
|
81
|
+
select: () => ({
|
|
82
|
+
from: () => ({
|
|
83
|
+
where: () => ({
|
|
84
|
+
for: () => ({
|
|
85
|
+
limit: async () => [
|
|
86
|
+
{
|
|
87
|
+
id: "definition_1",
|
|
88
|
+
entityType: "booking",
|
|
89
|
+
namespace: "app--other-92c",
|
|
90
|
+
ownerKind: "app",
|
|
91
|
+
ownerId: "app_other",
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
}),
|
|
95
|
+
}),
|
|
96
|
+
}),
|
|
97
|
+
}),
|
|
98
|
+
update: () => {
|
|
99
|
+
throw new Error("must not update a foreign definition");
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
await expect(service.updateForOwner(db, app, "definition_1", { label: "Changed" })).rejects.toMatchObject({
|
|
103
|
+
status: 403,
|
|
104
|
+
code: "custom_field_definition_read_only",
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
it("renames definitions and stored values in one transaction", async () => {
|
|
108
|
+
const existing = {
|
|
109
|
+
id: "definition_1",
|
|
110
|
+
...input,
|
|
111
|
+
namespace: "custom",
|
|
112
|
+
ownerKind: "operator",
|
|
113
|
+
ownerId: null,
|
|
114
|
+
lifecycleState: "active",
|
|
115
|
+
};
|
|
116
|
+
const renameDefinitionKey = vi.fn(async () => undefined);
|
|
117
|
+
const tx = postgresStub({
|
|
118
|
+
select: () => ({
|
|
119
|
+
from: () => ({
|
|
120
|
+
where: () => ({
|
|
121
|
+
for: () => ({ limit: async () => [existing] }),
|
|
122
|
+
}),
|
|
123
|
+
}),
|
|
124
|
+
}),
|
|
125
|
+
update: () => ({
|
|
126
|
+
set: (value) => ({
|
|
127
|
+
where: () => ({
|
|
128
|
+
returning: async () => [{ ...existing, ...value }],
|
|
129
|
+
}),
|
|
130
|
+
}),
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
const transaction = vi.fn(async (callback) => callback(tx));
|
|
134
|
+
const db = postgresStub({ transaction });
|
|
135
|
+
const service = createCustomFieldsService(targets, [
|
|
136
|
+
{
|
|
137
|
+
supports: (entityType) => entityType === "booking",
|
|
138
|
+
renameDefinitionKey,
|
|
139
|
+
deleteDefinitionValues: vi.fn(async () => undefined),
|
|
140
|
+
},
|
|
141
|
+
]);
|
|
142
|
+
await service.update(db, existing.id, { key: "renamed_external_id" });
|
|
143
|
+
expect(transaction).toHaveBeenCalledOnce();
|
|
144
|
+
expect(renameDefinitionKey).toHaveBeenCalledWith(tx, existing, "renamed_external_id");
|
|
145
|
+
});
|
|
146
|
+
it("fails closed when multiple lifecycle providers claim one target", async () => {
|
|
147
|
+
const existing = {
|
|
148
|
+
id: "definition_1",
|
|
149
|
+
...input,
|
|
150
|
+
namespace: "custom",
|
|
151
|
+
ownerKind: "operator",
|
|
152
|
+
ownerId: null,
|
|
153
|
+
lifecycleState: "active",
|
|
154
|
+
};
|
|
155
|
+
const tx = postgresStub({
|
|
156
|
+
select: () => ({
|
|
157
|
+
from: () => ({
|
|
158
|
+
where: () => ({
|
|
159
|
+
for: () => ({ limit: async () => [existing] }),
|
|
160
|
+
}),
|
|
161
|
+
}),
|
|
162
|
+
}),
|
|
163
|
+
});
|
|
164
|
+
const lifecycle = {
|
|
165
|
+
supports: (entityType) => entityType === "booking",
|
|
166
|
+
renameDefinitionKey: vi.fn(async () => undefined),
|
|
167
|
+
deleteDefinitionValues: vi.fn(async () => undefined),
|
|
168
|
+
};
|
|
169
|
+
const service = createCustomFieldsService(targets, [lifecycle, lifecycle]);
|
|
170
|
+
await expect(service.update(postgresStub({
|
|
171
|
+
transaction: async (callback) => callback(tx),
|
|
172
|
+
}), existing.id, { key: "renamed_external_id" })).rejects.toThrow(/exactly one.*found 2/);
|
|
173
|
+
expect(lifecycle.renameDefinitionKey).not.toHaveBeenCalled();
|
|
174
|
+
});
|
|
175
|
+
it("maps rename key collisions to the definition conflict response", async () => {
|
|
176
|
+
const existing = {
|
|
177
|
+
id: "definition_1",
|
|
178
|
+
...input,
|
|
179
|
+
namespace: "custom",
|
|
180
|
+
ownerKind: "operator",
|
|
181
|
+
ownerId: null,
|
|
182
|
+
lifecycleState: "active",
|
|
183
|
+
};
|
|
184
|
+
const tx = postgresStub({
|
|
185
|
+
select: () => ({
|
|
186
|
+
from: () => ({
|
|
187
|
+
where: () => ({
|
|
188
|
+
for: () => ({ limit: async () => [existing] }),
|
|
189
|
+
}),
|
|
190
|
+
}),
|
|
191
|
+
}),
|
|
192
|
+
update: () => ({
|
|
193
|
+
set: () => ({
|
|
194
|
+
where: () => ({
|
|
195
|
+
returning: async () => {
|
|
196
|
+
throw {
|
|
197
|
+
code: "23505",
|
|
198
|
+
constraint: "uidx_custom_field_definitions_namespace_key",
|
|
199
|
+
};
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
}),
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
const db = postgresStub({
|
|
206
|
+
transaction: async (callback) => callback(tx),
|
|
207
|
+
});
|
|
208
|
+
const service = createCustomFieldsService(targets, [
|
|
209
|
+
{
|
|
210
|
+
supports: (entityType) => entityType === "booking",
|
|
211
|
+
renameDefinitionKey: vi.fn(async () => undefined),
|
|
212
|
+
deleteDefinitionValues: vi.fn(async () => undefined),
|
|
213
|
+
},
|
|
214
|
+
]);
|
|
215
|
+
await expect(service.update(db, existing.id, { key: "duplicate_external_id" })).rejects.toMatchObject({
|
|
216
|
+
status: 409,
|
|
217
|
+
code: "duplicate_custom_field_key",
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
it("requires a platform-assigned app namespace", () => {
|
|
221
|
+
expect(() => createAppCustomFieldDefinitionOwner({ appId: "app_acme", namespace: "custom" })).toThrow(/platform-assigned/);
|
|
222
|
+
});
|
|
223
|
+
it("derives platform ownership from the selected target", async () => {
|
|
224
|
+
const { db, rows } = insertDb();
|
|
225
|
+
const service = createCustomFieldsService(targets);
|
|
226
|
+
const target = targets.get("booking");
|
|
227
|
+
expect(target).toBeDefined();
|
|
228
|
+
if (!target)
|
|
229
|
+
throw new Error("booking target missing");
|
|
230
|
+
await service.createForOwner(db, createPlatformCustomFieldDefinitionOwner(target), input);
|
|
231
|
+
expect(rows).toEqual([
|
|
232
|
+
expect.objectContaining({
|
|
233
|
+
namespace: "bookings",
|
|
234
|
+
ownerKind: "platform",
|
|
235
|
+
ownerId: "@voyant-travel/bookings",
|
|
236
|
+
}),
|
|
237
|
+
]);
|
|
238
|
+
});
|
|
239
|
+
it("rejects a platform owner crossing its graph target boundary", async () => {
|
|
240
|
+
const { db } = insertDb();
|
|
241
|
+
const service = createCustomFieldsService(targets);
|
|
242
|
+
const foreignOwner = createPlatformCustomFieldDefinitionOwner({
|
|
243
|
+
namespace: "relationships",
|
|
244
|
+
ownerUnitId: "@voyant-travel/relationships",
|
|
245
|
+
});
|
|
246
|
+
await expect(service.createForOwner(db, foreignOwner, input)).rejects.toMatchObject({
|
|
247
|
+
status: 403,
|
|
248
|
+
code: "custom_field_definition_read_only",
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { VoyantGraphCustomFieldTargetDeclaration } from "@voyant-travel/core/project";
|
|
2
|
+
type CustomFieldVisibilityInput = {
|
|
3
|
+
isSearchable?: boolean;
|
|
4
|
+
isExportable?: boolean;
|
|
5
|
+
isInvoiceable?: boolean;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Target manifests are the authority for whether a definition may participate
|
|
9
|
+
* in each reader. Unsupported visibility flags are always persisted as false.
|
|
10
|
+
*/
|
|
11
|
+
export declare function normalizeCustomFieldVisibility(target: Pick<VoyantGraphCustomFieldTargetDeclaration, "capabilities"> | undefined, visibility: CustomFieldVisibilityInput): CustomFieldVisibilityInput;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Target manifests are the authority for whether a definition may participate
|
|
3
|
+
* in each reader. Unsupported visibility flags are always persisted as false.
|
|
4
|
+
*/
|
|
5
|
+
export function normalizeCustomFieldVisibility(target, visibility) {
|
|
6
|
+
return {
|
|
7
|
+
isSearchable: target?.capabilities.includes("search") ? visibility.isSearchable : false,
|
|
8
|
+
isExportable: target?.capabilities.includes("export") ? visibility.isExportable : false,
|
|
9
|
+
isInvoiceable: target?.capabilities.includes("invoice") ? visibility.isInvoiceable : false,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { normalizeCustomFieldVisibility } from "./target-capabilities.js";
|
|
3
|
+
describe("custom-field target capabilities", () => {
|
|
4
|
+
it("forces unsupported reader visibility flags off", () => {
|
|
5
|
+
expect(normalizeCustomFieldVisibility({ capabilities: ["read", "write"] }, { isSearchable: true, isExportable: true, isInvoiceable: true })).toEqual({ isSearchable: false, isExportable: false, isInvoiceable: false });
|
|
6
|
+
});
|
|
7
|
+
it("preserves flags that the selected target explicitly supports", () => {
|
|
8
|
+
expect(normalizeCustomFieldVisibility({ capabilities: ["read", "search", "export", "invoice"] }, { isSearchable: true, isExportable: true, isInvoiceable: true })).toEqual({ isSearchable: true, isExportable: true, isInvoiceable: true });
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { VoyantGraphCustomFieldTarget } from "@voyant-travel/core/project";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import { customFieldTypeSchema } from "./contracts.js";
|
|
4
|
+
type CustomFieldType = z.infer<typeof customFieldTypeSchema>;
|
|
5
|
+
export type CustomFieldTarget = Omit<VoyantGraphCustomFieldTarget, "fieldTypes"> & {
|
|
6
|
+
fieldTypes: readonly CustomFieldType[];
|
|
7
|
+
};
|
|
8
|
+
/** Immutable, selected-graph-derived allowlist used by Settings and the API. */
|
|
9
|
+
export declare function createCustomFieldTargetRegistry(targets: readonly VoyantGraphCustomFieldTarget[]): ReadonlyMap<string, CustomFieldTarget>;
|
|
10
|
+
export {};
|
package/dist/targets.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { customFieldTypeSchema } from "./contracts.js";
|
|
2
|
+
/** Immutable, selected-graph-derived allowlist used by Settings and the API. */
|
|
3
|
+
export function createCustomFieldTargetRegistry(targets) {
|
|
4
|
+
const byId = new Map();
|
|
5
|
+
for (const target of targets) {
|
|
6
|
+
if (byId.has(target.id))
|
|
7
|
+
throw new Error(`duplicate custom-field target "${target.id}"`);
|
|
8
|
+
byId.set(target.id, Object.freeze({
|
|
9
|
+
...target,
|
|
10
|
+
fieldTypes: Object.freeze([
|
|
11
|
+
...new Set(target.fieldTypes.map((fieldType) => customFieldTypeSchema.parse(fieldType))),
|
|
12
|
+
].sort()),
|
|
13
|
+
capabilities: Object.freeze([...new Set(target.capabilities)].sort()),
|
|
14
|
+
}));
|
|
15
|
+
}
|
|
16
|
+
return byId;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { createCustomFieldTargetRegistry } from "./targets.js";
|
|
3
|
+
describe("selected custom-field target registry", () => {
|
|
4
|
+
it("is immutable, de-duplicates types, and rejects duplicate target ownership", () => {
|
|
5
|
+
const registry = createCustomFieldTargetRegistry([
|
|
6
|
+
{
|
|
7
|
+
id: "booking",
|
|
8
|
+
namespace: "bookings",
|
|
9
|
+
label: "Booking",
|
|
10
|
+
fieldTypes: ["text", "text", "boolean"],
|
|
11
|
+
capabilities: ["read", "write"],
|
|
12
|
+
ownerUnitId: "@voyant-travel/bookings",
|
|
13
|
+
},
|
|
14
|
+
]);
|
|
15
|
+
expect(registry.get("booking")?.fieldTypes).toEqual(["boolean", "text"]);
|
|
16
|
+
expect(() => createCustomFieldTargetRegistry([
|
|
17
|
+
{
|
|
18
|
+
id: "booking",
|
|
19
|
+
namespace: "bookings",
|
|
20
|
+
label: "Booking",
|
|
21
|
+
fieldTypes: ["text"],
|
|
22
|
+
capabilities: ["read"],
|
|
23
|
+
ownerUnitId: "@voyant-travel/bookings",
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "booking",
|
|
27
|
+
namespace: "orders",
|
|
28
|
+
label: "Another booking",
|
|
29
|
+
fieldTypes: ["text"],
|
|
30
|
+
capabilities: ["read"],
|
|
31
|
+
ownerUnitId: "@voyant-travel/orders",
|
|
32
|
+
},
|
|
33
|
+
])).toThrow(/duplicate custom-field target/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const upsertCustomFieldValueSchema: z.ZodObject<{
|
|
3
|
+
entityType: z.ZodString;
|
|
4
|
+
entityId: z.ZodString;
|
|
5
|
+
textValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
6
|
+
numberValue: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
7
|
+
dateValue: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
8
|
+
booleanValue: z.ZodOptional<z.ZodNullable<z.ZodBoolean>>;
|
|
9
|
+
monetaryValueCents: z.ZodOptional<z.ZodNullable<z.ZodNumber>>;
|
|
10
|
+
currencyCode: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
11
|
+
jsonValue: z.ZodOptional<z.ZodNullable<z.ZodUnion<[z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodArray<z.ZodString>]>>>;
|
|
12
|
+
}, z.core.$strip>;
|
|
13
|
+
export declare const customFieldValueListQuerySchema: z.ZodObject<{
|
|
14
|
+
entityType: z.ZodOptional<z.ZodString>;
|
|
15
|
+
entityId: z.ZodOptional<z.ZodString>;
|
|
16
|
+
definitionId: z.ZodOptional<z.ZodString>;
|
|
17
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
18
|
+
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
19
|
+
}, z.core.$strip>;
|
|
20
|
+
/** Synthetic representation of an entity-column custom-field value. */
|
|
21
|
+
export declare const customFieldValueSchema: z.ZodObject<{
|
|
22
|
+
id: z.ZodString;
|
|
23
|
+
definitionId: z.ZodString;
|
|
24
|
+
entityType: z.ZodString;
|
|
25
|
+
entityId: z.ZodString;
|
|
26
|
+
namespace: z.ZodString;
|
|
27
|
+
key: z.ZodString;
|
|
28
|
+
textValue: z.ZodNullable<z.ZodString>;
|
|
29
|
+
numberValue: z.ZodNullable<z.ZodNumber>;
|
|
30
|
+
dateValue: z.ZodNullable<z.ZodString>;
|
|
31
|
+
booleanValue: z.ZodNullable<z.ZodBoolean>;
|
|
32
|
+
monetaryValueCents: z.ZodNullable<z.ZodNumber>;
|
|
33
|
+
currencyCode: z.ZodNullable<z.ZodString>;
|
|
34
|
+
jsonValue: z.ZodNullable<z.ZodUnion<readonly [z.ZodRecord<z.ZodString, z.ZodUnknown>, z.ZodArray<z.ZodString>]>>;
|
|
35
|
+
}, z.core.$strip>;
|
|
36
|
+
export type CustomFieldValueListQuery = z.infer<typeof customFieldValueListQuerySchema>;
|
|
37
|
+
export type UpsertCustomFieldValueInput = z.infer<typeof upsertCustomFieldValueSchema>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const upsertCustomFieldValueSchema = z.object({
|
|
3
|
+
entityType: z.string().min(1),
|
|
4
|
+
entityId: z.string().min(1),
|
|
5
|
+
textValue: z.string().nullable().optional(),
|
|
6
|
+
numberValue: z.number().finite().nullable().optional(),
|
|
7
|
+
dateValue: z.string().date().nullable().optional(),
|
|
8
|
+
booleanValue: z.boolean().nullable().optional(),
|
|
9
|
+
monetaryValueCents: z.number().int().nullable().optional(),
|
|
10
|
+
currencyCode: z.string().nullable().optional(),
|
|
11
|
+
jsonValue: z.record(z.string(), z.unknown()).or(z.array(z.string())).nullable().optional(),
|
|
12
|
+
});
|
|
13
|
+
export const customFieldValueListQuerySchema = z.object({
|
|
14
|
+
entityType: z.string().min(1).optional(),
|
|
15
|
+
entityId: z.string().min(1).optional(),
|
|
16
|
+
definitionId: z.string().min(1).optional(),
|
|
17
|
+
limit: z.coerce.number().int().min(1).max(200).default(50),
|
|
18
|
+
offset: z.coerce.number().int().min(0).default(0),
|
|
19
|
+
});
|
|
20
|
+
const jsonRecord = z.record(z.string(), z.unknown());
|
|
21
|
+
/** Synthetic representation of an entity-column custom-field value. */
|
|
22
|
+
export const customFieldValueSchema = z.object({
|
|
23
|
+
id: z.string(),
|
|
24
|
+
definitionId: z.string(),
|
|
25
|
+
entityType: z.string(),
|
|
26
|
+
entityId: z.string(),
|
|
27
|
+
namespace: z.string(),
|
|
28
|
+
key: z.string(),
|
|
29
|
+
textValue: z.string().nullable(),
|
|
30
|
+
numberValue: z.number().nullable(),
|
|
31
|
+
dateValue: z.string().nullable(),
|
|
32
|
+
booleanValue: z.boolean().nullable(),
|
|
33
|
+
monetaryValueCents: z.number().int().nullable(),
|
|
34
|
+
currencyCode: z.string().nullable(),
|
|
35
|
+
jsonValue: z.union([jsonRecord, z.array(z.string())]).nullable(),
|
|
36
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { upsertCustomFieldValueSchema } from "./value-contracts.js";
|
|
3
|
+
describe("custom-field value contracts", () => {
|
|
4
|
+
it("requires entity type and entity id", () => {
|
|
5
|
+
const value = upsertCustomFieldValueSchema.parse({
|
|
6
|
+
entityType: "organization",
|
|
7
|
+
entityId: "crm_org_abc",
|
|
8
|
+
textValue: "hello",
|
|
9
|
+
});
|
|
10
|
+
expect(value.entityType).toBe("organization");
|
|
11
|
+
expect(value.entityId).toBe("crm_org_abc");
|
|
12
|
+
});
|
|
13
|
+
it("rejects incomplete value targets", () => {
|
|
14
|
+
expect(() => upsertCustomFieldValueSchema.parse({ entityId: "crm_org_abc" })).toThrow();
|
|
15
|
+
expect(() => upsertCustomFieldValueSchema.parse({ entityType: "organization" })).toThrow();
|
|
16
|
+
});
|
|
17
|
+
it("accepts finite fractional values for double definitions", () => {
|
|
18
|
+
expect(upsertCustomFieldValueSchema.parse({
|
|
19
|
+
entityType: "person",
|
|
20
|
+
entityId: "pers_1",
|
|
21
|
+
numberValue: 1.25,
|
|
22
|
+
}).numberValue).toBe(1.25);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { UpsertCustomFieldValueInput } from "./value-contracts.js";
|
|
2
|
+
/** A custom-field value is addressed by entity, physical namespace, and definition. */
|
|
3
|
+
export declare function syntheticCustomFieldValueId(entityType: string, entityId: string, namespace: string, definitionId: string): string;
|
|
4
|
+
export declare function parseSyntheticCustomFieldValueId(id: string): {
|
|
5
|
+
entityType: string;
|
|
6
|
+
entityId: string;
|
|
7
|
+
namespace: string;
|
|
8
|
+
definitionId: string;
|
|
9
|
+
} | null;
|
|
10
|
+
export interface TypedCustomFieldValueColumns {
|
|
11
|
+
textValue: string | null;
|
|
12
|
+
numberValue: number | null;
|
|
13
|
+
dateValue: string | null;
|
|
14
|
+
booleanValue: boolean | null;
|
|
15
|
+
monetaryValueCents: number | null;
|
|
16
|
+
currencyCode: string | null;
|
|
17
|
+
jsonValue: Record<string, unknown> | string[] | null;
|
|
18
|
+
}
|
|
19
|
+
export declare function jsonbValueFromTypedCustomFieldValue(fieldType: string, input: Partial<UpsertCustomFieldValueInput>): unknown;
|
|
20
|
+
export declare function typedCustomFieldValueFromJsonb(fieldType: string, value: unknown): TypedCustomFieldValueColumns;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** A custom-field value is addressed by entity, physical namespace, and definition. */
|
|
2
|
+
export function syntheticCustomFieldValueId(entityType, entityId, namespace, definitionId) {
|
|
3
|
+
return `${entityType}::${entityId}::${namespace}::${definitionId}`;
|
|
4
|
+
}
|
|
5
|
+
export function parseSyntheticCustomFieldValueId(id) {
|
|
6
|
+
const parts = id.split("::");
|
|
7
|
+
if (parts.length !== 4 || parts.some((part) => part.length === 0))
|
|
8
|
+
return null;
|
|
9
|
+
return {
|
|
10
|
+
entityType: parts[0],
|
|
11
|
+
entityId: parts[1],
|
|
12
|
+
namespace: parts[2],
|
|
13
|
+
definitionId: parts[3],
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
const emptyTypedValue = {
|
|
17
|
+
textValue: null,
|
|
18
|
+
numberValue: null,
|
|
19
|
+
dateValue: null,
|
|
20
|
+
booleanValue: null,
|
|
21
|
+
monetaryValueCents: null,
|
|
22
|
+
currencyCode: null,
|
|
23
|
+
jsonValue: null,
|
|
24
|
+
};
|
|
25
|
+
export function jsonbValueFromTypedCustomFieldValue(fieldType, input) {
|
|
26
|
+
switch (fieldType) {
|
|
27
|
+
case "double":
|
|
28
|
+
return input.numberValue ?? null;
|
|
29
|
+
case "date":
|
|
30
|
+
return input.dateValue ?? null;
|
|
31
|
+
case "boolean":
|
|
32
|
+
return input.booleanValue ?? null;
|
|
33
|
+
case "monetary":
|
|
34
|
+
return input.monetaryValueCents == null
|
|
35
|
+
? null
|
|
36
|
+
: { amountCents: input.monetaryValueCents, currency: input.currencyCode ?? null };
|
|
37
|
+
case "set":
|
|
38
|
+
case "json":
|
|
39
|
+
case "address":
|
|
40
|
+
return input.jsonValue ?? null;
|
|
41
|
+
default:
|
|
42
|
+
return input.textValue ?? null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function typedCustomFieldValueFromJsonb(fieldType, value) {
|
|
46
|
+
switch (fieldType) {
|
|
47
|
+
case "double":
|
|
48
|
+
return { ...emptyTypedValue, numberValue: value };
|
|
49
|
+
case "date":
|
|
50
|
+
return { ...emptyTypedValue, dateValue: value };
|
|
51
|
+
case "boolean":
|
|
52
|
+
return { ...emptyTypedValue, booleanValue: value };
|
|
53
|
+
case "monetary": {
|
|
54
|
+
const money = (value ?? {});
|
|
55
|
+
return {
|
|
56
|
+
...emptyTypedValue,
|
|
57
|
+
monetaryValueCents: money.amountCents ?? null,
|
|
58
|
+
currencyCode: money.currency ?? null,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
case "set":
|
|
62
|
+
case "json":
|
|
63
|
+
case "address":
|
|
64
|
+
return { ...emptyTypedValue, jsonValue: value };
|
|
65
|
+
default:
|
|
66
|
+
return { ...emptyTypedValue, textValue: value };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { jsonbValueFromTypedCustomFieldValue, parseSyntheticCustomFieldValueId, syntheticCustomFieldValueId, typedCustomFieldValueFromJsonb, } from "./value-mapping.js";
|
|
3
|
+
describe("synthetic custom-field value ids", () => {
|
|
4
|
+
it("round-trips entity, namespace, and definition identity", () => {
|
|
5
|
+
const id = syntheticCustomFieldValueId("person", "pers_1", "custom", "cfd_2");
|
|
6
|
+
expect(parseSyntheticCustomFieldValueId(id)).toEqual({
|
|
7
|
+
entityType: "person",
|
|
8
|
+
entityId: "pers_1",
|
|
9
|
+
namespace: "custom",
|
|
10
|
+
definitionId: "cfd_2",
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
it("rejects malformed ids", () => {
|
|
14
|
+
expect(parseSyntheticCustomFieldValueId("not-an-id")).toBeNull();
|
|
15
|
+
expect(parseSyntheticCustomFieldValueId("a::b")).toBeNull();
|
|
16
|
+
expect(parseSyntheticCustomFieldValueId("a::::c")).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
describe("typed custom-field value conversion", () => {
|
|
20
|
+
const cases = [
|
|
21
|
+
{ fieldType: "text", input: { textValue: "hi" }, jsonb: "hi" },
|
|
22
|
+
{ fieldType: "varchar", input: { textValue: "v" }, jsonb: "v" },
|
|
23
|
+
{ fieldType: "enum", input: { textValue: "gold" }, jsonb: "gold" },
|
|
24
|
+
{ fieldType: "phone", input: { textValue: "+40123" }, jsonb: "+40123" },
|
|
25
|
+
{ fieldType: "double", input: { numberValue: 42 }, jsonb: 42 },
|
|
26
|
+
{ fieldType: "date", input: { dateValue: "2026-06-17" }, jsonb: "2026-06-17" },
|
|
27
|
+
{ fieldType: "boolean", input: { booleanValue: true }, jsonb: true },
|
|
28
|
+
{
|
|
29
|
+
fieldType: "monetary",
|
|
30
|
+
input: { monetaryValueCents: 1500, currencyCode: "EUR" },
|
|
31
|
+
jsonb: { amountCents: 1500, currency: "EUR" },
|
|
32
|
+
},
|
|
33
|
+
{ fieldType: "set", input: { jsonValue: ["a", "b"] }, jsonb: ["a", "b"] },
|
|
34
|
+
{ fieldType: "json", input: { jsonValue: { k: 1 } }, jsonb: { k: 1 } },
|
|
35
|
+
{ fieldType: "address", input: { jsonValue: { city: "Cluj" } }, jsonb: { city: "Cluj" } },
|
|
36
|
+
];
|
|
37
|
+
for (const { fieldType, input, jsonb } of cases) {
|
|
38
|
+
it(`round-trips ${fieldType}`, () => {
|
|
39
|
+
expect(jsonbValueFromTypedCustomFieldValue(fieldType, input)).toEqual(jsonb);
|
|
40
|
+
expect(jsonbValueFromTypedCustomFieldValue(fieldType, typedCustomFieldValueFromJsonb(fieldType, jsonb))).toEqual(jsonb);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CustomFieldValueOperationsRuntime } from "@voyant-travel/core/runtime-port";
|
|
2
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import { type CustomFieldDefinitionOwner } from "./service.js";
|
|
4
|
+
import type { CustomFieldValueListQuery, UpsertCustomFieldValueInput } from "./value-contracts.js";
|
|
5
|
+
import { type TypedCustomFieldValueColumns } from "./value-mapping.js";
|
|
6
|
+
export type CustomFieldValueRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
definitionId: string;
|
|
9
|
+
entityType: string;
|
|
10
|
+
entityId: string;
|
|
11
|
+
namespace: string;
|
|
12
|
+
key: string;
|
|
13
|
+
} & TypedCustomFieldValueColumns;
|
|
14
|
+
/**
|
|
15
|
+
* Generic value orchestration. It owns definition authorization and locking,
|
|
16
|
+
* while additive runtime providers own only queries against their entity tables.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createCustomFieldValueService(operations: readonly CustomFieldValueOperationsRuntime[]): {
|
|
19
|
+
listForOwner: (db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, query: CustomFieldValueListQuery) => Promise<{
|
|
20
|
+
data: CustomFieldValueRow[];
|
|
21
|
+
total: number;
|
|
22
|
+
limit: number;
|
|
23
|
+
offset: number;
|
|
24
|
+
}>;
|
|
25
|
+
upsertForOwner: (db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, definitionId: string, input: UpsertCustomFieldValueInput) => Promise<CustomFieldValueRow>;
|
|
26
|
+
deleteForOwner: (db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, id: string) => Promise<{
|
|
27
|
+
id: string;
|
|
28
|
+
} | null>;
|
|
29
|
+
};
|