@apart-tech/intelligence-core 1.11.5 → 1.11.6

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.
Files changed (84) hide show
  1. package/dist/auth/ability.d.ts +4 -4
  2. package/dist/auth/ability.d.ts.map +1 -1
  3. package/dist/auth/ability.js +17 -11
  4. package/dist/auth/ability.js.map +1 -1
  5. package/dist/auth/ability.test.js +25 -12
  6. package/dist/auth/ability.test.js.map +1 -1
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +5 -1
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/db/tenant.d.ts.map +1 -1
  11. package/dist/db/tenant.js +8 -0
  12. package/dist/db/tenant.js.map +1 -1
  13. package/dist/index.d.ts +6 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +4 -0
  16. package/dist/index.js.map +1 -1
  17. package/dist/services/__tests__/chunk-service.test.d.ts +2 -0
  18. package/dist/services/__tests__/chunk-service.test.d.ts.map +1 -0
  19. package/dist/services/__tests__/chunk-service.test.js +111 -0
  20. package/dist/services/__tests__/chunk-service.test.js.map +1 -0
  21. package/dist/services/__tests__/chunker.test.d.ts +2 -0
  22. package/dist/services/__tests__/chunker.test.d.ts.map +1 -0
  23. package/dist/services/__tests__/chunker.test.js +113 -0
  24. package/dist/services/__tests__/chunker.test.js.map +1 -0
  25. package/dist/services/__tests__/node-service.test.d.ts +2 -0
  26. package/dist/services/__tests__/node-service.test.d.ts.map +1 -0
  27. package/dist/services/__tests__/node-service.test.js +207 -0
  28. package/dist/services/__tests__/node-service.test.js.map +1 -0
  29. package/dist/services/__tests__/pii-detector-service.test.js +51 -0
  30. package/dist/services/__tests__/pii-detector-service.test.js.map +1 -1
  31. package/dist/services/__tests__/pii-encryption-service.test.js +37 -0
  32. package/dist/services/__tests__/pii-encryption-service.test.js.map +1 -1
  33. package/dist/services/__tests__/search-service.test.d.ts +2 -0
  34. package/dist/services/__tests__/search-service.test.d.ts.map +1 -0
  35. package/dist/services/__tests__/search-service.test.js +163 -0
  36. package/dist/services/__tests__/search-service.test.js.map +1 -0
  37. package/dist/services/backfill-chunks.d.ts +30 -0
  38. package/dist/services/backfill-chunks.d.ts.map +1 -0
  39. package/dist/services/backfill-chunks.js +55 -0
  40. package/dist/services/backfill-chunks.js.map +1 -0
  41. package/dist/services/chunk-service.d.ts +45 -0
  42. package/dist/services/chunk-service.d.ts.map +1 -0
  43. package/dist/services/chunk-service.js +111 -0
  44. package/dist/services/chunk-service.js.map +1 -0
  45. package/dist/services/chunker.d.ts +32 -0
  46. package/dist/services/chunker.d.ts.map +1 -0
  47. package/dist/services/chunker.js +289 -0
  48. package/dist/services/chunker.js.map +1 -0
  49. package/dist/services/context-service.d.ts +3 -1
  50. package/dist/services/context-service.d.ts.map +1 -1
  51. package/dist/services/context-service.js +17 -1
  52. package/dist/services/context-service.js.map +1 -1
  53. package/dist/services/node-service.d.ts +12 -1
  54. package/dist/services/node-service.d.ts.map +1 -1
  55. package/dist/services/node-service.js +54 -11
  56. package/dist/services/node-service.js.map +1 -1
  57. package/dist/services/pii-detector-service.d.ts +1 -0
  58. package/dist/services/pii-detector-service.d.ts.map +1 -1
  59. package/dist/services/pii-detector-service.js +95 -2
  60. package/dist/services/pii-detector-service.js.map +1 -1
  61. package/dist/services/pii-encryption-service.d.ts +10 -0
  62. package/dist/services/pii-encryption-service.d.ts.map +1 -1
  63. package/dist/services/pii-encryption-service.js +32 -0
  64. package/dist/services/pii-encryption-service.js.map +1 -1
  65. package/dist/services/search-service.d.ts +30 -1
  66. package/dist/services/search-service.d.ts.map +1 -1
  67. package/dist/services/search-service.js +262 -45
  68. package/dist/services/search-service.js.map +1 -1
  69. package/dist/services/tag-service.d.ts +78 -0
  70. package/dist/services/tag-service.d.ts.map +1 -0
  71. package/dist/services/tag-service.js +639 -0
  72. package/dist/services/tag-service.js.map +1 -0
  73. package/dist/services/tag-service.test.d.ts +2 -0
  74. package/dist/services/tag-service.test.d.ts.map +1 -0
  75. package/dist/services/tag-service.test.js +448 -0
  76. package/dist/services/tag-service.test.js.map +1 -0
  77. package/dist/services/workspace-service.d.ts +2 -0
  78. package/dist/services/workspace-service.d.ts.map +1 -1
  79. package/dist/services/workspace-service.js +7 -1
  80. package/dist/services/workspace-service.js.map +1 -1
  81. package/dist/types/index.d.ts +67 -2
  82. package/dist/types/index.d.ts.map +1 -1
  83. package/package.json +2 -2
  84. 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