@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,236 @@
|
|
|
1
|
+
import type { CustomFieldValueLifecycleRuntime, CustomFieldValueOperationsRuntime } from "@voyant-travel/core/runtime-port";
|
|
2
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
3
|
+
import type { CustomFieldDefinitionInput, CustomFieldDefinitionListQuery, CustomFieldDefinitionUpdate } from "./contracts.js";
|
|
4
|
+
import type { CustomFieldTarget } from "./targets.js";
|
|
5
|
+
/**
|
|
6
|
+
* Trusted caller context for definition-domain operations. HTTP inputs never
|
|
7
|
+
* carry these facts: the app gateway must resolve them from its installation.
|
|
8
|
+
*/
|
|
9
|
+
export type CustomFieldDefinitionOwner = {
|
|
10
|
+
kind: "operator";
|
|
11
|
+
namespace: "custom";
|
|
12
|
+
ownerId?: undefined;
|
|
13
|
+
provenance?: Record<string, unknown>;
|
|
14
|
+
} | {
|
|
15
|
+
kind: "platform" | "app";
|
|
16
|
+
namespace: string;
|
|
17
|
+
ownerId: string;
|
|
18
|
+
provenance?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
export declare const operatorCustomFieldDefinitionOwner: CustomFieldDefinitionOwner;
|
|
21
|
+
export declare function createAppCustomFieldDefinitionOwner(input: {
|
|
22
|
+
appId: string;
|
|
23
|
+
namespace: string;
|
|
24
|
+
provenance?: Record<string, unknown>;
|
|
25
|
+
}): CustomFieldDefinitionOwner;
|
|
26
|
+
export declare function createPlatformCustomFieldDefinitionOwner(target: Pick<CustomFieldTarget, "namespace" | "ownerUnitId">, provenance?: Record<string, unknown>): CustomFieldDefinitionOwner;
|
|
27
|
+
export declare function assertCustomFieldDefinitionOwner(owner: CustomFieldDefinitionOwner): void;
|
|
28
|
+
export declare function createCustomFieldsService(targets: ReadonlyMap<string, CustomFieldTarget>, valueLifecycles?: readonly CustomFieldValueLifecycleRuntime[], valueOperations?: readonly CustomFieldValueOperationsRuntime[]): {
|
|
29
|
+
/** Settings policy: only active selected-target definitions are visible. */
|
|
30
|
+
list(db: PostgresJsDatabase, query: CustomFieldDefinitionListQuery): Promise<{
|
|
31
|
+
data: {
|
|
32
|
+
id: string;
|
|
33
|
+
entityType: string;
|
|
34
|
+
namespace: string;
|
|
35
|
+
ownerKind: "platform" | "operator" | "app";
|
|
36
|
+
ownerId: string | null;
|
|
37
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
38
|
+
provenance: Record<string, unknown>;
|
|
39
|
+
key: string;
|
|
40
|
+
label: string;
|
|
41
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
42
|
+
isRequired: boolean;
|
|
43
|
+
isSearchable: boolean;
|
|
44
|
+
isExportable: boolean;
|
|
45
|
+
isInvoiceable: boolean;
|
|
46
|
+
options: {
|
|
47
|
+
label: string;
|
|
48
|
+
value: string;
|
|
49
|
+
}[] | null;
|
|
50
|
+
createdAt: Date;
|
|
51
|
+
updatedAt: Date;
|
|
52
|
+
}[];
|
|
53
|
+
total: number;
|
|
54
|
+
limit: number;
|
|
55
|
+
offset: number;
|
|
56
|
+
}>;
|
|
57
|
+
get(db: PostgresJsDatabase, id: string): Promise<{
|
|
58
|
+
id: string;
|
|
59
|
+
entityType: string;
|
|
60
|
+
namespace: string;
|
|
61
|
+
ownerKind: "platform" | "operator" | "app";
|
|
62
|
+
ownerId: string | null;
|
|
63
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
64
|
+
provenance: Record<string, unknown>;
|
|
65
|
+
key: string;
|
|
66
|
+
label: string;
|
|
67
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
68
|
+
isRequired: boolean;
|
|
69
|
+
isSearchable: boolean;
|
|
70
|
+
isExportable: boolean;
|
|
71
|
+
isInvoiceable: boolean;
|
|
72
|
+
options: {
|
|
73
|
+
label: string;
|
|
74
|
+
value: string;
|
|
75
|
+
}[] | null;
|
|
76
|
+
createdAt: Date;
|
|
77
|
+
updatedAt: Date;
|
|
78
|
+
} | null>;
|
|
79
|
+
/** Settings creates only operator-owned definitions in the reserved namespace. */
|
|
80
|
+
create(db: PostgresJsDatabase, input: CustomFieldDefinitionInput): Promise<{
|
|
81
|
+
id: string;
|
|
82
|
+
provenance: Record<string, unknown>;
|
|
83
|
+
namespace: string;
|
|
84
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
85
|
+
entityType: string;
|
|
86
|
+
key: string;
|
|
87
|
+
label: string;
|
|
88
|
+
isRequired: boolean;
|
|
89
|
+
isSearchable: boolean;
|
|
90
|
+
isExportable: boolean;
|
|
91
|
+
isInvoiceable: boolean;
|
|
92
|
+
options: {
|
|
93
|
+
label: string;
|
|
94
|
+
value: string;
|
|
95
|
+
}[] | null;
|
|
96
|
+
ownerKind: "platform" | "operator" | "app";
|
|
97
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
98
|
+
ownerId: string | null;
|
|
99
|
+
createdAt: Date;
|
|
100
|
+
updatedAt: Date;
|
|
101
|
+
}>;
|
|
102
|
+
update(db: PostgresJsDatabase, id: string, input: CustomFieldDefinitionUpdate): Promise<{
|
|
103
|
+
id: string;
|
|
104
|
+
entityType: string;
|
|
105
|
+
namespace: string;
|
|
106
|
+
ownerKind: "platform" | "operator" | "app";
|
|
107
|
+
ownerId: string | null;
|
|
108
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
109
|
+
provenance: Record<string, unknown>;
|
|
110
|
+
key: string;
|
|
111
|
+
label: string;
|
|
112
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
113
|
+
isRequired: boolean;
|
|
114
|
+
isSearchable: boolean;
|
|
115
|
+
isExportable: boolean;
|
|
116
|
+
isInvoiceable: boolean;
|
|
117
|
+
options: {
|
|
118
|
+
label: string;
|
|
119
|
+
value: string;
|
|
120
|
+
}[] | null;
|
|
121
|
+
createdAt: Date;
|
|
122
|
+
updatedAt: Date;
|
|
123
|
+
} | null>;
|
|
124
|
+
remove(db: PostgresJsDatabase, id: string): Promise<{
|
|
125
|
+
id: string;
|
|
126
|
+
} | null>;
|
|
127
|
+
/** App/platform callers must use their server-resolved owner context. */
|
|
128
|
+
listForOwner(db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, query: CustomFieldDefinitionListQuery): Promise<{
|
|
129
|
+
data: {
|
|
130
|
+
id: string;
|
|
131
|
+
entityType: string;
|
|
132
|
+
namespace: string;
|
|
133
|
+
ownerKind: "platform" | "operator" | "app";
|
|
134
|
+
ownerId: string | null;
|
|
135
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
136
|
+
provenance: Record<string, unknown>;
|
|
137
|
+
key: string;
|
|
138
|
+
label: string;
|
|
139
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
140
|
+
isRequired: boolean;
|
|
141
|
+
isSearchable: boolean;
|
|
142
|
+
isExportable: boolean;
|
|
143
|
+
isInvoiceable: boolean;
|
|
144
|
+
options: {
|
|
145
|
+
label: string;
|
|
146
|
+
value: string;
|
|
147
|
+
}[] | null;
|
|
148
|
+
createdAt: Date;
|
|
149
|
+
updatedAt: Date;
|
|
150
|
+
}[];
|
|
151
|
+
total: number;
|
|
152
|
+
limit: number;
|
|
153
|
+
offset: number;
|
|
154
|
+
}>;
|
|
155
|
+
getForOwner(db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, id: string): Promise<{
|
|
156
|
+
id: string;
|
|
157
|
+
entityType: string;
|
|
158
|
+
namespace: string;
|
|
159
|
+
ownerKind: "platform" | "operator" | "app";
|
|
160
|
+
ownerId: string | null;
|
|
161
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
162
|
+
provenance: Record<string, unknown>;
|
|
163
|
+
key: string;
|
|
164
|
+
label: string;
|
|
165
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
166
|
+
isRequired: boolean;
|
|
167
|
+
isSearchable: boolean;
|
|
168
|
+
isExportable: boolean;
|
|
169
|
+
isInvoiceable: boolean;
|
|
170
|
+
options: {
|
|
171
|
+
label: string;
|
|
172
|
+
value: string;
|
|
173
|
+
}[] | null;
|
|
174
|
+
createdAt: Date;
|
|
175
|
+
updatedAt: Date;
|
|
176
|
+
} | null>;
|
|
177
|
+
createForOwner(db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, input: CustomFieldDefinitionInput): Promise<{
|
|
178
|
+
id: string;
|
|
179
|
+
provenance: Record<string, unknown>;
|
|
180
|
+
namespace: string;
|
|
181
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
182
|
+
entityType: string;
|
|
183
|
+
key: string;
|
|
184
|
+
label: string;
|
|
185
|
+
isRequired: boolean;
|
|
186
|
+
isSearchable: boolean;
|
|
187
|
+
isExportable: boolean;
|
|
188
|
+
isInvoiceable: boolean;
|
|
189
|
+
options: {
|
|
190
|
+
label: string;
|
|
191
|
+
value: string;
|
|
192
|
+
}[] | null;
|
|
193
|
+
ownerKind: "platform" | "operator" | "app";
|
|
194
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
195
|
+
ownerId: string | null;
|
|
196
|
+
createdAt: Date;
|
|
197
|
+
updatedAt: Date;
|
|
198
|
+
}>;
|
|
199
|
+
updateForOwner(db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, id: string, input: CustomFieldDefinitionUpdate): Promise<{
|
|
200
|
+
id: string;
|
|
201
|
+
entityType: string;
|
|
202
|
+
namespace: string;
|
|
203
|
+
ownerKind: "platform" | "operator" | "app";
|
|
204
|
+
ownerId: string | null;
|
|
205
|
+
lifecycleState: "active" | "inactive" | "deprecated";
|
|
206
|
+
provenance: Record<string, unknown>;
|
|
207
|
+
key: string;
|
|
208
|
+
label: string;
|
|
209
|
+
fieldType: "boolean" | "text" | "date" | "monetary" | "json" | "varchar" | "double" | "enum" | "set" | "address" | "phone";
|
|
210
|
+
isRequired: boolean;
|
|
211
|
+
isSearchable: boolean;
|
|
212
|
+
isExportable: boolean;
|
|
213
|
+
isInvoiceable: boolean;
|
|
214
|
+
options: {
|
|
215
|
+
label: string;
|
|
216
|
+
value: string;
|
|
217
|
+
}[] | null;
|
|
218
|
+
createdAt: Date;
|
|
219
|
+
updatedAt: Date;
|
|
220
|
+
} | null>;
|
|
221
|
+
removeForOwner(db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, id: string): Promise<{
|
|
222
|
+
id: string;
|
|
223
|
+
} | null>;
|
|
224
|
+
values: {
|
|
225
|
+
listForOwner: (db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, query: import("./value-contracts.js").CustomFieldValueListQuery) => Promise<{
|
|
226
|
+
data: import("./value-service.js").CustomFieldValueRow[];
|
|
227
|
+
total: number;
|
|
228
|
+
limit: number;
|
|
229
|
+
offset: number;
|
|
230
|
+
}>;
|
|
231
|
+
upsertForOwner: (db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, definitionId: string, input: import("./value-contracts.js").UpsertCustomFieldValueInput) => Promise<import("./value-service.js").CustomFieldValueRow>;
|
|
232
|
+
deleteForOwner: (db: PostgresJsDatabase, owner: CustomFieldDefinitionOwner, id: string) => Promise<{
|
|
233
|
+
id: string;
|
|
234
|
+
} | null>;
|
|
235
|
+
};
|
|
236
|
+
};
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { ApiHttpError } from "@voyant-travel/hono";
|
|
2
|
+
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
|
|
3
|
+
import { customFieldDefinitions } from "./schema.js";
|
|
4
|
+
import { normalizeCustomFieldVisibility } from "./target-capabilities.js";
|
|
5
|
+
import { createCustomFieldValueService } from "./value-service.js";
|
|
6
|
+
const PHYSICAL_NAMESPACE = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)*$/;
|
|
7
|
+
export const operatorCustomFieldDefinitionOwner = Object.freeze({
|
|
8
|
+
kind: "operator",
|
|
9
|
+
namespace: "custom",
|
|
10
|
+
provenance: { source: "operator-settings" },
|
|
11
|
+
});
|
|
12
|
+
export function createAppCustomFieldDefinitionOwner(input) {
|
|
13
|
+
if (!input.appId ||
|
|
14
|
+
!input.namespace.startsWith("app--") ||
|
|
15
|
+
!PHYSICAL_NAMESPACE.test(input.namespace)) {
|
|
16
|
+
throw new Error("App custom-field owners require a platform-assigned physical namespace.");
|
|
17
|
+
}
|
|
18
|
+
return Object.freeze({
|
|
19
|
+
kind: "app",
|
|
20
|
+
ownerId: input.appId,
|
|
21
|
+
namespace: input.namespace,
|
|
22
|
+
provenance: input.provenance ?? { source: "app-api" },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
export function createPlatformCustomFieldDefinitionOwner(target, provenance = { source: "platform-module" }) {
|
|
26
|
+
if (!target.ownerUnitId ||
|
|
27
|
+
!PHYSICAL_NAMESPACE.test(target.namespace) ||
|
|
28
|
+
target.namespace === "custom" ||
|
|
29
|
+
target.namespace.startsWith("app--")) {
|
|
30
|
+
throw new Error("Platform custom-field owners require a graph-assigned module namespace.");
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze({
|
|
33
|
+
kind: "platform",
|
|
34
|
+
ownerId: target.ownerUnitId,
|
|
35
|
+
namespace: target.namespace,
|
|
36
|
+
provenance,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function assertCustomFieldDefinitionOwner(owner) {
|
|
40
|
+
if (owner.kind === "operator") {
|
|
41
|
+
if (owner.namespace !== "custom" || owner.ownerId !== undefined) {
|
|
42
|
+
throw new Error("Operator custom-field definitions must use the reserved custom namespace.");
|
|
43
|
+
}
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (!owner.ownerId || !PHYSICAL_NAMESPACE.test(owner.namespace) || owner.namespace === "custom") {
|
|
47
|
+
throw new Error("Non-operator custom-field owners require an owner id and physical namespace.");
|
|
48
|
+
}
|
|
49
|
+
if (owner.kind === "app" && !owner.namespace.startsWith("app--")) {
|
|
50
|
+
throw new Error("App custom-field owners require a platform-assigned physical namespace.");
|
|
51
|
+
}
|
|
52
|
+
if (owner.kind === "platform" && owner.namespace.startsWith("app--")) {
|
|
53
|
+
throw new Error("Platform custom-field owners cannot claim an app namespace.");
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function ownerWhere(owner) {
|
|
57
|
+
return owner.kind === "operator"
|
|
58
|
+
? and(eq(customFieldDefinitions.ownerKind, owner.kind), eq(customFieldDefinitions.namespace, owner.namespace), isNull(customFieldDefinitions.ownerId))
|
|
59
|
+
: and(eq(customFieldDefinitions.ownerKind, owner.kind), eq(customFieldDefinitions.ownerId, owner.ownerId), eq(customFieldDefinitions.namespace, owner.namespace));
|
|
60
|
+
}
|
|
61
|
+
export function createCustomFieldsService(targets, valueLifecycles = [], valueOperations = []) {
|
|
62
|
+
const targetIds = [...targets.keys()];
|
|
63
|
+
const valueLifecycleFor = (entityType) => {
|
|
64
|
+
const matches = valueLifecycles.filter((candidate) => candidate.supports(entityType));
|
|
65
|
+
if (matches.length !== 1) {
|
|
66
|
+
throw new Error(`Expected exactly one custom-field value lifecycle provider for target "${entityType}", found ${matches.length}.`);
|
|
67
|
+
}
|
|
68
|
+
return matches[0];
|
|
69
|
+
};
|
|
70
|
+
const assertAllowed = (target, fieldType) => {
|
|
71
|
+
const definition = targets.get(target);
|
|
72
|
+
if (!definition) {
|
|
73
|
+
throw new ApiHttpError(`Unsupported custom-field target "${target}"`, {
|
|
74
|
+
status: 400,
|
|
75
|
+
code: "unsupported_custom_field_target",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
if (fieldType && !definition.fieldTypes.includes(fieldType)) {
|
|
79
|
+
throw new ApiHttpError(`Field type "${fieldType}" is not supported by target "${target}"`, {
|
|
80
|
+
status: 400,
|
|
81
|
+
code: "unsupported_custom_field_type",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return definition;
|
|
85
|
+
};
|
|
86
|
+
const selectedTargetWhere = () => targetIds.length > 0 ? inArray(customFieldDefinitions.entityType, targetIds) : undefined;
|
|
87
|
+
const listWhere = (query, owner) => and(query.entityType
|
|
88
|
+
? eq(customFieldDefinitions.entityType, query.entityType)
|
|
89
|
+
: selectedTargetWhere(), query.ownerKind ? eq(customFieldDefinitions.ownerKind, query.ownerKind) : undefined, eq(customFieldDefinitions.lifecycleState, query.lifecycleState ?? "active"), owner ? ownerWhere(owner) : undefined);
|
|
90
|
+
const getById = async (db, id, owner, includeInactive = false, lockForUpdate = false) => {
|
|
91
|
+
if (targetIds.length === 0)
|
|
92
|
+
return null;
|
|
93
|
+
const query = db
|
|
94
|
+
.select()
|
|
95
|
+
.from(customFieldDefinitions)
|
|
96
|
+
.where(and(eq(customFieldDefinitions.id, id), selectedTargetWhere(), includeInactive ? undefined : eq(customFieldDefinitions.lifecycleState, "active"), owner ? ownerWhere(owner) : undefined));
|
|
97
|
+
const [row] = lockForUpdate ? await query.for("update").limit(1) : await query.limit(1);
|
|
98
|
+
return row ?? null;
|
|
99
|
+
};
|
|
100
|
+
const list = async (db, query, owner) => {
|
|
101
|
+
if (query.entityType)
|
|
102
|
+
assertAllowed(query.entityType);
|
|
103
|
+
if (targetIds.length === 0) {
|
|
104
|
+
return { data: [], total: 0, limit: query.limit, offset: query.offset };
|
|
105
|
+
}
|
|
106
|
+
const where = listWhere(query, owner);
|
|
107
|
+
const [data, count] = await Promise.all([
|
|
108
|
+
db
|
|
109
|
+
.select()
|
|
110
|
+
.from(customFieldDefinitions)
|
|
111
|
+
.where(where)
|
|
112
|
+
.limit(query.limit)
|
|
113
|
+
.offset(query.offset)
|
|
114
|
+
.orderBy(customFieldDefinitions.entityType, customFieldDefinitions.namespace, customFieldDefinitions.label),
|
|
115
|
+
db.select({ count: sql `count(*)::int` }).from(customFieldDefinitions).where(where),
|
|
116
|
+
]);
|
|
117
|
+
return { data, total: count[0]?.count ?? 0, limit: query.limit, offset: query.offset };
|
|
118
|
+
};
|
|
119
|
+
const create = async (db, owner, input) => {
|
|
120
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
121
|
+
const target = assertAllowed(input.entityType, input.fieldType);
|
|
122
|
+
if (owner.kind === "platform" &&
|
|
123
|
+
(owner.namespace !== target.namespace || owner.ownerId !== target.ownerUnitId)) {
|
|
124
|
+
throw new ApiHttpError("Platform definition owner does not control this target", {
|
|
125
|
+
status: 403,
|
|
126
|
+
code: "custom_field_definition_read_only",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const [row] = await db
|
|
130
|
+
.insert(customFieldDefinitions)
|
|
131
|
+
.values({
|
|
132
|
+
...input,
|
|
133
|
+
...normalizeCustomFieldVisibility(target, input),
|
|
134
|
+
namespace: owner.namespace,
|
|
135
|
+
ownerKind: owner.kind,
|
|
136
|
+
ownerId: owner.ownerId ?? null,
|
|
137
|
+
lifecycleState: "active",
|
|
138
|
+
provenance: owner.provenance ?? {},
|
|
139
|
+
})
|
|
140
|
+
.onConflictDoNothing({
|
|
141
|
+
target: [
|
|
142
|
+
customFieldDefinitions.entityType,
|
|
143
|
+
customFieldDefinitions.namespace,
|
|
144
|
+
customFieldDefinitions.key,
|
|
145
|
+
],
|
|
146
|
+
})
|
|
147
|
+
.returning();
|
|
148
|
+
if (!row) {
|
|
149
|
+
throw new ApiHttpError("Custom field key already exists for this target and namespace", {
|
|
150
|
+
status: 409,
|
|
151
|
+
code: "duplicate_custom_field_key",
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return row;
|
|
155
|
+
};
|
|
156
|
+
const update = async (db, id, owner, input) => {
|
|
157
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
158
|
+
return db.transaction(async (tx) => {
|
|
159
|
+
const existing = await getById(tx, id, undefined, true, true);
|
|
160
|
+
if (!existing)
|
|
161
|
+
return null;
|
|
162
|
+
if (existing.ownerKind !== owner.kind ||
|
|
163
|
+
existing.ownerId !== (owner.ownerId ?? null) ||
|
|
164
|
+
existing.namespace !== owner.namespace) {
|
|
165
|
+
throw new ApiHttpError("Custom-field definition is controlled by another owner", {
|
|
166
|
+
status: 403,
|
|
167
|
+
code: "custom_field_definition_read_only",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
const target = assertAllowed(existing.entityType);
|
|
171
|
+
const nextKey = input.key;
|
|
172
|
+
const valueLifecycle = nextKey && nextKey !== existing.key ? valueLifecycleFor(existing.entityType) : undefined;
|
|
173
|
+
let row;
|
|
174
|
+
try {
|
|
175
|
+
;
|
|
176
|
+
[row] = await tx
|
|
177
|
+
.update(customFieldDefinitions)
|
|
178
|
+
.set({
|
|
179
|
+
...input,
|
|
180
|
+
...normalizeCustomFieldVisibility(target, input),
|
|
181
|
+
updatedAt: new Date(),
|
|
182
|
+
})
|
|
183
|
+
.where(and(eq(customFieldDefinitions.id, id), ownerWhere(owner)))
|
|
184
|
+
.returning();
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (isCustomFieldDefinitionKeyConflict(error)) {
|
|
188
|
+
throw duplicateCustomFieldKeyError();
|
|
189
|
+
}
|
|
190
|
+
throw error;
|
|
191
|
+
}
|
|
192
|
+
if (row && valueLifecycle && nextKey) {
|
|
193
|
+
await valueLifecycle.renameDefinitionKey(tx, existing, nextKey);
|
|
194
|
+
}
|
|
195
|
+
return row ?? null;
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
const remove = async (db, id, owner) => {
|
|
199
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
200
|
+
return db.transaction(async (tx) => {
|
|
201
|
+
const existing = await getById(tx, id, undefined, true, true);
|
|
202
|
+
if (!existing)
|
|
203
|
+
return null;
|
|
204
|
+
if (existing.ownerKind !== owner.kind ||
|
|
205
|
+
existing.ownerId !== (owner.ownerId ?? null) ||
|
|
206
|
+
existing.namespace !== owner.namespace) {
|
|
207
|
+
throw new ApiHttpError("Custom-field definition is controlled by another owner", {
|
|
208
|
+
status: 403,
|
|
209
|
+
code: "custom_field_definition_read_only",
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
const valueLifecycle = valueLifecycleFor(existing.entityType);
|
|
213
|
+
const [row] = await tx
|
|
214
|
+
.delete(customFieldDefinitions)
|
|
215
|
+
.where(and(eq(customFieldDefinitions.id, id), ownerWhere(owner)))
|
|
216
|
+
.returning({ id: customFieldDefinitions.id });
|
|
217
|
+
if (row) {
|
|
218
|
+
await valueLifecycle.deleteDefinitionValues(tx, existing);
|
|
219
|
+
}
|
|
220
|
+
return row ?? null;
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
return {
|
|
224
|
+
/** Settings policy: only active selected-target definitions are visible. */
|
|
225
|
+
list(db, query) {
|
|
226
|
+
return list(db, query);
|
|
227
|
+
},
|
|
228
|
+
get(db, id) {
|
|
229
|
+
return getById(db, id);
|
|
230
|
+
},
|
|
231
|
+
/** Settings creates only operator-owned definitions in the reserved namespace. */
|
|
232
|
+
create(db, input) {
|
|
233
|
+
return create(db, operatorCustomFieldDefinitionOwner, input);
|
|
234
|
+
},
|
|
235
|
+
update(db, id, input) {
|
|
236
|
+
return update(db, id, operatorCustomFieldDefinitionOwner, input);
|
|
237
|
+
},
|
|
238
|
+
remove(db, id) {
|
|
239
|
+
return remove(db, id, operatorCustomFieldDefinitionOwner);
|
|
240
|
+
},
|
|
241
|
+
/** App/platform callers must use their server-resolved owner context. */
|
|
242
|
+
listForOwner(db, owner, query) {
|
|
243
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
244
|
+
return list(db, { ...query, ownerKind: undefined }, owner);
|
|
245
|
+
},
|
|
246
|
+
getForOwner(db, owner, id) {
|
|
247
|
+
assertCustomFieldDefinitionOwner(owner);
|
|
248
|
+
return getById(db, id, owner);
|
|
249
|
+
},
|
|
250
|
+
createForOwner(db, owner, input) {
|
|
251
|
+
return create(db, owner, input);
|
|
252
|
+
},
|
|
253
|
+
updateForOwner(db, owner, id, input) {
|
|
254
|
+
return update(db, id, owner, input);
|
|
255
|
+
},
|
|
256
|
+
removeForOwner(db, owner, id) {
|
|
257
|
+
return remove(db, id, owner);
|
|
258
|
+
},
|
|
259
|
+
values: createCustomFieldValueService(valueOperations),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
function duplicateCustomFieldKeyError() {
|
|
263
|
+
return new ApiHttpError("Custom field key already exists for this target and namespace", {
|
|
264
|
+
status: 409,
|
|
265
|
+
code: "duplicate_custom_field_key",
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
function isCustomFieldDefinitionKeyConflict(error) {
|
|
269
|
+
return collectErrorStrings(error, new Set(), 0).some((value) => value.includes("23505") || value.includes("uidx_custom_field_definitions_namespace_key"));
|
|
270
|
+
}
|
|
271
|
+
function collectErrorStrings(error, seen, depth) {
|
|
272
|
+
if (!error || typeof error !== "object" || depth > 6 || seen.has(error))
|
|
273
|
+
return [];
|
|
274
|
+
seen.add(error);
|
|
275
|
+
const record = error;
|
|
276
|
+
const values = [
|
|
277
|
+
record.code,
|
|
278
|
+
record.sqlState,
|
|
279
|
+
record.sqlstate,
|
|
280
|
+
record.sql_state,
|
|
281
|
+
record.constraint,
|
|
282
|
+
record.constraintName,
|
|
283
|
+
record.constraint_name,
|
|
284
|
+
record.detail,
|
|
285
|
+
record.message,
|
|
286
|
+
record.stack,
|
|
287
|
+
].filter((value) => typeof value === "string");
|
|
288
|
+
for (const nested of [
|
|
289
|
+
record.cause,
|
|
290
|
+
record.originalError,
|
|
291
|
+
record.original,
|
|
292
|
+
record.error,
|
|
293
|
+
record.queryError,
|
|
294
|
+
record.sourceError,
|
|
295
|
+
]) {
|
|
296
|
+
values.push(...collectErrorStrings(nested, seen, depth + 1));
|
|
297
|
+
}
|
|
298
|
+
if (Array.isArray(record.errors)) {
|
|
299
|
+
for (const nested of record.errors) {
|
|
300
|
+
values.push(...collectErrorStrings(nested, seen, depth + 1));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return values;
|
|
304
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|