@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,10 @@
|
|
|
1
|
+
export declare const createCustomFieldsApiModule: import("@voyant-travel/core/project").VoyantGraphRuntimeFactory<{
|
|
2
|
+
module: {
|
|
3
|
+
name: string;
|
|
4
|
+
};
|
|
5
|
+
adminRoutes: import("@hono/zod-openapi").OpenAPIHono<{
|
|
6
|
+
Variables: {
|
|
7
|
+
db: import("drizzle-orm/postgres-js").PostgresJsDatabase;
|
|
8
|
+
};
|
|
9
|
+
}, {}, "/">;
|
|
10
|
+
}>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineGraphRuntimeFactory } from "@voyant-travel/core/project";
|
|
2
|
+
import { customFieldValueLifecycleRuntimePort, customFieldValueOperationsRuntimePort, } from "@voyant-travel/core/runtime-port";
|
|
3
|
+
import { createCustomFieldRoutes } from "./routes.js";
|
|
4
|
+
import { createCustomFieldTargetRegistry } from "./targets.js";
|
|
5
|
+
export const createCustomFieldsApiModule = defineGraphRuntimeFactory(async ({ getPorts, graph }) => {
|
|
6
|
+
const targets = createCustomFieldTargetRegistry(graph.customFieldTargets ?? []);
|
|
7
|
+
const valueLifecycles = await getPorts(customFieldValueLifecycleRuntimePort);
|
|
8
|
+
const valueOperations = await getPorts(customFieldValueOperationsRuntimePort);
|
|
9
|
+
return {
|
|
10
|
+
module: { name: "custom-fields" },
|
|
11
|
+
adminRoutes: createCustomFieldRoutes(targets, { valueLifecycles, valueOperations }),
|
|
12
|
+
};
|
|
13
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const customFieldTypeSchema: z.ZodEnum<{
|
|
3
|
+
boolean: "boolean";
|
|
4
|
+
text: "text";
|
|
5
|
+
date: "date";
|
|
6
|
+
monetary: "monetary";
|
|
7
|
+
json: "json";
|
|
8
|
+
varchar: "varchar";
|
|
9
|
+
double: "double";
|
|
10
|
+
enum: "enum";
|
|
11
|
+
set: "set";
|
|
12
|
+
address: "address";
|
|
13
|
+
phone: "phone";
|
|
14
|
+
}>;
|
|
15
|
+
export declare const customFieldOwnerKindSchema: z.ZodEnum<{
|
|
16
|
+
platform: "platform";
|
|
17
|
+
operator: "operator";
|
|
18
|
+
app: "app";
|
|
19
|
+
}>;
|
|
20
|
+
export declare const customFieldLifecycleStateSchema: z.ZodEnum<{
|
|
21
|
+
active: "active";
|
|
22
|
+
inactive: "inactive";
|
|
23
|
+
deprecated: "deprecated";
|
|
24
|
+
}>;
|
|
25
|
+
export declare const customFieldDefinitionProvenanceSchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
|
|
26
|
+
export declare const customFieldDefinitionInputSchema: z.ZodObject<{
|
|
27
|
+
entityType: z.ZodString;
|
|
28
|
+
key: z.ZodString;
|
|
29
|
+
label: z.ZodString;
|
|
30
|
+
fieldType: z.ZodEnum<{
|
|
31
|
+
boolean: "boolean";
|
|
32
|
+
text: "text";
|
|
33
|
+
date: "date";
|
|
34
|
+
monetary: "monetary";
|
|
35
|
+
json: "json";
|
|
36
|
+
varchar: "varchar";
|
|
37
|
+
double: "double";
|
|
38
|
+
enum: "enum";
|
|
39
|
+
set: "set";
|
|
40
|
+
address: "address";
|
|
41
|
+
phone: "phone";
|
|
42
|
+
}>;
|
|
43
|
+
isRequired: z.ZodDefault<z.ZodBoolean>;
|
|
44
|
+
isSearchable: z.ZodDefault<z.ZodBoolean>;
|
|
45
|
+
isExportable: z.ZodDefault<z.ZodBoolean>;
|
|
46
|
+
isInvoiceable: z.ZodDefault<z.ZodBoolean>;
|
|
47
|
+
options: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
48
|
+
label: z.ZodString;
|
|
49
|
+
value: z.ZodString;
|
|
50
|
+
}, z.core.$strip>>>>;
|
|
51
|
+
}, z.core.$strict>;
|
|
52
|
+
export declare const updateCustomFieldDefinitionSchema: z.ZodObject<{
|
|
53
|
+
key: z.ZodOptional<z.ZodString>;
|
|
54
|
+
label: z.ZodOptional<z.ZodString>;
|
|
55
|
+
isRequired: z.ZodOptional<z.ZodBoolean>;
|
|
56
|
+
isSearchable: z.ZodOptional<z.ZodBoolean>;
|
|
57
|
+
isExportable: z.ZodOptional<z.ZodBoolean>;
|
|
58
|
+
isInvoiceable: z.ZodOptional<z.ZodBoolean>;
|
|
59
|
+
options: z.ZodOptional<z.ZodNullable<z.ZodArray<z.ZodObject<{
|
|
60
|
+
label: z.ZodString;
|
|
61
|
+
value: z.ZodString;
|
|
62
|
+
}, z.core.$strip>>>>;
|
|
63
|
+
}, z.core.$strict>;
|
|
64
|
+
export declare const customFieldDefinitionListQuerySchema: z.ZodObject<{
|
|
65
|
+
entityType: z.ZodOptional<z.ZodString>;
|
|
66
|
+
ownerKind: z.ZodOptional<z.ZodEnum<{
|
|
67
|
+
platform: "platform";
|
|
68
|
+
operator: "operator";
|
|
69
|
+
app: "app";
|
|
70
|
+
}>>;
|
|
71
|
+
lifecycleState: z.ZodOptional<z.ZodEnum<{
|
|
72
|
+
active: "active";
|
|
73
|
+
inactive: "inactive";
|
|
74
|
+
deprecated: "deprecated";
|
|
75
|
+
}>>;
|
|
76
|
+
limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
77
|
+
offset: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
|
|
78
|
+
}, z.core.$strip>;
|
|
79
|
+
export type CustomFieldDefinitionInput = z.infer<typeof customFieldDefinitionInputSchema>;
|
|
80
|
+
export type CustomFieldDefinitionUpdate = z.infer<typeof updateCustomFieldDefinitionSchema>;
|
|
81
|
+
export type CustomFieldDefinitionListQuery = z.infer<typeof customFieldDefinitionListQuerySchema>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const customFieldTypeSchema = z.enum([
|
|
3
|
+
"varchar",
|
|
4
|
+
"text",
|
|
5
|
+
"double",
|
|
6
|
+
"monetary",
|
|
7
|
+
"date",
|
|
8
|
+
"boolean",
|
|
9
|
+
"enum",
|
|
10
|
+
"set",
|
|
11
|
+
"json",
|
|
12
|
+
"address",
|
|
13
|
+
"phone",
|
|
14
|
+
]);
|
|
15
|
+
export const customFieldOwnerKindSchema = z.enum(["platform", "operator", "app"]);
|
|
16
|
+
export const customFieldLifecycleStateSchema = z.enum(["active", "inactive", "deprecated"]);
|
|
17
|
+
export const customFieldDefinitionProvenanceSchema = z.record(z.string(), z.unknown());
|
|
18
|
+
export const customFieldDefinitionInputSchema = z
|
|
19
|
+
.object({
|
|
20
|
+
entityType: z.string().min(1),
|
|
21
|
+
key: z.string().min(1),
|
|
22
|
+
label: z.string().min(1),
|
|
23
|
+
fieldType: customFieldTypeSchema,
|
|
24
|
+
isRequired: z.boolean().default(false),
|
|
25
|
+
isSearchable: z.boolean().default(false),
|
|
26
|
+
isExportable: z.boolean().default(true),
|
|
27
|
+
isInvoiceable: z.boolean().default(false),
|
|
28
|
+
options: z
|
|
29
|
+
.array(z.object({ label: z.string(), value: z.string() }))
|
|
30
|
+
.nullable()
|
|
31
|
+
.optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
export const updateCustomFieldDefinitionSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
key: z.string().min(1).optional(),
|
|
37
|
+
label: z.string().min(1).optional(),
|
|
38
|
+
isRequired: z.boolean().optional(),
|
|
39
|
+
isSearchable: z.boolean().optional(),
|
|
40
|
+
isExportable: z.boolean().optional(),
|
|
41
|
+
isInvoiceable: z.boolean().optional(),
|
|
42
|
+
options: z
|
|
43
|
+
.array(z.object({ label: z.string(), value: z.string() }))
|
|
44
|
+
.nullable()
|
|
45
|
+
.optional(),
|
|
46
|
+
})
|
|
47
|
+
.strict();
|
|
48
|
+
export const customFieldDefinitionListQuerySchema = z.object({
|
|
49
|
+
entityType: z.string().min(1).optional(),
|
|
50
|
+
ownerKind: customFieldOwnerKindSchema.optional(),
|
|
51
|
+
lifecycleState: customFieldLifecycleStateSchema.optional(),
|
|
52
|
+
limit: z.coerce.number().int().positive().max(100).default(25),
|
|
53
|
+
offset: z.coerce.number().int().nonnegative().default(0),
|
|
54
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { customFieldDefinitionInputSchema, customFieldDefinitionListQuerySchema, updateCustomFieldDefinitionSchema, } from "./contracts.js";
|
|
3
|
+
describe("custom-field definition contracts", () => {
|
|
4
|
+
it("defaults visibility and admits graph-validated target ids", () => {
|
|
5
|
+
expect(customFieldDefinitionInputSchema.parse({
|
|
6
|
+
entityType: "booking",
|
|
7
|
+
key: "group_size",
|
|
8
|
+
label: "Group size",
|
|
9
|
+
fieldType: "double",
|
|
10
|
+
})).toMatchObject({
|
|
11
|
+
entityType: "booking",
|
|
12
|
+
isRequired: false,
|
|
13
|
+
isSearchable: false,
|
|
14
|
+
isExportable: true,
|
|
15
|
+
isInvoiceable: false,
|
|
16
|
+
});
|
|
17
|
+
});
|
|
18
|
+
it("keeps target and type immutable while allowing a namespace-scoped key rename", () => {
|
|
19
|
+
expect(updateCustomFieldDefinitionSchema.parse({ label: "Updated" })).toEqual({
|
|
20
|
+
label: "Updated",
|
|
21
|
+
});
|
|
22
|
+
expect(updateCustomFieldDefinitionSchema.parse({ key: "renamed" })).toEqual({ key: "renamed" });
|
|
23
|
+
});
|
|
24
|
+
it("rejects submitted physical ownership and supports ownership-aware reads", () => {
|
|
25
|
+
expect(() => customFieldDefinitionInputSchema.parse({
|
|
26
|
+
entityType: "booking",
|
|
27
|
+
key: "external_id",
|
|
28
|
+
label: "External ID",
|
|
29
|
+
fieldType: "text",
|
|
30
|
+
namespace: "app--foreign",
|
|
31
|
+
})).toThrow();
|
|
32
|
+
expect(customFieldDefinitionListQuerySchema.parse({ ownerKind: "app", lifecycleState: "active" })).toMatchObject({ ownerKind: "app", lifecycleState: "active" });
|
|
33
|
+
});
|
|
34
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./contracts.js";
|
|
2
|
+
export { customFieldDefinitionFromRow, loadCustomFieldDefinitions, loadCustomFieldRegistry, loadCustomFieldRegistryForWrite, } from "./registry.js";
|
|
3
|
+
export { customFieldDefinitions, customFieldLifecycleStateEnum, customFieldOwnerKindEnum, customFieldTypeEnum, } from "./schema.js";
|
|
4
|
+
export { assertCustomFieldDefinitionOwner, type CustomFieldDefinitionOwner, createAppCustomFieldDefinitionOwner, createCustomFieldsService, createPlatformCustomFieldDefinitionOwner, operatorCustomFieldDefinitionOwner, } from "./service.js";
|
|
5
|
+
export { normalizeCustomFieldVisibility } from "./target-capabilities.js";
|
|
6
|
+
export { type CustomFieldTarget, createCustomFieldTargetRegistry } from "./targets.js";
|
|
7
|
+
export { type CustomFieldValueListQuery, customFieldValueListQuerySchema, customFieldValueSchema, type UpsertCustomFieldValueInput, upsertCustomFieldValueSchema, } from "./value-contracts.js";
|
|
8
|
+
export { jsonbValueFromTypedCustomFieldValue, parseSyntheticCustomFieldValueId, syntheticCustomFieldValueId, type TypedCustomFieldValueColumns, typedCustomFieldValueFromJsonb, } from "./value-mapping.js";
|
|
9
|
+
export { type CustomFieldValueRow, createCustomFieldValueService } from "./value-service.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export * from "./contracts.js";
|
|
2
|
+
export { customFieldDefinitionFromRow, loadCustomFieldDefinitions, loadCustomFieldRegistry, loadCustomFieldRegistryForWrite, } from "./registry.js";
|
|
3
|
+
export { customFieldDefinitions, customFieldLifecycleStateEnum, customFieldOwnerKindEnum, customFieldTypeEnum, } from "./schema.js";
|
|
4
|
+
export { assertCustomFieldDefinitionOwner, createAppCustomFieldDefinitionOwner, createCustomFieldsService, createPlatformCustomFieldDefinitionOwner, operatorCustomFieldDefinitionOwner, } from "./service.js";
|
|
5
|
+
export { normalizeCustomFieldVisibility } from "./target-capabilities.js";
|
|
6
|
+
export { createCustomFieldTargetRegistry } from "./targets.js";
|
|
7
|
+
export { customFieldValueListQuerySchema, customFieldValueSchema, upsertCustomFieldValueSchema, } from "./value-contracts.js";
|
|
8
|
+
export { jsonbValueFromTypedCustomFieldValue, parseSyntheticCustomFieldValueId, syntheticCustomFieldValueId, typedCustomFieldValueFromJsonb, } from "./value-mapping.js";
|
|
9
|
+
export { createCustomFieldValueService } from "./value-service.js";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type CustomFieldDefinition, type CustomFieldRegistry } from "@voyant-travel/core/custom-fields";
|
|
2
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import { customFieldDefinitions } from "./schema.js";
|
|
4
|
+
import type { CustomFieldTarget } from "./targets.js";
|
|
5
|
+
export declare function customFieldDefinitionFromRow(row: typeof customFieldDefinitions.$inferSelect, target?: CustomFieldTarget): CustomFieldDefinition | null;
|
|
6
|
+
export declare function loadCustomFieldDefinitions(db: PostgresJsDatabase, targets?: ReadonlyMap<string, CustomFieldTarget>): Promise<CustomFieldDefinition[]>;
|
|
7
|
+
export declare function loadCustomFieldRegistry(db: PostgresJsDatabase, targets?: ReadonlyMap<string, CustomFieldTarget>): Promise<CustomFieldRegistry>;
|
|
8
|
+
export declare function loadCustomFieldRegistryForWrite(db: PostgresJsDatabase, entity: string, targets?: ReadonlyMap<string, CustomFieldTarget>): Promise<CustomFieldRegistry>;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { createCustomFieldRegistry, } from "@voyant-travel/core/custom-fields";
|
|
2
|
+
import { eq } from "drizzle-orm";
|
|
3
|
+
import { customFieldDefinitions } from "./schema.js";
|
|
4
|
+
import { normalizeCustomFieldVisibility } from "./target-capabilities.js";
|
|
5
|
+
const TYPE_MAP = {
|
|
6
|
+
varchar: "text",
|
|
7
|
+
text: "text",
|
|
8
|
+
double: "number",
|
|
9
|
+
monetary: "monetary",
|
|
10
|
+
date: "date",
|
|
11
|
+
boolean: "boolean",
|
|
12
|
+
enum: "select",
|
|
13
|
+
set: "multiselect",
|
|
14
|
+
json: "json",
|
|
15
|
+
address: "json",
|
|
16
|
+
phone: "text",
|
|
17
|
+
};
|
|
18
|
+
export function customFieldDefinitionFromRow(row, target) {
|
|
19
|
+
if (row.lifecycleState !== "active")
|
|
20
|
+
return null;
|
|
21
|
+
if (target && !target.fieldTypes.includes(row.fieldType))
|
|
22
|
+
return null;
|
|
23
|
+
const visibility = target ? normalizeCustomFieldVisibility(target, row) : row;
|
|
24
|
+
return {
|
|
25
|
+
entity: row.entityType,
|
|
26
|
+
namespace: row.namespace,
|
|
27
|
+
key: row.key,
|
|
28
|
+
type: TYPE_MAP[row.fieldType] ?? "text",
|
|
29
|
+
label: row.label,
|
|
30
|
+
required: row.isRequired,
|
|
31
|
+
options: row.options?.map((option) => option.value),
|
|
32
|
+
visibility: {
|
|
33
|
+
export: visibility.isExportable,
|
|
34
|
+
invoice: visibility.isInvoiceable,
|
|
35
|
+
search: visibility.isSearchable,
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function definitionsFromRows(rows, targets) {
|
|
40
|
+
return rows.flatMap((row) => {
|
|
41
|
+
const target = targets?.get(row.entityType);
|
|
42
|
+
if (targets && !target)
|
|
43
|
+
return [];
|
|
44
|
+
const definition = customFieldDefinitionFromRow(row, target);
|
|
45
|
+
return definition ? [definition] : [];
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
export async function loadCustomFieldDefinitions(db, targets) {
|
|
49
|
+
return definitionsFromRows(await db.select().from(customFieldDefinitions), targets);
|
|
50
|
+
}
|
|
51
|
+
export async function loadCustomFieldRegistry(db, targets) {
|
|
52
|
+
return createCustomFieldRegistry(await loadCustomFieldDefinitions(db, targets));
|
|
53
|
+
}
|
|
54
|
+
export async function loadCustomFieldRegistryForWrite(db, entity, targets) {
|
|
55
|
+
const rows = await db
|
|
56
|
+
.select()
|
|
57
|
+
.from(customFieldDefinitions)
|
|
58
|
+
.where(eq(customFieldDefinitions.entityType, entity))
|
|
59
|
+
.for("share");
|
|
60
|
+
return createCustomFieldRegistry(definitionsFromRows(rows, targets));
|
|
61
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { createCustomFieldRegistry } from "@voyant-travel/core/custom-fields";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { loadCustomFieldDefinitions, loadCustomFieldRegistry, loadCustomFieldRegistryForWrite, } from "./registry.js";
|
|
4
|
+
import { createCustomFieldTargetRegistry } from "./targets.js";
|
|
5
|
+
function fakeDb(rows) {
|
|
6
|
+
const db = Object.create(null);
|
|
7
|
+
return Object.assign(db, {
|
|
8
|
+
select: () => ({ from: () => Promise.resolve(rows) }),
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
describe("database custom-field registry", () => {
|
|
12
|
+
it("maps persisted definitions and visibility into the runtime registry", async () => {
|
|
13
|
+
const rows = [
|
|
14
|
+
{
|
|
15
|
+
entityType: "person",
|
|
16
|
+
namespace: "custom",
|
|
17
|
+
lifecycleState: "active",
|
|
18
|
+
key: "tier",
|
|
19
|
+
fieldType: "enum",
|
|
20
|
+
label: "Tier",
|
|
21
|
+
isRequired: true,
|
|
22
|
+
isSearchable: true,
|
|
23
|
+
isExportable: true,
|
|
24
|
+
isInvoiceable: true,
|
|
25
|
+
options: [
|
|
26
|
+
{ label: "Gold", value: "gold" },
|
|
27
|
+
{ label: "Silver", value: "silver" },
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
];
|
|
31
|
+
expect(await loadCustomFieldDefinitions(fakeDb(rows))).toEqual([
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
entity: "person",
|
|
34
|
+
key: "tier",
|
|
35
|
+
type: "select",
|
|
36
|
+
options: ["gold", "silver"],
|
|
37
|
+
visibility: { export: true, invoice: true, search: true },
|
|
38
|
+
}),
|
|
39
|
+
]);
|
|
40
|
+
expect((await loadCustomFieldRegistry(fakeDb(rows))).field("person", "custom", "tier")).toMatchObject({
|
|
41
|
+
type: "select",
|
|
42
|
+
visibility: { export: true, invoice: true, search: true },
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
it("filters deselected targets and normalizes visibility to selected capabilities", async () => {
|
|
46
|
+
const rows = [
|
|
47
|
+
{
|
|
48
|
+
entityType: "person",
|
|
49
|
+
namespace: "custom",
|
|
50
|
+
lifecycleState: "active",
|
|
51
|
+
key: "tier",
|
|
52
|
+
fieldType: "text",
|
|
53
|
+
label: "Tier",
|
|
54
|
+
isRequired: false,
|
|
55
|
+
isSearchable: true,
|
|
56
|
+
isExportable: true,
|
|
57
|
+
isInvoiceable: true,
|
|
58
|
+
options: null,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
entityType: "activity",
|
|
62
|
+
namespace: "custom",
|
|
63
|
+
lifecycleState: "active",
|
|
64
|
+
key: "private_note",
|
|
65
|
+
fieldType: "text",
|
|
66
|
+
label: "Private note",
|
|
67
|
+
isRequired: false,
|
|
68
|
+
isSearchable: true,
|
|
69
|
+
isExportable: true,
|
|
70
|
+
isInvoiceable: true,
|
|
71
|
+
options: null,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
const targets = createCustomFieldTargetRegistry([
|
|
75
|
+
{
|
|
76
|
+
id: "person",
|
|
77
|
+
namespace: "relationships",
|
|
78
|
+
label: "Person",
|
|
79
|
+
fieldTypes: ["text"],
|
|
80
|
+
capabilities: ["read", "export"],
|
|
81
|
+
ownerUnitId: "@voyant-travel/relationships",
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
expect(await loadCustomFieldDefinitions(fakeDb(rows), targets)).toEqual([
|
|
85
|
+
expect.objectContaining({
|
|
86
|
+
entity: "person",
|
|
87
|
+
visibility: { export: true, invoice: false, search: false },
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
it("keeps same-key definitions distinct by physical namespace", async () => {
|
|
92
|
+
const rows = [
|
|
93
|
+
{
|
|
94
|
+
entityType: "person",
|
|
95
|
+
namespace: "custom",
|
|
96
|
+
lifecycleState: "active",
|
|
97
|
+
key: "external_id",
|
|
98
|
+
fieldType: "text",
|
|
99
|
+
label: "Operator external ID",
|
|
100
|
+
isRequired: false,
|
|
101
|
+
isSearchable: false,
|
|
102
|
+
isExportable: true,
|
|
103
|
+
isInvoiceable: false,
|
|
104
|
+
options: null,
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
entityType: "person",
|
|
108
|
+
namespace: "app--acme-7f3",
|
|
109
|
+
lifecycleState: "active",
|
|
110
|
+
key: "external_id",
|
|
111
|
+
fieldType: "text",
|
|
112
|
+
label: "App external ID",
|
|
113
|
+
isRequired: false,
|
|
114
|
+
isSearchable: false,
|
|
115
|
+
isExportable: true,
|
|
116
|
+
isInvoiceable: false,
|
|
117
|
+
options: null,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
const definitions = await loadCustomFieldDefinitions(fakeDb(rows));
|
|
121
|
+
expect(definitions).toHaveLength(2);
|
|
122
|
+
expect(createCustomFieldRegistry(definitions).field("person", "app--acme-7f3", "external_id")).toMatchObject({ label: "App external ID" });
|
|
123
|
+
});
|
|
124
|
+
it("keeps inactive definitions out of the runtime registry", async () => {
|
|
125
|
+
const rows = [
|
|
126
|
+
{
|
|
127
|
+
entityType: "person",
|
|
128
|
+
namespace: "custom",
|
|
129
|
+
lifecycleState: "inactive",
|
|
130
|
+
key: "retired",
|
|
131
|
+
fieldType: "text",
|
|
132
|
+
label: "Retired",
|
|
133
|
+
isRequired: false,
|
|
134
|
+
isSearchable: false,
|
|
135
|
+
isExportable: false,
|
|
136
|
+
isInvoiceable: false,
|
|
137
|
+
options: null,
|
|
138
|
+
},
|
|
139
|
+
];
|
|
140
|
+
expect(await loadCustomFieldDefinitions(fakeDb(rows))).toEqual([]);
|
|
141
|
+
});
|
|
142
|
+
it("takes a shared definition lock for entity write validation", async () => {
|
|
143
|
+
const forLock = vi.fn(async () => []);
|
|
144
|
+
const where = vi.fn(() => ({ for: forLock }));
|
|
145
|
+
const db = {
|
|
146
|
+
select: () => ({ from: () => ({ where }) }),
|
|
147
|
+
};
|
|
148
|
+
await loadCustomFieldRegistryForWrite(db, "person");
|
|
149
|
+
expect(where).toHaveBeenCalledOnce();
|
|
150
|
+
expect(forLock).toHaveBeenCalledWith("share");
|
|
151
|
+
});
|
|
152
|
+
});
|
package/dist/routes.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
2
|
+
import type { CustomFieldValueLifecycleRuntime, CustomFieldValueOperationsRuntime } from "@voyant-travel/core/runtime-port";
|
|
3
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
4
|
+
import type { CustomFieldTarget } from "./targets.js";
|
|
5
|
+
type Env = {
|
|
6
|
+
Variables: {
|
|
7
|
+
db: PostgresJsDatabase;
|
|
8
|
+
};
|
|
9
|
+
};
|
|
10
|
+
export declare function createCustomFieldRoutes(targets: ReadonlyMap<string, CustomFieldTarget>, options?: {
|
|
11
|
+
includeTargets?: boolean;
|
|
12
|
+
valueLifecycles?: readonly CustomFieldValueLifecycleRuntime[];
|
|
13
|
+
valueOperations?: readonly CustomFieldValueOperationsRuntime[];
|
|
14
|
+
}): OpenAPIHono<Env, {}, "/">;
|
|
15
|
+
export {};
|
package/dist/routes.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi";
|
|
2
|
+
import { openApiValidationHook } from "@voyant-travel/hono";
|
|
3
|
+
import { customFieldDefinitionInputSchema, customFieldDefinitionListQuerySchema, customFieldTypeSchema, updateCustomFieldDefinitionSchema, } from "./contracts.js";
|
|
4
|
+
import { createCustomFieldsService, operatorCustomFieldDefinitionOwner } from "./service.js";
|
|
5
|
+
import { customFieldValueListQuerySchema, customFieldValueSchema, upsertCustomFieldValueSchema, } from "./value-contracts.js";
|
|
6
|
+
const definitionSchema = customFieldDefinitionInputSchema.extend({
|
|
7
|
+
id: z.string(),
|
|
8
|
+
namespace: z.string(),
|
|
9
|
+
ownerKind: z.enum(["platform", "operator", "app"]),
|
|
10
|
+
ownerId: z.string().nullable(),
|
|
11
|
+
lifecycleState: z.enum(["active", "inactive", "deprecated"]),
|
|
12
|
+
provenance: z.record(z.string(), z.unknown()),
|
|
13
|
+
createdAt: z.union([z.string(), z.date()]),
|
|
14
|
+
updatedAt: z.union([z.string(), z.date()]),
|
|
15
|
+
});
|
|
16
|
+
const idParamSchema = z.object({ id: z.string() });
|
|
17
|
+
const errorSchema = z.object({ error: z.string() });
|
|
18
|
+
const successSchema = z.object({ success: z.literal(true) });
|
|
19
|
+
const customFieldValueListResponseSchema = z.object({
|
|
20
|
+
data: z.array(customFieldValueSchema),
|
|
21
|
+
total: z.number(),
|
|
22
|
+
limit: z.number(),
|
|
23
|
+
offset: z.number(),
|
|
24
|
+
});
|
|
25
|
+
const targetSchema = z.object({
|
|
26
|
+
id: z.string(),
|
|
27
|
+
namespace: z.string(),
|
|
28
|
+
label: z.string(),
|
|
29
|
+
fieldTypes: z.array(customFieldTypeSchema),
|
|
30
|
+
capabilities: z.array(z.enum(["read", "write", "search", "export", "invoice", "presentation"])),
|
|
31
|
+
ownerUnitId: z.string(),
|
|
32
|
+
});
|
|
33
|
+
const jsonContent = (schema) => ({
|
|
34
|
+
content: { "application/json": { schema } },
|
|
35
|
+
});
|
|
36
|
+
const requiredJsonBody = (schema) => ({
|
|
37
|
+
body: {
|
|
38
|
+
required: true,
|
|
39
|
+
content: { "application/json": { schema } },
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
export function createCustomFieldRoutes(targets, options = {}) {
|
|
43
|
+
const service = createCustomFieldsService(targets, options.valueLifecycles, options.valueOperations);
|
|
44
|
+
const routes = new OpenAPIHono({ defaultHook: openApiValidationHook });
|
|
45
|
+
if (options.includeTargets !== false) {
|
|
46
|
+
routes.openapi(createRoute({
|
|
47
|
+
method: "get",
|
|
48
|
+
path: "/targets",
|
|
49
|
+
responses: {
|
|
50
|
+
200: {
|
|
51
|
+
description: "Selected custom-field target registry",
|
|
52
|
+
...jsonContent(z.object({ data: z.array(targetSchema) })),
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
}), async (c) => c.json({
|
|
56
|
+
data: [...targets.values()].map((target) => ({
|
|
57
|
+
...target,
|
|
58
|
+
fieldTypes: [...target.fieldTypes],
|
|
59
|
+
capabilities: [...target.capabilities],
|
|
60
|
+
})),
|
|
61
|
+
}, 200));
|
|
62
|
+
}
|
|
63
|
+
routes.openapi(createRoute({
|
|
64
|
+
method: "get",
|
|
65
|
+
path: "/",
|
|
66
|
+
request: { query: customFieldDefinitionListQuerySchema },
|
|
67
|
+
responses: {
|
|
68
|
+
200: {
|
|
69
|
+
description: "Selected-target custom-field definitions",
|
|
70
|
+
...jsonContent(z.object({
|
|
71
|
+
data: z.array(definitionSchema),
|
|
72
|
+
total: z.number(),
|
|
73
|
+
limit: z.number(),
|
|
74
|
+
offset: z.number(),
|
|
75
|
+
})),
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
}), async (c) => c.json(await service.list(c.get("db"), c.req.valid("query")), 200));
|
|
79
|
+
routes.openapi(createRoute({
|
|
80
|
+
method: "post",
|
|
81
|
+
path: "/",
|
|
82
|
+
request: requiredJsonBody(customFieldDefinitionInputSchema),
|
|
83
|
+
responses: {
|
|
84
|
+
201: {
|
|
85
|
+
description: "Created custom-field definition",
|
|
86
|
+
...jsonContent(z.object({ data: definitionSchema })),
|
|
87
|
+
},
|
|
88
|
+
400: {
|
|
89
|
+
description: "Unsupported target or field type",
|
|
90
|
+
...jsonContent(errorSchema),
|
|
91
|
+
},
|
|
92
|
+
409: {
|
|
93
|
+
description: "Duplicate custom-field key",
|
|
94
|
+
...jsonContent(errorSchema),
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
}), async (c) => c.json({ data: await service.create(c.get("db"), c.req.valid("json")) }, 201));
|
|
98
|
+
routes.openapi(createRoute({
|
|
99
|
+
method: "get",
|
|
100
|
+
path: "/values",
|
|
101
|
+
request: { query: customFieldValueListQuerySchema },
|
|
102
|
+
responses: {
|
|
103
|
+
200: {
|
|
104
|
+
description: "Paginated list of custom-field values",
|
|
105
|
+
...jsonContent(customFieldValueListResponseSchema),
|
|
106
|
+
},
|
|
107
|
+
400: { description: "Unsupported custom-field target", ...jsonContent(errorSchema) },
|
|
108
|
+
},
|
|
109
|
+
}), async (c) => c.json(await service.values.listForOwner(c.get("db"), operatorCustomFieldDefinitionOwner, c.req.valid("query")), 200));
|
|
110
|
+
routes.openapi(createRoute({
|
|
111
|
+
method: "get",
|
|
112
|
+
path: "/{id}",
|
|
113
|
+
request: { params: idParamSchema },
|
|
114
|
+
responses: {
|
|
115
|
+
200: {
|
|
116
|
+
description: "Custom-field definition",
|
|
117
|
+
...jsonContent(z.object({ data: definitionSchema })),
|
|
118
|
+
},
|
|
119
|
+
404: {
|
|
120
|
+
description: "Custom-field definition not found",
|
|
121
|
+
...jsonContent(errorSchema),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
}), async (c) => {
|
|
125
|
+
const row = await service.get(c.get("db"), c.req.valid("param").id);
|
|
126
|
+
return row ? c.json({ data: row }, 200) : c.json({ error: "Custom field not found" }, 404);
|
|
127
|
+
});
|
|
128
|
+
routes.openapi(createRoute({
|
|
129
|
+
method: "patch",
|
|
130
|
+
path: "/{id}",
|
|
131
|
+
request: {
|
|
132
|
+
params: idParamSchema,
|
|
133
|
+
...requiredJsonBody(updateCustomFieldDefinitionSchema),
|
|
134
|
+
},
|
|
135
|
+
responses: {
|
|
136
|
+
200: {
|
|
137
|
+
description: "Updated custom-field definition",
|
|
138
|
+
...jsonContent(z.object({ data: definitionSchema })),
|
|
139
|
+
},
|
|
140
|
+
404: {
|
|
141
|
+
description: "Custom-field definition not found",
|
|
142
|
+
...jsonContent(errorSchema),
|
|
143
|
+
},
|
|
144
|
+
403: {
|
|
145
|
+
description: "Definition is controlled by another owner",
|
|
146
|
+
...jsonContent(errorSchema),
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
}), async (c) => {
|
|
150
|
+
const row = await service.update(c.get("db"), c.req.valid("param").id, c.req.valid("json"));
|
|
151
|
+
return row ? c.json({ data: row }, 200) : c.json({ error: "Custom field not found" }, 404);
|
|
152
|
+
});
|
|
153
|
+
routes.openapi(createRoute({
|
|
154
|
+
method: "delete",
|
|
155
|
+
path: "/{id}",
|
|
156
|
+
request: { params: idParamSchema },
|
|
157
|
+
responses: {
|
|
158
|
+
200: {
|
|
159
|
+
description: "Deleted custom-field definition",
|
|
160
|
+
...jsonContent(successSchema),
|
|
161
|
+
},
|
|
162
|
+
404: {
|
|
163
|
+
description: "Custom-field definition not found",
|
|
164
|
+
...jsonContent(errorSchema),
|
|
165
|
+
},
|
|
166
|
+
403: {
|
|
167
|
+
description: "Definition is controlled by another owner",
|
|
168
|
+
...jsonContent(errorSchema),
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
}), async (c) => {
|
|
172
|
+
const row = await service.remove(c.get("db"), c.req.valid("param").id);
|
|
173
|
+
return row
|
|
174
|
+
? c.json({ success: true }, 200)
|
|
175
|
+
: c.json({ error: "Custom field not found" }, 404);
|
|
176
|
+
});
|
|
177
|
+
routes.openapi(createRoute({
|
|
178
|
+
method: "put",
|
|
179
|
+
path: "/{id}/value",
|
|
180
|
+
request: { params: idParamSchema, ...requiredJsonBody(upsertCustomFieldValueSchema) },
|
|
181
|
+
responses: {
|
|
182
|
+
200: {
|
|
183
|
+
description: "The upserted custom-field value",
|
|
184
|
+
...jsonContent(z.object({ data: customFieldValueSchema })),
|
|
185
|
+
},
|
|
186
|
+
400: { description: "invalid_request", ...jsonContent(errorSchema) },
|
|
187
|
+
404: {
|
|
188
|
+
description: "Custom-field definition or entity not found",
|
|
189
|
+
...jsonContent(errorSchema),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
}), async (c) => c.json({
|
|
193
|
+
data: await service.values.upsertForOwner(c.get("db"), operatorCustomFieldDefinitionOwner, c.req.valid("param").id, c.req.valid("json")),
|
|
194
|
+
}, 200));
|
|
195
|
+
routes.openapi(createRoute({
|
|
196
|
+
method: "delete",
|
|
197
|
+
path: "/values/{id}",
|
|
198
|
+
request: { params: idParamSchema },
|
|
199
|
+
responses: {
|
|
200
|
+
200: { description: "Custom-field value deleted", ...jsonContent(successSchema) },
|
|
201
|
+
404: { description: "Custom-field value not found", ...jsonContent(errorSchema) },
|
|
202
|
+
},
|
|
203
|
+
}), async (c) => {
|
|
204
|
+
const row = await service.values.deleteForOwner(c.get("db"), operatorCustomFieldDefinitionOwner, c.req.valid("param").id);
|
|
205
|
+
return row
|
|
206
|
+
? c.json({ success: true }, 200)
|
|
207
|
+
: c.json({ error: "Custom field value not found" }, 404);
|
|
208
|
+
});
|
|
209
|
+
return routes;
|
|
210
|
+
}
|