@apart-tech/intelligence-core 1.11.5 → 1.12.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/auth/ability.d.ts +4 -4
- package/dist/auth/ability.d.ts.map +1 -1
- package/dist/auth/ability.js +17 -11
- package/dist/auth/ability.js.map +1 -1
- package/dist/auth/ability.test.js +25 -12
- package/dist/auth/ability.test.js.map +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +5 -1
- package/dist/config/index.js.map +1 -1
- package/dist/db/tenant.d.ts.map +1 -1
- package/dist/db/tenant.js +8 -0
- package/dist/db/tenant.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/services/__tests__/chunk-service.test.d.ts +2 -0
- package/dist/services/__tests__/chunk-service.test.d.ts.map +1 -0
- package/dist/services/__tests__/chunk-service.test.js +111 -0
- package/dist/services/__tests__/chunk-service.test.js.map +1 -0
- package/dist/services/__tests__/chunker.test.d.ts +2 -0
- package/dist/services/__tests__/chunker.test.d.ts.map +1 -0
- package/dist/services/__tests__/chunker.test.js +113 -0
- package/dist/services/__tests__/chunker.test.js.map +1 -0
- package/dist/services/__tests__/node-service.test.d.ts +2 -0
- package/dist/services/__tests__/node-service.test.d.ts.map +1 -0
- package/dist/services/__tests__/node-service.test.js +207 -0
- package/dist/services/__tests__/node-service.test.js.map +1 -0
- package/dist/services/__tests__/pii-detector-service.test.js +51 -0
- package/dist/services/__tests__/pii-detector-service.test.js.map +1 -1
- package/dist/services/__tests__/pii-encryption-service.test.js +37 -0
- package/dist/services/__tests__/pii-encryption-service.test.js.map +1 -1
- package/dist/services/__tests__/search-service.test.d.ts +2 -0
- package/dist/services/__tests__/search-service.test.d.ts.map +1 -0
- package/dist/services/__tests__/search-service.test.js +163 -0
- package/dist/services/__tests__/search-service.test.js.map +1 -0
- package/dist/services/backfill-chunks.d.ts +30 -0
- package/dist/services/backfill-chunks.d.ts.map +1 -0
- package/dist/services/backfill-chunks.js +55 -0
- package/dist/services/backfill-chunks.js.map +1 -0
- package/dist/services/chunk-service.d.ts +45 -0
- package/dist/services/chunk-service.d.ts.map +1 -0
- package/dist/services/chunk-service.js +111 -0
- package/dist/services/chunk-service.js.map +1 -0
- package/dist/services/chunker.d.ts +32 -0
- package/dist/services/chunker.d.ts.map +1 -0
- package/dist/services/chunker.js +289 -0
- package/dist/services/chunker.js.map +1 -0
- package/dist/services/context-service.d.ts +3 -1
- package/dist/services/context-service.d.ts.map +1 -1
- package/dist/services/context-service.js +17 -1
- package/dist/services/context-service.js.map +1 -1
- package/dist/services/node-service.d.ts +12 -1
- package/dist/services/node-service.d.ts.map +1 -1
- package/dist/services/node-service.js +54 -11
- package/dist/services/node-service.js.map +1 -1
- package/dist/services/pii-detector-service.d.ts +1 -0
- package/dist/services/pii-detector-service.d.ts.map +1 -1
- package/dist/services/pii-detector-service.js +95 -2
- package/dist/services/pii-detector-service.js.map +1 -1
- package/dist/services/pii-encryption-service.d.ts +10 -0
- package/dist/services/pii-encryption-service.d.ts.map +1 -1
- package/dist/services/pii-encryption-service.js +32 -0
- package/dist/services/pii-encryption-service.js.map +1 -1
- package/dist/services/search-service.d.ts +30 -1
- package/dist/services/search-service.d.ts.map +1 -1
- package/dist/services/search-service.js +262 -45
- package/dist/services/search-service.js.map +1 -1
- package/dist/services/tag-service.d.ts +78 -0
- package/dist/services/tag-service.d.ts.map +1 -0
- package/dist/services/tag-service.js +639 -0
- package/dist/services/tag-service.js.map +1 -0
- package/dist/services/tag-service.test.d.ts +2 -0
- package/dist/services/tag-service.test.d.ts.map +1 -0
- package/dist/services/tag-service.test.js +448 -0
- package/dist/services/tag-service.test.js.map +1 -0
- package/dist/services/user-service.d.ts +1 -0
- package/dist/services/user-service.d.ts.map +1 -1
- package/dist/services/user-service.test.js +72 -1
- package/dist/services/user-service.test.js.map +1 -1
- package/dist/services/workspace-service.d.ts +2 -0
- package/dist/services/workspace-service.d.ts.map +1 -1
- package/dist/services/workspace-service.js +7 -1
- package/dist/services/workspace-service.js.map +1 -1
- package/dist/types/index.d.ts +67 -2
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +2 -2
- package/prisma/schema.prisma +180 -3
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import { Prisma } from "@prisma/client";
|
|
2
|
+
import { SINGLE_TENANT_ORG_ID, tenantWhere } from "../db/tenant.js";
|
|
3
|
+
// ── Errors ──────────────────────────────────────────────────────────────────
|
|
4
|
+
export class TagValidationError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "TagValidationError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export class TagNotFoundError extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "TagNotFoundError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
// ── Service ─────────────────────────────────────────────────────────────────
|
|
17
|
+
export class TagService {
|
|
18
|
+
db;
|
|
19
|
+
tenantCtx;
|
|
20
|
+
constructor(db, tenantCtx) {
|
|
21
|
+
this.db = db;
|
|
22
|
+
this.tenantCtx = tenantCtx ?? { organizationId: SINGLE_TENANT_ORG_ID };
|
|
23
|
+
}
|
|
24
|
+
// ── TagDefinition Management ────────────────────────────────────────────
|
|
25
|
+
async createDefinition(input) {
|
|
26
|
+
// Validate enum type requires enumValues
|
|
27
|
+
if (input.tagType === "enum" && (!input.enumValues || input.enumValues.length === 0)) {
|
|
28
|
+
throw new TagValidationError("Tag type 'enum' requires non-empty enumValues");
|
|
29
|
+
}
|
|
30
|
+
// Validate parent depth (max 3 levels)
|
|
31
|
+
if (input.parentDefinitionId) {
|
|
32
|
+
const depth = await this.getDefinitionDepth(input.parentDefinitionId);
|
|
33
|
+
if (depth >= 2) {
|
|
34
|
+
throw new TagValidationError("Tag definition hierarchy cannot exceed 3 levels");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Validate dependsOn cycle detection
|
|
38
|
+
if (input.dependsOn && input.dependsOn.length > 0) {
|
|
39
|
+
// dependsOn references tagNames — validate they exist and no cycles
|
|
40
|
+
for (const depName of input.dependsOn) {
|
|
41
|
+
if (depName === input.tagName) {
|
|
42
|
+
throw new TagValidationError(`Tag '${input.tagName}' cannot depend on itself`);
|
|
43
|
+
}
|
|
44
|
+
const dep = await this.getDefinitionByName(depName);
|
|
45
|
+
if (!dep) {
|
|
46
|
+
throw new TagValidationError(`Dependency tag '${depName}' does not exist`);
|
|
47
|
+
}
|
|
48
|
+
// Check for circular: if the dep depends on input.tagName
|
|
49
|
+
const depDeps = dep.dependsOn || [];
|
|
50
|
+
if (depDeps.includes(input.tagName)) {
|
|
51
|
+
throw new TagValidationError(`Circular dependency detected: '${depName}' already depends on '${input.tagName}'`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return this.db.tagDefinition.create({
|
|
56
|
+
data: {
|
|
57
|
+
tagName: input.tagName,
|
|
58
|
+
tagType: input.tagType,
|
|
59
|
+
cardinality: input.cardinality ?? "singleton",
|
|
60
|
+
source: input.source ?? "extracted",
|
|
61
|
+
description: input.description ?? "",
|
|
62
|
+
category: input.category ?? null,
|
|
63
|
+
labels: input.labels ?? Prisma.JsonNull,
|
|
64
|
+
applicableNodeTypes: input.applicableNodeTypes ?? [],
|
|
65
|
+
requiredForNodeTypes: input.requiredForNodeTypes ?? [],
|
|
66
|
+
enumValues: input.enumValues ?? Prisma.JsonNull,
|
|
67
|
+
dependsOn: input.dependsOn ?? [],
|
|
68
|
+
autoAcceptThreshold: input.autoAcceptThreshold ?? null,
|
|
69
|
+
parentDefinitionId: input.parentDefinitionId ?? null,
|
|
70
|
+
createdBy: input.createdBy,
|
|
71
|
+
organizationId: this.tenantCtx.organizationId,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
async updateDefinition(id, input) {
|
|
76
|
+
const existing = await this.db.tagDefinition.findUnique({ where: { id } });
|
|
77
|
+
if (!existing) {
|
|
78
|
+
throw new TagNotFoundError(`TagDefinition ${id} not found`);
|
|
79
|
+
}
|
|
80
|
+
const data = {};
|
|
81
|
+
if (input.description !== undefined)
|
|
82
|
+
data.description = input.description;
|
|
83
|
+
if (input.category !== undefined)
|
|
84
|
+
data.category = input.category;
|
|
85
|
+
if (input.labels !== undefined)
|
|
86
|
+
data.labels = input.labels;
|
|
87
|
+
if (input.applicableNodeTypes !== undefined)
|
|
88
|
+
data.applicableNodeTypes = input.applicableNodeTypes;
|
|
89
|
+
if (input.requiredForNodeTypes !== undefined)
|
|
90
|
+
data.requiredForNodeTypes = input.requiredForNodeTypes;
|
|
91
|
+
if (input.autoAcceptThreshold !== undefined)
|
|
92
|
+
data.autoAcceptThreshold = input.autoAcceptThreshold;
|
|
93
|
+
if (input.replacedBy !== undefined)
|
|
94
|
+
data.replacedBy = input.replacedBy;
|
|
95
|
+
if (input.enumValues !== undefined) {
|
|
96
|
+
data.enumValues = input.enumValues;
|
|
97
|
+
data.enumVersion = (existing.enumVersion ?? 1) + 1;
|
|
98
|
+
}
|
|
99
|
+
if (input.status !== undefined) {
|
|
100
|
+
data.status = input.status;
|
|
101
|
+
if (input.status === "deprecated" && !existing.deprecatedAt) {
|
|
102
|
+
data.deprecatedAt = new Date();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return this.db.tagDefinition.update({ where: { id }, data });
|
|
106
|
+
}
|
|
107
|
+
async getDefinition(id) {
|
|
108
|
+
return this.db.tagDefinition.findUnique({ where: { id } });
|
|
109
|
+
}
|
|
110
|
+
async getDefinitionByName(tagName) {
|
|
111
|
+
// Org-scoped first
|
|
112
|
+
const orgDef = await this.db.tagDefinition.findFirst({
|
|
113
|
+
where: { tagName, organizationId: this.tenantCtx.organizationId },
|
|
114
|
+
});
|
|
115
|
+
if (orgDef)
|
|
116
|
+
return orgDef;
|
|
117
|
+
// Fall back to system templates (organizationId = NULL)
|
|
118
|
+
return this.db.tagDefinition.findFirst({
|
|
119
|
+
where: { tagName, organizationId: null },
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
async listDefinitions(options) {
|
|
123
|
+
const where = {};
|
|
124
|
+
if (options?.status)
|
|
125
|
+
where.status = options.status;
|
|
126
|
+
if (options?.category)
|
|
127
|
+
where.category = options.category;
|
|
128
|
+
// Include both org-specific and system templates
|
|
129
|
+
const [orgDefs, systemDefs] = await Promise.all([
|
|
130
|
+
this.db.tagDefinition.findMany({
|
|
131
|
+
where: { ...where, organizationId: this.tenantCtx.organizationId },
|
|
132
|
+
orderBy: { tagName: "asc" },
|
|
133
|
+
}),
|
|
134
|
+
this.db.tagDefinition.findMany({
|
|
135
|
+
where: { ...where, organizationId: null },
|
|
136
|
+
orderBy: { tagName: "asc" },
|
|
137
|
+
}),
|
|
138
|
+
]);
|
|
139
|
+
// System templates that aren't overridden by org-specific defs
|
|
140
|
+
const orgNames = new Set(orgDefs.map((d) => d.tagName));
|
|
141
|
+
const uniqueSystemDefs = systemDefs.filter((d) => !orgNames.has(d.tagName));
|
|
142
|
+
return [...orgDefs, ...uniqueSystemDefs].sort((a, b) => a.tagName.localeCompare(b.tagName));
|
|
143
|
+
}
|
|
144
|
+
// ── Core Tag Operations ─────────────────────────────────────────────────
|
|
145
|
+
async applyTags(nodeId, tags, taggedBy) {
|
|
146
|
+
const results = [];
|
|
147
|
+
for (const tag of tags) {
|
|
148
|
+
const result = await this.applySingleTag(nodeId, tag, taggedBy);
|
|
149
|
+
results.push(result);
|
|
150
|
+
}
|
|
151
|
+
return results;
|
|
152
|
+
}
|
|
153
|
+
async applySingleTag(nodeId, input, taggedBy) {
|
|
154
|
+
// 1. Look up definition
|
|
155
|
+
const def = await this.getDefinitionByName(input.tagName);
|
|
156
|
+
if (!def) {
|
|
157
|
+
throw new TagValidationError(`Unknown tag name '${input.tagName}'`);
|
|
158
|
+
}
|
|
159
|
+
// 2. Status check
|
|
160
|
+
if (def.status === "retired") {
|
|
161
|
+
throw new TagValidationError(`Tag '${input.tagName}' is retired and cannot be used`);
|
|
162
|
+
}
|
|
163
|
+
if (def.status === "deprecated") {
|
|
164
|
+
// Grace period: 1 hour after deprecatedAt
|
|
165
|
+
const graceEnd = def.deprecatedAt
|
|
166
|
+
? new Date(def.deprecatedAt.getTime() + 60 * 60 * 1000)
|
|
167
|
+
: null;
|
|
168
|
+
if (graceEnd && new Date() > graceEnd) {
|
|
169
|
+
throw new TagValidationError(`Tag '${input.tagName}' is deprecated (grace period expired)`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// 3. Type validation
|
|
173
|
+
this.validateTagType(def.tagType, input);
|
|
174
|
+
// 4. Enum check
|
|
175
|
+
if (def.tagType === "enum" && input.valueText) {
|
|
176
|
+
const allowed = def.enumValues || [];
|
|
177
|
+
if (!allowed.includes(input.valueText)) {
|
|
178
|
+
throw new TagValidationError(`Value '${input.valueText}' is not in enum values for '${input.tagName}': [${allowed.join(", ")}]`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// 5. Currency enforcement for number tags
|
|
182
|
+
if (def.tagType === "number" && input.valueCurrency) {
|
|
183
|
+
if (input.valueCurrency.length !== 3) {
|
|
184
|
+
throw new TagValidationError("valueCurrency must be a 3-letter ISO currency code");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// 6. Determine verification status
|
|
188
|
+
let verificationStatus = "unverified";
|
|
189
|
+
if (def.autoAcceptThreshold !== null &&
|
|
190
|
+
input.confidence !== undefined &&
|
|
191
|
+
input.confidence >= def.autoAcceptThreshold) {
|
|
192
|
+
verificationStatus = "auto-accepted";
|
|
193
|
+
}
|
|
194
|
+
const isSingleton = def.cardinality === "singleton";
|
|
195
|
+
const orgId = this.tenantCtx.organizationId;
|
|
196
|
+
// 7. Singleton: raw SQL UPSERT with IS DISTINCT FROM guard
|
|
197
|
+
if (isSingleton) {
|
|
198
|
+
const result = await this.upsertSingletonRaw(nodeId, orgId, def, input, taggedBy, verificationStatus);
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
// 8. Multi: plain INSERT via Prisma
|
|
202
|
+
const created = await this.db.nodeTag.create({
|
|
203
|
+
data: {
|
|
204
|
+
nodeId,
|
|
205
|
+
organizationId: orgId,
|
|
206
|
+
tagDefinitionId: def.id,
|
|
207
|
+
tagName: input.tagName,
|
|
208
|
+
isSingleton: false,
|
|
209
|
+
valueText: input.valueText ?? null,
|
|
210
|
+
valueNum: input.valueNum ?? null,
|
|
211
|
+
valueCurrency: input.valueCurrency ?? null,
|
|
212
|
+
valueUnit: input.valueUnit ?? null,
|
|
213
|
+
valueDate: input.valueDate ? new Date(input.valueDate) : null,
|
|
214
|
+
valueRef: input.valueRef ?? null,
|
|
215
|
+
textSpan: input.textSpan ?? null,
|
|
216
|
+
offsetStart: input.offsetStart ?? null,
|
|
217
|
+
offsetEnd: input.offsetEnd ?? null,
|
|
218
|
+
contentVersion: input.contentVersion ?? null,
|
|
219
|
+
attrs: (input.attrs ?? {}),
|
|
220
|
+
taggedBy,
|
|
221
|
+
modelId: input.modelId ?? null,
|
|
222
|
+
modelVersion: input.modelVersion ?? null,
|
|
223
|
+
confidence: input.confidence ?? null,
|
|
224
|
+
verificationStatus,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
// Write audit
|
|
228
|
+
await this.writeAudit(created.id, nodeId, orgId, "apply", null, this.tagValueSnapshot(created), taggedBy);
|
|
229
|
+
return created;
|
|
230
|
+
}
|
|
231
|
+
async upsertSingletonRaw(nodeId, orgId, def, input, taggedBy, verificationStatus) {
|
|
232
|
+
// The UPSERT with IS DISTINCT FROM guard — only updates if the value actually changed.
|
|
233
|
+
// The BEFORE UPDATE trigger on node_tags copies the old value to history.
|
|
234
|
+
const rows = await this.db.$queryRaw `
|
|
235
|
+
INSERT INTO node_tags (
|
|
236
|
+
id, node_id, organization_id, tag_definition_id, tag_name, is_singleton,
|
|
237
|
+
value_text, value_num, value_currency, value_unit, value_date, value_ref,
|
|
238
|
+
text_span, offset_start, offset_end, content_version, attrs,
|
|
239
|
+
tagged_by, model_id, model_version, confidence, verification_status,
|
|
240
|
+
created_at, updated_at
|
|
241
|
+
) VALUES (
|
|
242
|
+
gen_random_uuid(), ${nodeId}::uuid, ${orgId}::uuid, ${def.id}::uuid,
|
|
243
|
+
${input.tagName}, true,
|
|
244
|
+
${input.valueText ?? null}, ${input.valueNum ?? null},
|
|
245
|
+
${input.valueCurrency ?? null}, ${input.valueUnit ?? null},
|
|
246
|
+
${input.valueDate ? new Date(input.valueDate) : null}::timestamptz,
|
|
247
|
+
${input.valueRef ?? null}::uuid,
|
|
248
|
+
${input.textSpan ?? null}, ${input.offsetStart ?? null},
|
|
249
|
+
${input.offsetEnd ?? null}, ${input.contentVersion ?? null},
|
|
250
|
+
${JSON.stringify(input.attrs ?? {})}::jsonb,
|
|
251
|
+
${taggedBy}, ${input.modelId ?? null}, ${input.modelVersion ?? null},
|
|
252
|
+
${input.confidence ?? null}, ${verificationStatus},
|
|
253
|
+
NOW(), NOW()
|
|
254
|
+
)
|
|
255
|
+
ON CONFLICT (node_id, tag_name) WHERE is_singleton = true
|
|
256
|
+
DO UPDATE SET
|
|
257
|
+
value_text = EXCLUDED.value_text,
|
|
258
|
+
value_num = EXCLUDED.value_num,
|
|
259
|
+
value_currency = EXCLUDED.value_currency,
|
|
260
|
+
value_unit = EXCLUDED.value_unit,
|
|
261
|
+
value_date = EXCLUDED.value_date,
|
|
262
|
+
value_ref = EXCLUDED.value_ref,
|
|
263
|
+
text_span = EXCLUDED.text_span,
|
|
264
|
+
offset_start = EXCLUDED.offset_start,
|
|
265
|
+
offset_end = EXCLUDED.offset_end,
|
|
266
|
+
content_version = EXCLUDED.content_version,
|
|
267
|
+
attrs = EXCLUDED.attrs,
|
|
268
|
+
tagged_by = EXCLUDED.tagged_by,
|
|
269
|
+
model_id = EXCLUDED.model_id,
|
|
270
|
+
model_version = EXCLUDED.model_version,
|
|
271
|
+
confidence = EXCLUDED.confidence,
|
|
272
|
+
verification_status = EXCLUDED.verification_status,
|
|
273
|
+
updated_at = NOW()
|
|
274
|
+
WHERE node_tags.value_text IS DISTINCT FROM EXCLUDED.value_text
|
|
275
|
+
OR node_tags.value_num IS DISTINCT FROM EXCLUDED.value_num
|
|
276
|
+
OR node_tags.value_date IS DISTINCT FROM EXCLUDED.value_date
|
|
277
|
+
OR node_tags.value_ref IS DISTINCT FROM EXCLUDED.value_ref
|
|
278
|
+
RETURNING *
|
|
279
|
+
`;
|
|
280
|
+
if (rows.length === 0) {
|
|
281
|
+
// No-op update (values identical) — return the existing row
|
|
282
|
+
const existing = await this.db.nodeTag.findFirst({
|
|
283
|
+
where: { nodeId, tagName: input.tagName, isSingleton: true },
|
|
284
|
+
});
|
|
285
|
+
if (!existing) {
|
|
286
|
+
throw new TagNotFoundError(`Singleton tag '${input.tagName}' on node ${nodeId} not found after UPSERT`);
|
|
287
|
+
}
|
|
288
|
+
return existing;
|
|
289
|
+
}
|
|
290
|
+
const result = rows[0];
|
|
291
|
+
await this.writeAudit(result.id, nodeId, orgId, "apply", null, this.tagValueSnapshot(result), taggedBy);
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
async upsertSingleton(nodeId, input, taggedBy) {
|
|
295
|
+
const results = await this.applyTags(nodeId, [input], taggedBy);
|
|
296
|
+
return results[0];
|
|
297
|
+
}
|
|
298
|
+
async getNodeTags(nodeId, tagNames) {
|
|
299
|
+
const where = {
|
|
300
|
+
nodeId,
|
|
301
|
+
supersededBy: null,
|
|
302
|
+
};
|
|
303
|
+
if (tagNames && tagNames.length > 0) {
|
|
304
|
+
where.tagName = { in: tagNames };
|
|
305
|
+
}
|
|
306
|
+
return this.db.nodeTag.findMany({ where, orderBy: { tagName: "asc" } });
|
|
307
|
+
}
|
|
308
|
+
async queryTags(query) {
|
|
309
|
+
const orgWhere = tenantWhere(this.tenantCtx, "t");
|
|
310
|
+
const filterConditions = this.buildFilterConditions(query.filters, "t");
|
|
311
|
+
const activeCondition = Prisma.sql `t.superseded_by IS NULL`;
|
|
312
|
+
const whereClause = Prisma.sql `${orgWhere} AND ${activeCondition} AND ${filterConditions}`;
|
|
313
|
+
// Simple query: return matching node IDs with tag values
|
|
314
|
+
if (!query.aggregations || query.aggregations.length === 0) {
|
|
315
|
+
const limitVal = query.limit ?? 100;
|
|
316
|
+
const offsetVal = query.offset ?? 0;
|
|
317
|
+
const countResult = await this.db.$queryRaw `
|
|
318
|
+
SELECT COUNT(DISTINCT t.node_id) as count
|
|
319
|
+
FROM node_tags t
|
|
320
|
+
WHERE ${whereClause}
|
|
321
|
+
`;
|
|
322
|
+
const rows = await this.db.$queryRaw `
|
|
323
|
+
SELECT DISTINCT t.node_id, t.tag_name, t.value_text, t.value_num,
|
|
324
|
+
t.value_currency, t.value_unit, t.value_date, t.value_ref,
|
|
325
|
+
t.confidence, t.verification_status
|
|
326
|
+
FROM node_tags t
|
|
327
|
+
WHERE ${whereClause}
|
|
328
|
+
ORDER BY t.tag_name, t.node_id
|
|
329
|
+
LIMIT ${limitVal} OFFSET ${offsetVal}
|
|
330
|
+
`;
|
|
331
|
+
return {
|
|
332
|
+
rows: rows,
|
|
333
|
+
total: Number(countResult[0].count),
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
// Aggregation query
|
|
337
|
+
const aggSelects = query.aggregations.map((agg) => {
|
|
338
|
+
const col = this.getValueColumn(agg.tagName);
|
|
339
|
+
switch (agg.fn) {
|
|
340
|
+
case "count":
|
|
341
|
+
return Prisma.sql `COUNT(*) as "${Prisma.raw(`${agg.fn}_${agg.tagName}`)}"`;
|
|
342
|
+
case "sum":
|
|
343
|
+
return Prisma.sql `SUM(t.value_num) as "${Prisma.raw(`${agg.fn}_${agg.tagName}`)}"`;
|
|
344
|
+
case "avg":
|
|
345
|
+
return Prisma.sql `AVG(t.value_num) as "${Prisma.raw(`${agg.fn}_${agg.tagName}`)}"`;
|
|
346
|
+
case "min":
|
|
347
|
+
return Prisma.sql `MIN(${col}) as "${Prisma.raw(`${agg.fn}_${agg.tagName}`)}"`;
|
|
348
|
+
case "max":
|
|
349
|
+
return Prisma.sql `MAX(${col}) as "${Prisma.raw(`${agg.fn}_${agg.tagName}`)}"`;
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
const selectClause = Prisma.join(aggSelects, ", ");
|
|
353
|
+
let groupByClause = Prisma.sql ``;
|
|
354
|
+
let extraSelect = Prisma.sql ``;
|
|
355
|
+
if (query.groupBy && query.groupBy.length > 0) {
|
|
356
|
+
// Mandatory: group by currency when aggregating numerics
|
|
357
|
+
const groupCols = query.groupBy.map((g) => Prisma.raw(`t.${g}`));
|
|
358
|
+
groupByClause = Prisma.sql `GROUP BY ${Prisma.join(groupCols, ", ")}`;
|
|
359
|
+
const groupSelects = query.groupBy.map((g) => Prisma.sql `${Prisma.raw(`t.${g}`)}`);
|
|
360
|
+
extraSelect = Prisma.sql `${Prisma.join(groupSelects, ", ")}, `;
|
|
361
|
+
}
|
|
362
|
+
const rows = await this.db.$queryRaw `
|
|
363
|
+
SELECT ${extraSelect}${selectClause}
|
|
364
|
+
FROM node_tags t
|
|
365
|
+
WHERE ${whereClause}
|
|
366
|
+
${groupByClause}
|
|
367
|
+
`;
|
|
368
|
+
return { rows: rows, total: rows.length };
|
|
369
|
+
}
|
|
370
|
+
// ── Verification ────────────────────────────────────────────────────────
|
|
371
|
+
async verifyTag(tagId, verifiedBy, reason) {
|
|
372
|
+
const tag = await this.db.nodeTag.findUnique({ where: { id: tagId } });
|
|
373
|
+
if (!tag)
|
|
374
|
+
throw new TagNotFoundError(`Tag ${tagId} not found`);
|
|
375
|
+
const updated = await this.db.nodeTag.update({
|
|
376
|
+
where: { id: tagId },
|
|
377
|
+
data: {
|
|
378
|
+
verificationStatus: "human-verified",
|
|
379
|
+
verifiedBy,
|
|
380
|
+
verifiedAt: new Date(),
|
|
381
|
+
updatedAt: new Date(),
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
await this.writeAudit(tagId, tag.nodeId, tag.organizationId, "verify", { verificationStatus: tag.verificationStatus }, { verificationStatus: "human-verified" }, verifiedBy, reason);
|
|
385
|
+
return updated;
|
|
386
|
+
}
|
|
387
|
+
async contestTag(tagId, contestedBy, reason) {
|
|
388
|
+
const tag = await this.db.nodeTag.findUnique({ where: { id: tagId } });
|
|
389
|
+
if (!tag)
|
|
390
|
+
throw new TagNotFoundError(`Tag ${tagId} not found`);
|
|
391
|
+
const updated = await this.db.nodeTag.update({
|
|
392
|
+
where: { id: tagId },
|
|
393
|
+
data: {
|
|
394
|
+
verificationStatus: "contested",
|
|
395
|
+
updatedAt: new Date(),
|
|
396
|
+
},
|
|
397
|
+
});
|
|
398
|
+
await this.writeAudit(tagId, tag.nodeId, tag.organizationId, "contest", { verificationStatus: tag.verificationStatus }, { verificationStatus: "contested" }, contestedBy, reason);
|
|
399
|
+
return updated;
|
|
400
|
+
}
|
|
401
|
+
// ── Bulk Operations ─────────────────────────────────────────────────────
|
|
402
|
+
async bulkApply(tags, taggedBy) {
|
|
403
|
+
const batchId = crypto.randomUUID();
|
|
404
|
+
const applied = [];
|
|
405
|
+
const errors = [];
|
|
406
|
+
// Process in 500-row sub-batches (Phase 1: optimistic concurrency)
|
|
407
|
+
const batchSize = 500;
|
|
408
|
+
for (let i = 0; i < tags.length; i += batchSize) {
|
|
409
|
+
const batch = tags.slice(i, i + batchSize);
|
|
410
|
+
for (let j = 0; j < batch.length; j++) {
|
|
411
|
+
const { nodeId, ...tagInput } = batch[j];
|
|
412
|
+
try {
|
|
413
|
+
const result = await this.applySingleTag(nodeId, tagInput, taggedBy);
|
|
414
|
+
// Set batchId on the result
|
|
415
|
+
await this.db.nodeTag.update({
|
|
416
|
+
where: { id: result.id },
|
|
417
|
+
data: { batchId },
|
|
418
|
+
});
|
|
419
|
+
applied.push({ ...result, batchId });
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
errors.push({
|
|
423
|
+
index: i + j,
|
|
424
|
+
tagName: tagInput.tagName,
|
|
425
|
+
error: err instanceof Error ? err.message : String(err),
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { applied, errors };
|
|
431
|
+
}
|
|
432
|
+
async rollbackBatch(batchId) {
|
|
433
|
+
// Delete all tags in the batch — triggers will create history rows
|
|
434
|
+
const result = await this.db.nodeTag.deleteMany({
|
|
435
|
+
where: { batchId },
|
|
436
|
+
});
|
|
437
|
+
return result.count;
|
|
438
|
+
}
|
|
439
|
+
// ── History ─────────────────────────────────────────────────────────────
|
|
440
|
+
async getTagAtTime(nodeId, tagName, asOf) {
|
|
441
|
+
const orgWhere = tenantWhere(this.tenantCtx);
|
|
442
|
+
const rows = await this.db.$queryRaw `
|
|
443
|
+
SELECT value_text, value_num, value_currency, value_unit, value_date, value_ref,
|
|
444
|
+
valid_from, valid_until
|
|
445
|
+
FROM node_tag_history
|
|
446
|
+
WHERE node_id = ${nodeId}::uuid AND tag_name = ${tagName}
|
|
447
|
+
AND ${orgWhere}
|
|
448
|
+
AND valid_from <= ${asOf}::timestamptz AND valid_until > ${asOf}::timestamptz
|
|
449
|
+
UNION ALL
|
|
450
|
+
SELECT value_text, value_num, value_currency, value_unit, value_date, value_ref,
|
|
451
|
+
created_at AS valid_from, NULL::timestamptz AS valid_until
|
|
452
|
+
FROM node_tags
|
|
453
|
+
WHERE node_id = ${nodeId}::uuid AND tag_name = ${tagName}
|
|
454
|
+
AND ${orgWhere}
|
|
455
|
+
AND created_at <= ${asOf}::timestamptz
|
|
456
|
+
AND superseded_by IS NULL
|
|
457
|
+
ORDER BY valid_from DESC LIMIT 1
|
|
458
|
+
`;
|
|
459
|
+
return rows.length > 0 ? rows[0] : null;
|
|
460
|
+
}
|
|
461
|
+
// ── Private Helpers ─────────────────────────────────────────────────────
|
|
462
|
+
validateTagType(tagType, input) {
|
|
463
|
+
switch (tagType) {
|
|
464
|
+
case "text":
|
|
465
|
+
case "enum":
|
|
466
|
+
if (input.valueText === undefined && input.valueNum === undefined) {
|
|
467
|
+
// Allow either — text-typed tags primarily use valueText
|
|
468
|
+
}
|
|
469
|
+
break;
|
|
470
|
+
case "number":
|
|
471
|
+
if (input.valueNum === undefined) {
|
|
472
|
+
throw new TagValidationError(`Tag type 'number' requires valueNum`);
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
case "date":
|
|
476
|
+
if (input.valueDate === undefined) {
|
|
477
|
+
throw new TagValidationError(`Tag type 'date' requires valueDate`);
|
|
478
|
+
}
|
|
479
|
+
break;
|
|
480
|
+
case "ref":
|
|
481
|
+
if (input.valueRef === undefined) {
|
|
482
|
+
throw new TagValidationError(`Tag type 'ref' requires valueRef`);
|
|
483
|
+
}
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
async getDefinitionDepth(definitionId) {
|
|
488
|
+
let depth = 0;
|
|
489
|
+
let currentId = definitionId;
|
|
490
|
+
while (currentId) {
|
|
491
|
+
const found = await this.db.tagDefinition.findUnique({
|
|
492
|
+
where: { id: currentId },
|
|
493
|
+
select: { parentDefinitionId: true },
|
|
494
|
+
});
|
|
495
|
+
if (!found?.parentDefinitionId)
|
|
496
|
+
break;
|
|
497
|
+
depth++;
|
|
498
|
+
currentId = found.parentDefinitionId;
|
|
499
|
+
if (depth > 10)
|
|
500
|
+
break; // Safety valve
|
|
501
|
+
}
|
|
502
|
+
return depth;
|
|
503
|
+
}
|
|
504
|
+
buildFilterConditions(filters, alias) {
|
|
505
|
+
if (filters.length === 0)
|
|
506
|
+
return Prisma.sql `TRUE`;
|
|
507
|
+
const conditions = filters.map((f) => {
|
|
508
|
+
const tagNameCond = Prisma.sql `${Prisma.raw(alias)}.tag_name = ${f.tagName}`;
|
|
509
|
+
if (f.op === "exists") {
|
|
510
|
+
return tagNameCond;
|
|
511
|
+
}
|
|
512
|
+
const col = this.resolveFilterColumn(f, alias);
|
|
513
|
+
switch (f.op) {
|
|
514
|
+
case "eq":
|
|
515
|
+
return Prisma.sql `${tagNameCond} AND ${col} = ${f.value}`;
|
|
516
|
+
case "neq":
|
|
517
|
+
return Prisma.sql `${tagNameCond} AND ${col} != ${f.value}`;
|
|
518
|
+
case "gt":
|
|
519
|
+
return Prisma.sql `${tagNameCond} AND ${col} > ${f.value}`;
|
|
520
|
+
case "gte":
|
|
521
|
+
return Prisma.sql `${tagNameCond} AND ${col} >= ${f.value}`;
|
|
522
|
+
case "lt":
|
|
523
|
+
return Prisma.sql `${tagNameCond} AND ${col} < ${f.value}`;
|
|
524
|
+
case "lte":
|
|
525
|
+
return Prisma.sql `${tagNameCond} AND ${col} <= ${f.value}`;
|
|
526
|
+
case "between":
|
|
527
|
+
return Prisma.sql `${tagNameCond} AND ${col} BETWEEN ${f.value} AND ${f.valueTo}`;
|
|
528
|
+
case "in":
|
|
529
|
+
if (Array.isArray(f.value)) {
|
|
530
|
+
return Prisma.sql `${tagNameCond} AND ${col} = ANY(${f.value}::text[])`;
|
|
531
|
+
}
|
|
532
|
+
return tagNameCond;
|
|
533
|
+
case "contains":
|
|
534
|
+
return Prisma.sql `${tagNameCond} AND ${col} ILIKE ${"%" + String(f.value) + "%"}`;
|
|
535
|
+
default:
|
|
536
|
+
return tagNameCond;
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
return Prisma.join(conditions, " AND ");
|
|
540
|
+
}
|
|
541
|
+
resolveFilterColumn(filter, alias) {
|
|
542
|
+
const val = filter.value;
|
|
543
|
+
if (typeof val === "number")
|
|
544
|
+
return Prisma.raw(`${alias}.value_num`);
|
|
545
|
+
if (val instanceof Date)
|
|
546
|
+
return Prisma.raw(`${alias}.value_date`);
|
|
547
|
+
return Prisma.raw(`${alias}.value_text`);
|
|
548
|
+
}
|
|
549
|
+
getValueColumn(_tagName) {
|
|
550
|
+
// In a richer implementation, this would look up the definition's tagType.
|
|
551
|
+
// For now, default to value_num for aggregations.
|
|
552
|
+
return Prisma.raw("t.value_num");
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Build tag filter conditions as EXISTS subqueries for search integration.
|
|
556
|
+
* Each TagFilter becomes:
|
|
557
|
+
* EXISTS (SELECT 1 FROM node_tags t WHERE t.node_id = nodes.id
|
|
558
|
+
* AND t.tag_name = :tagName AND t.superseded_by IS NULL AND <op condition>)
|
|
559
|
+
*/
|
|
560
|
+
buildSearchTagCondition(filters) {
|
|
561
|
+
if (filters.length === 0)
|
|
562
|
+
return Prisma.sql `TRUE`;
|
|
563
|
+
const existsConditions = filters.map((f) => {
|
|
564
|
+
const baseCond = Prisma.sql `t.node_id = nodes.id AND t.tag_name = ${f.tagName} AND t.superseded_by IS NULL`;
|
|
565
|
+
if (f.op === "exists") {
|
|
566
|
+
return Prisma.sql `EXISTS (SELECT 1 FROM node_tags t WHERE ${baseCond})`;
|
|
567
|
+
}
|
|
568
|
+
let opCond;
|
|
569
|
+
switch (f.op) {
|
|
570
|
+
case "eq":
|
|
571
|
+
if (typeof f.value === "number") {
|
|
572
|
+
opCond = Prisma.sql `t.value_num = ${f.value}`;
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
opCond = Prisma.sql `t.value_text = ${String(f.value)}`;
|
|
576
|
+
}
|
|
577
|
+
break;
|
|
578
|
+
case "neq":
|
|
579
|
+
if (typeof f.value === "number") {
|
|
580
|
+
opCond = Prisma.sql `t.value_num != ${f.value}`;
|
|
581
|
+
}
|
|
582
|
+
else {
|
|
583
|
+
opCond = Prisma.sql `t.value_text != ${String(f.value)}`;
|
|
584
|
+
}
|
|
585
|
+
break;
|
|
586
|
+
case "gt":
|
|
587
|
+
opCond = Prisma.sql `t.value_num > ${f.value}`;
|
|
588
|
+
break;
|
|
589
|
+
case "gte":
|
|
590
|
+
opCond = Prisma.sql `t.value_num >= ${f.value}`;
|
|
591
|
+
break;
|
|
592
|
+
case "lt":
|
|
593
|
+
opCond = Prisma.sql `t.value_num < ${f.value}`;
|
|
594
|
+
break;
|
|
595
|
+
case "lte":
|
|
596
|
+
opCond = Prisma.sql `t.value_num <= ${f.value}`;
|
|
597
|
+
break;
|
|
598
|
+
case "between":
|
|
599
|
+
opCond = Prisma.sql `t.value_num BETWEEN ${f.value} AND ${f.valueTo}`;
|
|
600
|
+
break;
|
|
601
|
+
case "in":
|
|
602
|
+
if (Array.isArray(f.value)) {
|
|
603
|
+
opCond = Prisma.sql `t.value_text = ANY(${f.value}::text[])`;
|
|
604
|
+
}
|
|
605
|
+
else {
|
|
606
|
+
opCond = Prisma.sql `TRUE`;
|
|
607
|
+
}
|
|
608
|
+
break;
|
|
609
|
+
case "contains":
|
|
610
|
+
opCond = Prisma.sql `t.value_text ILIKE ${"%" + String(f.value) + "%"}`;
|
|
611
|
+
break;
|
|
612
|
+
default:
|
|
613
|
+
opCond = Prisma.sql `TRUE`;
|
|
614
|
+
}
|
|
615
|
+
return Prisma.sql `EXISTS (SELECT 1 FROM node_tags t WHERE ${baseCond} AND ${opCond})`;
|
|
616
|
+
});
|
|
617
|
+
return Prisma.join(existsConditions, " AND ");
|
|
618
|
+
}
|
|
619
|
+
tagValueSnapshot(tag) {
|
|
620
|
+
return {
|
|
621
|
+
valueText: tag.valueText,
|
|
622
|
+
valueNum: tag.valueNum,
|
|
623
|
+
valueCurrency: tag.valueCurrency,
|
|
624
|
+
valueUnit: tag.valueUnit,
|
|
625
|
+
valueDate: tag.valueDate,
|
|
626
|
+
valueRef: tag.valueRef,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
async writeAudit(tagId, nodeId, orgId, action, oldValue, newValue, actorId, reason) {
|
|
630
|
+
await this.db.$queryRaw `
|
|
631
|
+
INSERT INTO node_tag_audit (id, tag_id, node_id, organization_id, action, old_value, new_value, actor_id, reason)
|
|
632
|
+
VALUES (gen_random_uuid(), ${tagId}::uuid, ${nodeId}::uuid, ${orgId}::uuid,
|
|
633
|
+
${action}, ${oldValue ? JSON.stringify(oldValue) : null}::jsonb,
|
|
634
|
+
${newValue ? JSON.stringify(newValue) : null}::jsonb,
|
|
635
|
+
${actorId}::uuid, ${reason ?? null})
|
|
636
|
+
`;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
//# sourceMappingURL=tag-service.js.map
|