dxcomplete 0.2.1 → 0.2.2
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/.env.example +0 -7
- package/README.md +17 -45
- package/dist/cli.js +0 -22
- package/dist/validate.js +10 -26
- package/docs/model.md +3 -3
- package/docs/taxonomy.md +1 -1
- package/package.json +23 -23
- package/templates/process/README.md +1 -1
- package/dist/http/service.d.ts +0 -7
- package/dist/http/service.js +0 -725
- package/dist/mcp/docs.d.ts +0 -114
- package/dist/mcp/docs.js +0 -626
- package/dist/mcp/server.d.ts +0 -20
- package/dist/mcp/server.js +0 -3059
- package/dist/runtime/auth.d.ts +0 -162
- package/dist/runtime/auth.js +0 -394
- package/dist/runtime/check.d.ts +0 -7
- package/dist/runtime/check.js +0 -16
- package/dist/runtime/config.d.ts +0 -17
- package/dist/runtime/config.js +0 -93
- package/dist/runtime/mongo.d.ts +0 -9
- package/dist/runtime/mongo.js +0 -56
- package/dist/runtime/records.d.ts +0 -427
- package/dist/runtime/records.js +0 -2092
- package/scripts/check-env-surface.mjs +0 -136
- package/scripts/check-public-copy.mjs +0 -263
- package/scripts/check-service-boundary.mjs +0 -63
- package/scripts/runtime-work-order.mjs +0 -506
- package/scripts/smoke-mcp-http.mjs +0 -4026
- package/src/cli.ts +0 -268
- package/src/http/server.ts +0 -314
- package/src/http/service.ts +0 -934
- package/src/init.ts +0 -262
- package/src/install-manifest.ts +0 -144
- package/src/mcp/docs.ts +0 -777
- package/src/mcp/server.ts +0 -4580
- package/src/package-root.ts +0 -31
- package/src/runtime/actor.ts +0 -61
- package/src/runtime/auth.ts +0 -673
- package/src/runtime/check.ts +0 -18
- package/src/runtime/config.ts +0 -128
- package/src/runtime/mongo.ts +0 -89
- package/src/runtime/records.ts +0 -3205
- package/src/runtime/workspace.ts +0 -155
- package/src/upgrade.ts +0 -356
- package/src/validate.ts +0 -141
- package/src/version.ts +0 -16
package/src/runtime/records.ts
DELETED
|
@@ -1,3205 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import type { ClientSession, Db } from "mongodb";
|
|
3
|
-
import type { ActorContext } from "./actor.js";
|
|
4
|
-
|
|
5
|
-
export const COLLECTION_NAMES = [
|
|
6
|
-
"workspaces",
|
|
7
|
-
"statements",
|
|
8
|
-
"journal_entries",
|
|
9
|
-
"environments",
|
|
10
|
-
"components",
|
|
11
|
-
"estimates",
|
|
12
|
-
"benefits",
|
|
13
|
-
"expectations",
|
|
14
|
-
"requirements",
|
|
15
|
-
"tasks",
|
|
16
|
-
"commitments",
|
|
17
|
-
"deferrals",
|
|
18
|
-
"changes",
|
|
19
|
-
"incidents",
|
|
20
|
-
"problems",
|
|
21
|
-
"maintenance_schedules",
|
|
22
|
-
"support_requests",
|
|
23
|
-
"value_realizations",
|
|
24
|
-
"decisions",
|
|
25
|
-
"risks"
|
|
26
|
-
] as const;
|
|
27
|
-
|
|
28
|
-
export const LEGACY_COLLECTION_NAMES = [
|
|
29
|
-
"engagements",
|
|
30
|
-
"initiatives",
|
|
31
|
-
"service_charters",
|
|
32
|
-
"cost_baselines",
|
|
33
|
-
"cost_actuals",
|
|
34
|
-
"benefit_measurements"
|
|
35
|
-
] as const;
|
|
36
|
-
export const DXCOMPLETE_TICKET_COLLECTION_NAME = "dxcomplete_tickets";
|
|
37
|
-
export const LEGACY_INTAKE_COLLECTION_NAME = "intake_items";
|
|
38
|
-
export const LEGACY_PRIVATE_COLLECTION_NAMES = [LEGACY_INTAKE_COLLECTION_NAME] as const;
|
|
39
|
-
export const INDEX_COLLECTION_NAMES = [...COLLECTION_NAMES, DXCOMPLETE_TICKET_COLLECTION_NAME] as const;
|
|
40
|
-
export const LINK_SCAN_COLLECTION_NAMES = [...COLLECTION_NAMES, ...LEGACY_COLLECTION_NAMES] as const;
|
|
41
|
-
export const READABLE_ID_SEQUENCES_COLLECTION = "readable_id_sequences";
|
|
42
|
-
export const READABLE_ID_TYPE_CODES = {
|
|
43
|
-
statements: "STM",
|
|
44
|
-
journal_entries: "JRN",
|
|
45
|
-
environments: "ENV",
|
|
46
|
-
components: "CMP",
|
|
47
|
-
expectations: "EXP",
|
|
48
|
-
requirements: "REQ",
|
|
49
|
-
tasks: "TSK",
|
|
50
|
-
commitments: "CMT",
|
|
51
|
-
deferrals: "DFR",
|
|
52
|
-
decisions: "DEC",
|
|
53
|
-
changes: "CHG",
|
|
54
|
-
incidents: "INC",
|
|
55
|
-
problems: "PRB",
|
|
56
|
-
maintenance_schedules: "MNT",
|
|
57
|
-
support_requests: "SUP",
|
|
58
|
-
value_realizations: "VAL",
|
|
59
|
-
risks: "RSK",
|
|
60
|
-
estimates: "EST",
|
|
61
|
-
benefits: "BFT"
|
|
62
|
-
} as const;
|
|
63
|
-
export const READABLE_ID_COLLECTION_NAMES = Object.keys(READABLE_ID_TYPE_CODES) as ReadableIdCollectionName[];
|
|
64
|
-
|
|
65
|
-
export type CollectionName = (typeof COLLECTION_NAMES)[number];
|
|
66
|
-
export type ReadableIdCollectionName = keyof typeof READABLE_ID_TYPE_CODES;
|
|
67
|
-
export type PrivateCollectionName = typeof DXCOMPLETE_TICKET_COLLECTION_NAME;
|
|
68
|
-
export type RuntimeCollectionName =
|
|
69
|
-
| CollectionName
|
|
70
|
-
| PrivateCollectionName
|
|
71
|
-
| (typeof LEGACY_COLLECTION_NAMES)[number]
|
|
72
|
-
| (typeof LEGACY_PRIVATE_COLLECTION_NAMES)[number];
|
|
73
|
-
|
|
74
|
-
export type RecordLink = {
|
|
75
|
-
toType: RuntimeCollectionName;
|
|
76
|
-
toId: string;
|
|
77
|
-
relationship: string;
|
|
78
|
-
createdAt: string;
|
|
79
|
-
createdBy: string;
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
export type RecordEdge = RecordLink & {
|
|
83
|
-
fromType: RuntimeCollectionName;
|
|
84
|
-
fromId: string;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export type DxcRecord = {
|
|
88
|
-
_id: string;
|
|
89
|
-
recordType: RuntimeCollectionName;
|
|
90
|
-
readableId?: string;
|
|
91
|
-
workspaceId?: string;
|
|
92
|
-
title?: string;
|
|
93
|
-
summary?: string;
|
|
94
|
-
fields: Record<string, unknown>;
|
|
95
|
-
links: RecordLink[];
|
|
96
|
-
archivedAt?: string;
|
|
97
|
-
archivedBy?: string;
|
|
98
|
-
archiveReason?: string;
|
|
99
|
-
createdAt: string;
|
|
100
|
-
createdBy: string;
|
|
101
|
-
updatedAt: string;
|
|
102
|
-
updatedBy: string;
|
|
103
|
-
derived?: Record<string, unknown>;
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
export type ReviewableRecordType = "expectations" | "requirements";
|
|
107
|
-
|
|
108
|
-
export type ReviewNote = {
|
|
109
|
-
id: string;
|
|
110
|
-
body: string;
|
|
111
|
-
createdAt: string;
|
|
112
|
-
createdBy: string;
|
|
113
|
-
important?: true;
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
export type RecordVersionSnapshot = {
|
|
117
|
-
title?: string;
|
|
118
|
-
summary?: string;
|
|
119
|
-
fields: Record<string, unknown>;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
export type RecordVersionHistoryEntry = {
|
|
123
|
-
id: string;
|
|
124
|
-
fromVersion: number;
|
|
125
|
-
toVersion: number;
|
|
126
|
-
createdAt: string;
|
|
127
|
-
createdBy: string;
|
|
128
|
-
changedFields: string[];
|
|
129
|
-
previousSnapshot: RecordVersionSnapshot;
|
|
130
|
-
nextSnapshot: RecordVersionSnapshot;
|
|
131
|
-
revisionNote?: string;
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
export type ChangeEventType =
|
|
135
|
-
| "notice_given"
|
|
136
|
-
| "veto_recorded"
|
|
137
|
-
| "decision_recorded"
|
|
138
|
-
| "result_reported"
|
|
139
|
-
| "recovery_recorded"
|
|
140
|
-
| "plan_revised"
|
|
141
|
-
| "note_added";
|
|
142
|
-
|
|
143
|
-
export type ChangeEvent = {
|
|
144
|
-
id: string;
|
|
145
|
-
eventType: ChangeEventType;
|
|
146
|
-
createdAt: string;
|
|
147
|
-
createdBy: string;
|
|
148
|
-
} & Record<string, unknown>;
|
|
149
|
-
|
|
150
|
-
export type DeferralEventType =
|
|
151
|
-
| "condition_addressed"
|
|
152
|
-
| "condition_reopened"
|
|
153
|
-
| "condition_note_added"
|
|
154
|
-
| "deferral_resolved"
|
|
155
|
-
| "deferral_abandoned";
|
|
156
|
-
|
|
157
|
-
export type DeferralCondition = {
|
|
158
|
-
id: string;
|
|
159
|
-
statement: string;
|
|
160
|
-
state: "open" | "addressed";
|
|
161
|
-
createdAt: string;
|
|
162
|
-
createdBy: string;
|
|
163
|
-
updatedAt: string;
|
|
164
|
-
updatedBy: string;
|
|
165
|
-
};
|
|
166
|
-
|
|
167
|
-
export type DeferralEvent = {
|
|
168
|
-
id: string;
|
|
169
|
-
eventType: DeferralEventType;
|
|
170
|
-
createdAt: string;
|
|
171
|
-
createdBy: string;
|
|
172
|
-
} & Record<string, unknown>;
|
|
173
|
-
|
|
174
|
-
export type DecisionEntryType = "argument" | "decision" | "note";
|
|
175
|
-
|
|
176
|
-
export type DecisionEntry = {
|
|
177
|
-
id: string;
|
|
178
|
-
entryType: DecisionEntryType;
|
|
179
|
-
body: string;
|
|
180
|
-
createdAt: string;
|
|
181
|
-
createdBy: string;
|
|
182
|
-
decidedBy?: string;
|
|
183
|
-
rationale?: string;
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
export type TaskEntryType = "comment" | "status_change" | "note";
|
|
187
|
-
export type TaskStatus = "open" | "in_progress" | "blocked" | "done";
|
|
188
|
-
|
|
189
|
-
export type TaskEntry = {
|
|
190
|
-
id: string;
|
|
191
|
-
entryType: TaskEntryType;
|
|
192
|
-
body: string;
|
|
193
|
-
createdAt: string;
|
|
194
|
-
createdBy: string;
|
|
195
|
-
status?: TaskStatus;
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
export type RiskEntryType = "identified" | "assessment" | "treatment" | "monitor_note" | "closed" | "reopened";
|
|
199
|
-
export type RiskLevel = "low" | "medium" | "high";
|
|
200
|
-
export type RiskTreatment = "accept" | "mitigate" | "transfer" | "avoid";
|
|
201
|
-
|
|
202
|
-
export type RiskEntry = {
|
|
203
|
-
id: string;
|
|
204
|
-
entryType: RiskEntryType;
|
|
205
|
-
body: string;
|
|
206
|
-
createdAt: string;
|
|
207
|
-
createdBy: string;
|
|
208
|
-
likelihood?: RiskLevel;
|
|
209
|
-
impact?: RiskLevel;
|
|
210
|
-
treatment?: RiskTreatment;
|
|
211
|
-
treatmentRationale?: string;
|
|
212
|
-
};
|
|
213
|
-
|
|
214
|
-
export type IncidentEntryType = "detected" | "update" | "severity" | "resolved" | "reopened" | "note";
|
|
215
|
-
export type IncidentSeverity = "low" | "medium" | "high" | "critical";
|
|
216
|
-
|
|
217
|
-
export type IncidentEntry = {
|
|
218
|
-
id: string;
|
|
219
|
-
entryType: IncidentEntryType;
|
|
220
|
-
body: string;
|
|
221
|
-
createdAt: string;
|
|
222
|
-
createdBy: string;
|
|
223
|
-
severity?: IncidentSeverity;
|
|
224
|
-
};
|
|
225
|
-
|
|
226
|
-
export type ProblemEntryType =
|
|
227
|
-
| "identified"
|
|
228
|
-
| "investigation"
|
|
229
|
-
| "root_cause"
|
|
230
|
-
| "known_error"
|
|
231
|
-
| "resolved"
|
|
232
|
-
| "reopened"
|
|
233
|
-
| "note";
|
|
234
|
-
|
|
235
|
-
export type ProblemEntry = {
|
|
236
|
-
id: string;
|
|
237
|
-
entryType: ProblemEntryType;
|
|
238
|
-
body: string;
|
|
239
|
-
createdAt: string;
|
|
240
|
-
createdBy: string;
|
|
241
|
-
rootCause?: string;
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
export type SupportRequestEntryType =
|
|
245
|
-
| "raised"
|
|
246
|
-
| "triage"
|
|
247
|
-
| "update"
|
|
248
|
-
| "escalated"
|
|
249
|
-
| "resolved"
|
|
250
|
-
| "reopened"
|
|
251
|
-
| "note";
|
|
252
|
-
|
|
253
|
-
export type SupportRequestEntry = {
|
|
254
|
-
id: string;
|
|
255
|
-
entryType: SupportRequestEntryType;
|
|
256
|
-
body: string;
|
|
257
|
-
createdAt: string;
|
|
258
|
-
createdBy: string;
|
|
259
|
-
incidentId?: string;
|
|
260
|
-
};
|
|
261
|
-
|
|
262
|
-
export type JournalEntryKind = "note" | "summary";
|
|
263
|
-
|
|
264
|
-
export type AppendJournalNoteInput = {
|
|
265
|
-
workspaceId: string;
|
|
266
|
-
body: string;
|
|
267
|
-
tag?: string;
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
export type ReadJournalInput = {
|
|
271
|
-
workspaceId: string;
|
|
272
|
-
limit?: number;
|
|
273
|
-
includeArchived?: boolean;
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
export type ReadJournalResult = {
|
|
277
|
-
workspaceId: string;
|
|
278
|
-
readTier: "hot" | "cold";
|
|
279
|
-
compaction: {
|
|
280
|
-
activeRawNoteCount: number;
|
|
281
|
-
threshold: number;
|
|
282
|
-
recommended: boolean;
|
|
283
|
-
};
|
|
284
|
-
entries: DxcRecord[];
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
export type GetJournalEntryInput = {
|
|
288
|
-
workspaceId: string;
|
|
289
|
-
id: string;
|
|
290
|
-
};
|
|
291
|
-
|
|
292
|
-
export type AppendJournalSummaryInput = {
|
|
293
|
-
workspaceId: string;
|
|
294
|
-
body: string;
|
|
295
|
-
covers: string[];
|
|
296
|
-
tag?: string;
|
|
297
|
-
};
|
|
298
|
-
|
|
299
|
-
export type CreateRecordInput = {
|
|
300
|
-
id?: string;
|
|
301
|
-
workspaceId?: string;
|
|
302
|
-
title?: string;
|
|
303
|
-
summary?: string;
|
|
304
|
-
fields?: Record<string, unknown>;
|
|
305
|
-
allowManagedFields?: boolean;
|
|
306
|
-
};
|
|
307
|
-
|
|
308
|
-
export type UpdateRecordInput = {
|
|
309
|
-
recordType: CollectionName;
|
|
310
|
-
id: string;
|
|
311
|
-
workspaceId?: string;
|
|
312
|
-
title?: string;
|
|
313
|
-
summary?: string;
|
|
314
|
-
fields?: Record<string, unknown>;
|
|
315
|
-
unsetFields?: string[];
|
|
316
|
-
allowManagedFields?: boolean;
|
|
317
|
-
revisionNote?: string;
|
|
318
|
-
};
|
|
319
|
-
|
|
320
|
-
export type AppendReviewNoteInput = {
|
|
321
|
-
recordType: ReviewableRecordType;
|
|
322
|
-
id: string;
|
|
323
|
-
workspaceId: string;
|
|
324
|
-
body: string;
|
|
325
|
-
important?: boolean;
|
|
326
|
-
};
|
|
327
|
-
|
|
328
|
-
export type AppendChangeEventInput = {
|
|
329
|
-
workspaceId: string;
|
|
330
|
-
changeId: string;
|
|
331
|
-
eventType: ChangeEventType;
|
|
332
|
-
event: Record<string, unknown>;
|
|
333
|
-
};
|
|
334
|
-
|
|
335
|
-
export type AppendDeferralEventInput = {
|
|
336
|
-
workspaceId: string;
|
|
337
|
-
deferralId: string;
|
|
338
|
-
eventType: DeferralEventType;
|
|
339
|
-
event: Record<string, unknown>;
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
export type AppendDecisionEntryInput = {
|
|
343
|
-
workspaceId: string;
|
|
344
|
-
decisionId: string;
|
|
345
|
-
entryType: DecisionEntryType;
|
|
346
|
-
body: string;
|
|
347
|
-
decidedBy?: string;
|
|
348
|
-
rationale?: string;
|
|
349
|
-
};
|
|
350
|
-
|
|
351
|
-
export type AppendTaskEntryInput = {
|
|
352
|
-
workspaceId: string;
|
|
353
|
-
taskId: string;
|
|
354
|
-
entryType: TaskEntryType;
|
|
355
|
-
body: string;
|
|
356
|
-
status?: TaskStatus;
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
export type AppendRiskEntryInput = {
|
|
360
|
-
workspaceId: string;
|
|
361
|
-
riskId: string;
|
|
362
|
-
entryType: RiskEntryType;
|
|
363
|
-
body: string;
|
|
364
|
-
likelihood?: RiskLevel;
|
|
365
|
-
impact?: RiskLevel;
|
|
366
|
-
treatment?: RiskTreatment;
|
|
367
|
-
treatmentRationale?: string;
|
|
368
|
-
};
|
|
369
|
-
|
|
370
|
-
export type AppendIncidentEntryInput = {
|
|
371
|
-
workspaceId: string;
|
|
372
|
-
incidentId: string;
|
|
373
|
-
entryType: IncidentEntryType;
|
|
374
|
-
body: string;
|
|
375
|
-
severity?: IncidentSeverity;
|
|
376
|
-
};
|
|
377
|
-
|
|
378
|
-
export type AppendProblemEntryInput = {
|
|
379
|
-
workspaceId: string;
|
|
380
|
-
problemId: string;
|
|
381
|
-
entryType: ProblemEntryType;
|
|
382
|
-
body: string;
|
|
383
|
-
rootCause?: string;
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
export type AppendSupportRequestEntryInput = {
|
|
387
|
-
workspaceId: string;
|
|
388
|
-
supportRequestId: string;
|
|
389
|
-
entryType: SupportRequestEntryType;
|
|
390
|
-
body: string;
|
|
391
|
-
incidentId?: string;
|
|
392
|
-
};
|
|
393
|
-
|
|
394
|
-
export type UnlinkRecordsInput = {
|
|
395
|
-
fromType: CollectionName;
|
|
396
|
-
fromId: string;
|
|
397
|
-
toType: CollectionName;
|
|
398
|
-
toId: string;
|
|
399
|
-
workspaceId?: string;
|
|
400
|
-
relationship?: string;
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
export type ArchiveRecordInput = {
|
|
404
|
-
recordType: CollectionName;
|
|
405
|
-
id: string;
|
|
406
|
-
workspaceId?: string;
|
|
407
|
-
reason?: string;
|
|
408
|
-
supersededByType?: CollectionName;
|
|
409
|
-
supersededById?: string;
|
|
410
|
-
};
|
|
411
|
-
|
|
412
|
-
export type LinkedRecordsResult = {
|
|
413
|
-
source: DxcRecord;
|
|
414
|
-
outbound: Array<{
|
|
415
|
-
edge: RecordEdge;
|
|
416
|
-
record: DxcRecord;
|
|
417
|
-
}>;
|
|
418
|
-
inbound: Array<{
|
|
419
|
-
edge: RecordEdge;
|
|
420
|
-
record: DxcRecord;
|
|
421
|
-
}>;
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
export type DxcompleteTicketEntryDirection = "submitter_entry" | "dxcomplete_reply";
|
|
425
|
-
|
|
426
|
-
export type DxcompleteTicketEntry = {
|
|
427
|
-
id: string;
|
|
428
|
-
body: string;
|
|
429
|
-
createdAt: string;
|
|
430
|
-
createdBy: string;
|
|
431
|
-
direction: DxcompleteTicketEntryDirection;
|
|
432
|
-
addressedToActorId?: string;
|
|
433
|
-
readAt?: string;
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
export type CreateDxcompleteTicketInput = {
|
|
437
|
-
title: string;
|
|
438
|
-
body: string;
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
export type AppendDxcompleteTicketInput = {
|
|
442
|
-
id: string;
|
|
443
|
-
body: string;
|
|
444
|
-
};
|
|
445
|
-
|
|
446
|
-
export type AppendDxcompleteTicketReplyInput = {
|
|
447
|
-
id: string;
|
|
448
|
-
body: string;
|
|
449
|
-
addressedToActorId: string;
|
|
450
|
-
};
|
|
451
|
-
|
|
452
|
-
export type ArchiveDxcompleteTicketInput = {
|
|
453
|
-
id: string;
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
export type ReadDxcompleteTicketInput = {
|
|
457
|
-
id: string;
|
|
458
|
-
};
|
|
459
|
-
|
|
460
|
-
export type DxcompleteTicketUnreadReplySummary = {
|
|
461
|
-
id: string;
|
|
462
|
-
createdAt: string;
|
|
463
|
-
createdBy: string;
|
|
464
|
-
direction: "dxcomplete_reply";
|
|
465
|
-
addressedToActorId?: string;
|
|
466
|
-
};
|
|
467
|
-
|
|
468
|
-
export type DxcompleteTicketUnreadReplyResult = {
|
|
469
|
-
ticketId: string;
|
|
470
|
-
title?: string;
|
|
471
|
-
updatedAt: string;
|
|
472
|
-
unreadReplyCount: number;
|
|
473
|
-
newestReplyAt?: string;
|
|
474
|
-
replies: DxcompleteTicketUnreadReplySummary[];
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
export const RUNTIME_ACTOR_ID = "dxcomplete-runtime";
|
|
478
|
-
const REVIEW_NOTE_RECORD_TYPES = ["expectations", "requirements"] as const;
|
|
479
|
-
const VERSIONED_RECORD_TYPES = [
|
|
480
|
-
"statements",
|
|
481
|
-
"environments",
|
|
482
|
-
"components",
|
|
483
|
-
"expectations",
|
|
484
|
-
"requirements",
|
|
485
|
-
"estimates",
|
|
486
|
-
"benefits",
|
|
487
|
-
"maintenance_schedules",
|
|
488
|
-
"value_realizations"
|
|
489
|
-
] as const;
|
|
490
|
-
const VERSION_HISTORY_FIELD = "versionHistory";
|
|
491
|
-
const CHANGE_MANAGED_FIELDS = [
|
|
492
|
-
"changePlan",
|
|
493
|
-
"executionSteps",
|
|
494
|
-
"rollbackPlan",
|
|
495
|
-
"riskImpact",
|
|
496
|
-
"changeType",
|
|
497
|
-
"impactGrade",
|
|
498
|
-
"emergencyImportance",
|
|
499
|
-
"emergencyImmediacy",
|
|
500
|
-
"emergencyRationaleGaps",
|
|
501
|
-
"plannedFor",
|
|
502
|
-
"events"
|
|
503
|
-
] as const;
|
|
504
|
-
const COMMITMENT_MANAGED_FIELDS = ["commitmentStatement", "reservations"] as const;
|
|
505
|
-
const DEFERRAL_MANAGED_FIELDS = ["reason", "status", "conditions", "conditionEvents"] as const;
|
|
506
|
-
const ESTIMATE_MANAGED_FIELDS = ["lineItems", "rollup"] as const;
|
|
507
|
-
const BENEFITS_MANAGED_FIELDS = ["benefitItems", "rollup"] as const;
|
|
508
|
-
const ENVIRONMENT_MANAGED_FIELDS = ["name", "description"] as const;
|
|
509
|
-
const COMPONENT_MANAGED_FIELDS = [
|
|
510
|
-
"name",
|
|
511
|
-
"environmentId",
|
|
512
|
-
"kind",
|
|
513
|
-
"locator",
|
|
514
|
-
"identifiers",
|
|
515
|
-
"secretPointers",
|
|
516
|
-
"notes"
|
|
517
|
-
] as const;
|
|
518
|
-
const MAINTENANCE_SCHEDULE_MANAGED_FIELDS = [
|
|
519
|
-
"name",
|
|
520
|
-
"kind",
|
|
521
|
-
"cadence",
|
|
522
|
-
"startDate",
|
|
523
|
-
"rationale",
|
|
524
|
-
"notes"
|
|
525
|
-
] as const;
|
|
526
|
-
const SUPPORT_REQUEST_LEDGER_MANAGED_FIELDS = [
|
|
527
|
-
"reporter",
|
|
528
|
-
"kind",
|
|
529
|
-
"reportedExperience",
|
|
530
|
-
"entries",
|
|
531
|
-
"currentStatus",
|
|
532
|
-
"status"
|
|
533
|
-
] as const;
|
|
534
|
-
const VALUE_REALIZATION_MANAGED_FIELDS = ["metrics"] as const;
|
|
535
|
-
const DECISION_LEDGER_MANAGED_FIELDS = [
|
|
536
|
-
"matter",
|
|
537
|
-
"entries",
|
|
538
|
-
"currentDecision",
|
|
539
|
-
"question",
|
|
540
|
-
"decision",
|
|
541
|
-
"decidedBy",
|
|
542
|
-
"rationale",
|
|
543
|
-
"argumentsConsidered",
|
|
544
|
-
"concerns",
|
|
545
|
-
"status"
|
|
546
|
-
] as const;
|
|
547
|
-
const TASK_LEDGER_MANAGED_FIELDS = [
|
|
548
|
-
"description",
|
|
549
|
-
"assignee",
|
|
550
|
-
"assignor",
|
|
551
|
-
"entries",
|
|
552
|
-
"currentStatus",
|
|
553
|
-
"details",
|
|
554
|
-
"status"
|
|
555
|
-
] as const;
|
|
556
|
-
const RISK_LEDGER_MANAGED_FIELDS = [
|
|
557
|
-
"topic",
|
|
558
|
-
"entries",
|
|
559
|
-
"currentStatus",
|
|
560
|
-
"currentAssessment",
|
|
561
|
-
"currentTreatment",
|
|
562
|
-
"likelihood",
|
|
563
|
-
"impact",
|
|
564
|
-
"mitigation"
|
|
565
|
-
] as const;
|
|
566
|
-
const INCIDENT_LEDGER_MANAGED_FIELDS = [
|
|
567
|
-
"description",
|
|
568
|
-
"entries",
|
|
569
|
-
"currentStatus",
|
|
570
|
-
"currentSeverity",
|
|
571
|
-
"status",
|
|
572
|
-
"severity"
|
|
573
|
-
] as const;
|
|
574
|
-
const PROBLEM_LEDGER_MANAGED_FIELDS = [
|
|
575
|
-
"description",
|
|
576
|
-
"entries",
|
|
577
|
-
"currentStatus",
|
|
578
|
-
"currentRootCause",
|
|
579
|
-
"status",
|
|
580
|
-
"rootCause"
|
|
581
|
-
] as const;
|
|
582
|
-
const DECISION_INPUT_MANAGED_FIELDS = ["informedBy", "informedByIds", "inputRecords"] as const;
|
|
583
|
-
const JOURNAL_ENTRY_MANAGED_FIELDS = ["kind", "body", "tag", "covers", "coveredBySummaryId"] as const;
|
|
584
|
-
const JOURNAL_COMPACTION_THRESHOLD = 200;
|
|
585
|
-
const VERSIONED_TYPED_FIELDS: Partial<Record<CollectionName, string[]>> = {
|
|
586
|
-
statements: ["statement", "source"],
|
|
587
|
-
expectations: ["statement", "successRecognition", "approvalState", "approvedBy", "approvedAt", "source"],
|
|
588
|
-
requirements: ["statement", "acceptanceCriteria", "priority", "status"],
|
|
589
|
-
estimates: ["lineItems", "rollup"],
|
|
590
|
-
benefits: ["benefitItems", "rollup"],
|
|
591
|
-
environments: ["name", "description"],
|
|
592
|
-
components: ["name", "environmentId", "kind", "locator", "identifiers", "secretPointers", "notes"],
|
|
593
|
-
maintenance_schedules: ["name", "kind", "cadence", "startDate", "rationale", "notes"],
|
|
594
|
-
value_realizations: ["metrics"]
|
|
595
|
-
};
|
|
596
|
-
|
|
597
|
-
const RESERVED_RELATIONSHIP_FIELDS: Partial<Record<CollectionName, string[]>> = {
|
|
598
|
-
changes: ["requirementId"],
|
|
599
|
-
commitments: ["requirementIds", "expectationIds", "deferralId"],
|
|
600
|
-
deferrals: ["requirementIds", "expectationIds"],
|
|
601
|
-
estimates: ["requirementIds", "expectationIds"],
|
|
602
|
-
benefits: ["requirementIds", "expectationIds"],
|
|
603
|
-
value_realizations: ["requirementIds", "expectationIds", "commitmentIds"],
|
|
604
|
-
expectations: ["statementId"],
|
|
605
|
-
requirements: ["expectationId"],
|
|
606
|
-
tasks: ["requirementId"]
|
|
607
|
-
};
|
|
608
|
-
|
|
609
|
-
const REMOVED_INITIATIVE_FIELD_RECORD_TYPES: CollectionName[] = [
|
|
610
|
-
"changes",
|
|
611
|
-
"estimates",
|
|
612
|
-
"benefits",
|
|
613
|
-
"expectations"
|
|
614
|
-
];
|
|
615
|
-
|
|
616
|
-
export function assertCollectionName(value: string): CollectionName {
|
|
617
|
-
if ((COLLECTION_NAMES as readonly string[]).includes(value)) {
|
|
618
|
-
return value as CollectionName;
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
throw new Error(`Unsupported record type "${value}".`);
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
export async function createRecord(
|
|
625
|
-
db: Db,
|
|
626
|
-
recordType: CollectionName,
|
|
627
|
-
input: CreateRecordInput,
|
|
628
|
-
actorId: string
|
|
629
|
-
): Promise<DxcRecord> {
|
|
630
|
-
assertNoReservedRelationshipFields(recordType, input.fields);
|
|
631
|
-
if (input.allowManagedFields) {
|
|
632
|
-
assertNoReviewNotesField(recordType, input.fields);
|
|
633
|
-
} else {
|
|
634
|
-
assertNoManagedField(recordType, input.fields);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
const now = new Date().toISOString();
|
|
638
|
-
const workspaceId = workspaceIdForCreate(recordType, input.workspaceId);
|
|
639
|
-
|
|
640
|
-
if (workspaceId) {
|
|
641
|
-
await assertWorkspaceExists(db, workspaceId);
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
const record: DxcRecord = {
|
|
645
|
-
_id: input.id ?? randomUUID(),
|
|
646
|
-
recordType,
|
|
647
|
-
...(workspaceId ? { workspaceId } : {}),
|
|
648
|
-
title: input.title,
|
|
649
|
-
summary: input.summary,
|
|
650
|
-
fields: input.fields ?? {},
|
|
651
|
-
links: [],
|
|
652
|
-
createdAt: now,
|
|
653
|
-
createdBy: actorId,
|
|
654
|
-
updatedAt: now,
|
|
655
|
-
updatedBy: actorId
|
|
656
|
-
};
|
|
657
|
-
|
|
658
|
-
if (recordTypeSupportsReadableId(recordType)) {
|
|
659
|
-
const session = db.client.startSession();
|
|
660
|
-
try {
|
|
661
|
-
await session.withTransaction(async () => {
|
|
662
|
-
record.readableId = await allocateReadableId(db, recordType, workspaceId, actorId, now, session);
|
|
663
|
-
await db.collection<DxcRecord>(recordType).insertOne(record, { session });
|
|
664
|
-
});
|
|
665
|
-
} finally {
|
|
666
|
-
await session.endSession();
|
|
667
|
-
}
|
|
668
|
-
return withDerivedRecordFields(db, record);
|
|
669
|
-
}
|
|
670
|
-
|
|
671
|
-
await db.collection<DxcRecord>(recordType).insertOne(record);
|
|
672
|
-
return withDerivedRecordFields(db, record);
|
|
673
|
-
}
|
|
674
|
-
|
|
675
|
-
export async function listRecords(
|
|
676
|
-
db: Db,
|
|
677
|
-
recordType: CollectionName,
|
|
678
|
-
limit: number,
|
|
679
|
-
options: { includeArchived?: boolean; workspaceId?: string } = {}
|
|
680
|
-
): Promise<DxcRecord[]> {
|
|
681
|
-
const filter: Record<string, unknown> = {};
|
|
682
|
-
|
|
683
|
-
if (recordTypeRequiresWorkspace(recordType)) {
|
|
684
|
-
filter.workspaceId = readRequiredWorkspaceId(options.workspaceId, recordType);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
if (!options.includeArchived) {
|
|
688
|
-
filter.archivedAt = { $exists: false };
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
const records = await db
|
|
692
|
-
.collection<DxcRecord>(recordType)
|
|
693
|
-
.find(filter)
|
|
694
|
-
.sort({ createdAt: -1 })
|
|
695
|
-
.limit(limit)
|
|
696
|
-
.toArray();
|
|
697
|
-
|
|
698
|
-
return Promise.all(records.map((record) => withDerivedRecordFields(db, record)));
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
export async function getRecord(
|
|
702
|
-
db: Db,
|
|
703
|
-
recordType: RuntimeCollectionName,
|
|
704
|
-
id: string,
|
|
705
|
-
options: { workspaceId?: string; allowAnyWorkspace?: boolean } = {}
|
|
706
|
-
): Promise<DxcRecord | null> {
|
|
707
|
-
const filter = recordIdentityFilter(recordType, id);
|
|
708
|
-
|
|
709
|
-
if (
|
|
710
|
-
recordTypeRequiresWorkspace(recordType) &&
|
|
711
|
-
!options.allowAnyWorkspace &&
|
|
712
|
-
isCurrentCollection(recordType)
|
|
713
|
-
) {
|
|
714
|
-
filter.workspaceId = readRequiredWorkspaceId(options.workspaceId, recordType);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
|
-
const record = await db.collection<DxcRecord>(recordType).findOne(filter);
|
|
718
|
-
return record ? withDerivedRecordFields(db, record) : null;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
export async function listLinkedRecords(
|
|
722
|
-
db: Db,
|
|
723
|
-
input: {
|
|
724
|
-
recordType: CollectionName;
|
|
725
|
-
id: string;
|
|
726
|
-
workspaceId?: string;
|
|
727
|
-
direction?: "outbound" | "inbound" | "both";
|
|
728
|
-
relationship?: string;
|
|
729
|
-
includeArchived?: boolean;
|
|
730
|
-
}
|
|
731
|
-
): Promise<LinkedRecordsResult> {
|
|
732
|
-
const source = await getRecord(db, input.recordType, input.id, { workspaceId: input.workspaceId });
|
|
733
|
-
|
|
734
|
-
if (!source) {
|
|
735
|
-
throw new Error(`Record not found: ${input.recordType}/${input.id}`);
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
const direction = input.direction ?? "both";
|
|
739
|
-
const result: LinkedRecordsResult = {
|
|
740
|
-
source,
|
|
741
|
-
outbound: [],
|
|
742
|
-
inbound: []
|
|
743
|
-
};
|
|
744
|
-
|
|
745
|
-
if (direction === "outbound" || direction === "both") {
|
|
746
|
-
for (const link of source.links.filter((link) => linkMatches(link, input.relationship))) {
|
|
747
|
-
const target = await getRecord(db, link.toType, link.toId, {
|
|
748
|
-
workspaceId: source.workspaceId,
|
|
749
|
-
allowAnyWorkspace: !source.workspaceId
|
|
750
|
-
});
|
|
751
|
-
|
|
752
|
-
if (
|
|
753
|
-
!target ||
|
|
754
|
-
(!input.includeArchived && target.archivedAt) ||
|
|
755
|
-
!recordBelongsToWorkspace(target, source.workspaceId)
|
|
756
|
-
) {
|
|
757
|
-
continue;
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
result.outbound.push({
|
|
761
|
-
edge: {
|
|
762
|
-
fromType: source.recordType,
|
|
763
|
-
fromId: source._id,
|
|
764
|
-
...link
|
|
765
|
-
},
|
|
766
|
-
record: target
|
|
767
|
-
});
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
|
|
771
|
-
if (direction === "inbound" || direction === "both") {
|
|
772
|
-
for (const collectionName of LINK_SCAN_COLLECTION_NAMES) {
|
|
773
|
-
const filter: Record<string, unknown> = {
|
|
774
|
-
links: {
|
|
775
|
-
$elemMatch: {
|
|
776
|
-
toType: input.recordType,
|
|
777
|
-
toId: source._id,
|
|
778
|
-
...(input.relationship ? { relationship: input.relationship } : {})
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
};
|
|
782
|
-
|
|
783
|
-
if (source.workspaceId && recordTypeRequiresWorkspace(collectionName)) {
|
|
784
|
-
filter.workspaceId = source.workspaceId;
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
if (!input.includeArchived) {
|
|
788
|
-
filter.archivedAt = { $exists: false };
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
const records = await db.collection<DxcRecord>(collectionName).find(filter).toArray();
|
|
792
|
-
|
|
793
|
-
for (const record of records) {
|
|
794
|
-
for (const link of record.links.filter(
|
|
795
|
-
(link) =>
|
|
796
|
-
link.toType === input.recordType &&
|
|
797
|
-
link.toId === source._id &&
|
|
798
|
-
linkMatches(link, input.relationship)
|
|
799
|
-
)) {
|
|
800
|
-
result.inbound.push({
|
|
801
|
-
edge: {
|
|
802
|
-
fromType: record.recordType,
|
|
803
|
-
fromId: record._id,
|
|
804
|
-
...link
|
|
805
|
-
},
|
|
806
|
-
record
|
|
807
|
-
});
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
return result;
|
|
814
|
-
}
|
|
815
|
-
|
|
816
|
-
async function withDerivedRecordFields(db: Db, record: DxcRecord): Promise<DxcRecord> {
|
|
817
|
-
if (record.recordType === "maintenance_schedules") {
|
|
818
|
-
return {
|
|
819
|
-
...record,
|
|
820
|
-
derived: await deriveMaintenanceScheduleState(db, record)
|
|
821
|
-
};
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
if (record.recordType === "value_realizations") {
|
|
825
|
-
return {
|
|
826
|
-
...record,
|
|
827
|
-
derived: deriveValueRealizationState(record)
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
return record;
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
async function deriveMaintenanceScheduleState(db: Db, record: DxcRecord): Promise<Record<string, unknown>> {
|
|
835
|
-
const cadence = parseMaintenanceCadence(record.fields.cadence);
|
|
836
|
-
const startDate = parseDateString(record.fields.startDate);
|
|
837
|
-
const occurrences = await findMaintenanceOccurrences(db, record);
|
|
838
|
-
const latestOccurrence = occurrences[0];
|
|
839
|
-
const latestOccurrenceAt = latestOccurrence?.occurredAt;
|
|
840
|
-
const basis = latestOccurrenceAt ?? startDate?.toISOString();
|
|
841
|
-
const nextDueAt = cadence && basis ? addMaintenanceCadence(new Date(basis), cadence).toISOString() : undefined;
|
|
842
|
-
|
|
843
|
-
return compactRuntimeObject({
|
|
844
|
-
occurrenceCount: occurrences.length,
|
|
845
|
-
latestOccurrence,
|
|
846
|
-
cadence: cadence ?? undefined,
|
|
847
|
-
startDate: startDate?.toISOString(),
|
|
848
|
-
nextDueAt,
|
|
849
|
-
overdue: nextDueAt ? new Date() > new Date(nextDueAt) : undefined
|
|
850
|
-
});
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async function findMaintenanceOccurrences(
|
|
854
|
-
db: Db,
|
|
855
|
-
schedule: DxcRecord
|
|
856
|
-
): Promise<Array<{ recordType: "changes" | "tasks"; id: string; readableId?: string; occurredAt: string }>> {
|
|
857
|
-
const workspaceId = schedule.workspaceId;
|
|
858
|
-
if (!workspaceId) {
|
|
859
|
-
return [];
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const linkFilter = {
|
|
863
|
-
workspaceId,
|
|
864
|
-
archivedAt: { $exists: false },
|
|
865
|
-
links: {
|
|
866
|
-
$elemMatch: {
|
|
867
|
-
toType: "maintenance_schedules",
|
|
868
|
-
toId: schedule._id,
|
|
869
|
-
relationship: "assigned_to"
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
};
|
|
873
|
-
const [changes, tasks] = await Promise.all([
|
|
874
|
-
db.collection<DxcRecord>("changes").find(linkFilter).toArray(),
|
|
875
|
-
db.collection<DxcRecord>("tasks").find(linkFilter).toArray()
|
|
876
|
-
]);
|
|
877
|
-
const occurrences = [
|
|
878
|
-
...changes.flatMap((record) => occurrenceFromChange(record)),
|
|
879
|
-
...tasks.flatMap((record) => occurrenceFromTask(record))
|
|
880
|
-
];
|
|
881
|
-
|
|
882
|
-
return occurrences.sort((left, right) => right.occurredAt.localeCompare(left.occurredAt));
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
function occurrenceFromChange(record: DxcRecord): Array<{ recordType: "changes"; id: string; readableId?: string; occurredAt: string }> {
|
|
886
|
-
const events = Array.isArray(record.fields.events) ? record.fields.events : [];
|
|
887
|
-
const event = [...events]
|
|
888
|
-
.reverse()
|
|
889
|
-
.find((entry): entry is Record<string, unknown> =>
|
|
890
|
-
Boolean(
|
|
891
|
-
entry &&
|
|
892
|
-
typeof entry === "object" &&
|
|
893
|
-
(entry.eventType === "result_reported" || entry.eventType === "recovery_recorded") &&
|
|
894
|
-
typeof entry.createdAt === "string"
|
|
895
|
-
)
|
|
896
|
-
);
|
|
897
|
-
const plannedFor = typeof record.fields.plannedFor === "string" ? record.fields.plannedFor : undefined;
|
|
898
|
-
const occurredAt = typeof event?.createdAt === "string" ? event.createdAt : plannedFor ?? record.createdAt;
|
|
899
|
-
|
|
900
|
-
return [{ recordType: "changes", id: record._id, readableId: record.readableId, occurredAt }];
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
function occurrenceFromTask(record: DxcRecord): Array<{ recordType: "tasks"; id: string; readableId?: string; occurredAt: string }> {
|
|
904
|
-
const entries = Array.isArray(record.fields.entries) ? record.fields.entries : [];
|
|
905
|
-
const doneEntry = [...entries]
|
|
906
|
-
.reverse()
|
|
907
|
-
.find((entry): entry is Record<string, unknown> =>
|
|
908
|
-
Boolean(
|
|
909
|
-
entry &&
|
|
910
|
-
typeof entry === "object" &&
|
|
911
|
-
entry.entryType === "status_change" &&
|
|
912
|
-
entry.status === "done" &&
|
|
913
|
-
typeof entry.createdAt === "string"
|
|
914
|
-
)
|
|
915
|
-
);
|
|
916
|
-
|
|
917
|
-
const occurredAt = typeof doneEntry?.createdAt === "string" ? doneEntry.createdAt : record.createdAt;
|
|
918
|
-
return [{ recordType: "tasks", id: record._id, readableId: record.readableId, occurredAt }];
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
type MaintenanceCadence = {
|
|
922
|
-
count: number;
|
|
923
|
-
unit: "day" | "week" | "month" | "quarter" | "year";
|
|
924
|
-
};
|
|
925
|
-
|
|
926
|
-
function parseMaintenanceCadence(value: unknown): MaintenanceCadence | undefined {
|
|
927
|
-
if (!value || typeof value !== "object") {
|
|
928
|
-
return undefined;
|
|
929
|
-
}
|
|
930
|
-
const cadence = value as Partial<MaintenanceCadence>;
|
|
931
|
-
if (
|
|
932
|
-
Number.isInteger(cadence.count) &&
|
|
933
|
-
(cadence.count ?? 0) > 0 &&
|
|
934
|
-
(cadence.unit === "day" ||
|
|
935
|
-
cadence.unit === "week" ||
|
|
936
|
-
cadence.unit === "month" ||
|
|
937
|
-
cadence.unit === "quarter" ||
|
|
938
|
-
cadence.unit === "year")
|
|
939
|
-
) {
|
|
940
|
-
return { count: cadence.count as number, unit: cadence.unit };
|
|
941
|
-
}
|
|
942
|
-
return undefined;
|
|
943
|
-
}
|
|
944
|
-
|
|
945
|
-
function parseDateString(value: unknown): Date | undefined {
|
|
946
|
-
if (typeof value !== "string") {
|
|
947
|
-
return undefined;
|
|
948
|
-
}
|
|
949
|
-
const date = new Date(value);
|
|
950
|
-
return Number.isNaN(date.getTime()) ? undefined : date;
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
function addMaintenanceCadence(date: Date, cadence: MaintenanceCadence): Date {
|
|
954
|
-
const next = new Date(date);
|
|
955
|
-
switch (cadence.unit) {
|
|
956
|
-
case "day":
|
|
957
|
-
next.setUTCDate(next.getUTCDate() + cadence.count);
|
|
958
|
-
return next;
|
|
959
|
-
case "week":
|
|
960
|
-
next.setUTCDate(next.getUTCDate() + cadence.count * 7);
|
|
961
|
-
return next;
|
|
962
|
-
case "month":
|
|
963
|
-
next.setUTCMonth(next.getUTCMonth() + cadence.count);
|
|
964
|
-
return next;
|
|
965
|
-
case "quarter":
|
|
966
|
-
next.setUTCMonth(next.getUTCMonth() + cadence.count * 3);
|
|
967
|
-
return next;
|
|
968
|
-
case "year":
|
|
969
|
-
next.setUTCFullYear(next.getUTCFullYear() + cadence.count);
|
|
970
|
-
return next;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
function deriveValueRealizationState(record: DxcRecord): Record<string, unknown> {
|
|
975
|
-
const metrics = normalizeValueRealizationMetrics(record.fields.metrics);
|
|
976
|
-
const comparisons = metrics.map((metric) => {
|
|
977
|
-
if (!metric.actual) {
|
|
978
|
-
return {
|
|
979
|
-
id: metric.id,
|
|
980
|
-
name: metric.name,
|
|
981
|
-
unit: metric.unit,
|
|
982
|
-
direction: metric.direction,
|
|
983
|
-
status: "open"
|
|
984
|
-
};
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
const absoluteChange = metric.actual.value - metric.baseline.value;
|
|
988
|
-
const ratio = metric.baseline.value === 0 ? null : metric.actual.value / metric.baseline.value;
|
|
989
|
-
const percentChange = ratio === null ? null : (ratio - 1) * 100;
|
|
990
|
-
const outcome =
|
|
991
|
-
absoluteChange === 0
|
|
992
|
-
? "unchanged"
|
|
993
|
-
: metric.direction === "higher_is_better"
|
|
994
|
-
? absoluteChange > 0
|
|
995
|
-
? "improved"
|
|
996
|
-
: "regressed"
|
|
997
|
-
: absoluteChange < 0
|
|
998
|
-
? "improved"
|
|
999
|
-
: "regressed";
|
|
1000
|
-
|
|
1001
|
-
return {
|
|
1002
|
-
id: metric.id,
|
|
1003
|
-
name: metric.name,
|
|
1004
|
-
unit: metric.unit,
|
|
1005
|
-
direction: metric.direction,
|
|
1006
|
-
status: "measured",
|
|
1007
|
-
absoluteChange,
|
|
1008
|
-
percentChange,
|
|
1009
|
-
ratio,
|
|
1010
|
-
outcome,
|
|
1011
|
-
baselineMeasuredAt: metric.baseline.measuredAt,
|
|
1012
|
-
actualMeasuredAt: metric.actual.measuredAt
|
|
1013
|
-
};
|
|
1014
|
-
});
|
|
1015
|
-
|
|
1016
|
-
return {
|
|
1017
|
-
metricCount: metrics.length,
|
|
1018
|
-
openMetricCount: comparisons.filter((comparison) => comparison.status === "open").length,
|
|
1019
|
-
measuredMetricCount: comparisons.filter((comparison) => comparison.status === "measured").length,
|
|
1020
|
-
comparisons
|
|
1021
|
-
};
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1024
|
-
type ValueRealizationMetric = {
|
|
1025
|
-
id: string;
|
|
1026
|
-
name: string;
|
|
1027
|
-
unit: string;
|
|
1028
|
-
direction: "lower_is_better" | "higher_is_better";
|
|
1029
|
-
baseline: {
|
|
1030
|
-
value: number;
|
|
1031
|
-
measuredAt: string;
|
|
1032
|
-
};
|
|
1033
|
-
actual?: {
|
|
1034
|
-
value: number;
|
|
1035
|
-
measuredAt: string;
|
|
1036
|
-
};
|
|
1037
|
-
};
|
|
1038
|
-
|
|
1039
|
-
function normalizeValueRealizationMetrics(value: unknown): ValueRealizationMetric[] {
|
|
1040
|
-
if (!Array.isArray(value)) {
|
|
1041
|
-
return [];
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
return value.filter(isValueRealizationMetric);
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function isValueRealizationMetric(value: unknown): value is ValueRealizationMetric {
|
|
1048
|
-
if (!value || typeof value !== "object") {
|
|
1049
|
-
return false;
|
|
1050
|
-
}
|
|
1051
|
-
const metric = value as ValueRealizationMetric;
|
|
1052
|
-
return (
|
|
1053
|
-
typeof metric.id === "string" &&
|
|
1054
|
-
typeof metric.name === "string" &&
|
|
1055
|
-
typeof metric.unit === "string" &&
|
|
1056
|
-
(metric.direction === "lower_is_better" || metric.direction === "higher_is_better") &&
|
|
1057
|
-
isMeasuredValue(metric.baseline) &&
|
|
1058
|
-
(metric.actual === undefined || isMeasuredValue(metric.actual))
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
function isMeasuredValue(value: unknown): value is { value: number; measuredAt: string } {
|
|
1063
|
-
return (
|
|
1064
|
-
Boolean(value) &&
|
|
1065
|
-
typeof value === "object" &&
|
|
1066
|
-
typeof (value as { value?: unknown }).value === "number" &&
|
|
1067
|
-
Number.isFinite((value as { value: number }).value) &&
|
|
1068
|
-
typeof (value as { measuredAt?: unknown }).measuredAt === "string"
|
|
1069
|
-
);
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
function compactRuntimeObject(input: Record<string, unknown>): Record<string, unknown> {
|
|
1073
|
-
return Object.fromEntries(Object.entries(input).filter(([, value]) => value !== undefined));
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
export async function migrateLegacyIntakeItemsToDxcompleteTickets(db: Db): Promise<{ copied: number; skipped: number }> {
|
|
1077
|
-
const legacyCollection = db.collection<DxcRecord>(LEGACY_INTAKE_COLLECTION_NAME);
|
|
1078
|
-
const ticketCollection = db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME);
|
|
1079
|
-
const legacyRecords = await legacyCollection.find({}).toArray();
|
|
1080
|
-
let copied = 0;
|
|
1081
|
-
let skipped = 0;
|
|
1082
|
-
|
|
1083
|
-
for (const legacyRecord of legacyRecords) {
|
|
1084
|
-
const existing = await ticketCollection.findOne({ _id: legacyRecord._id });
|
|
1085
|
-
|
|
1086
|
-
if (existing) {
|
|
1087
|
-
skipped += 1;
|
|
1088
|
-
continue;
|
|
1089
|
-
}
|
|
1090
|
-
|
|
1091
|
-
const migratedRecord: DxcRecord = {
|
|
1092
|
-
...legacyRecord,
|
|
1093
|
-
recordType: DXCOMPLETE_TICKET_COLLECTION_NAME,
|
|
1094
|
-
fields: {
|
|
1095
|
-
...legacyRecord.fields,
|
|
1096
|
-
entries: normalizeTicketEntries(legacyRecord.fields.entries)
|
|
1097
|
-
}
|
|
1098
|
-
};
|
|
1099
|
-
|
|
1100
|
-
await ticketCollection.insertOne(migratedRecord);
|
|
1101
|
-
copied += 1;
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
return { copied, skipped };
|
|
1105
|
-
}
|
|
1106
|
-
|
|
1107
|
-
export async function createDxcompleteTicket(
|
|
1108
|
-
db: Db,
|
|
1109
|
-
input: CreateDxcompleteTicketInput,
|
|
1110
|
-
actor: ActorContext
|
|
1111
|
-
): Promise<DxcRecord> {
|
|
1112
|
-
const now = new Date().toISOString();
|
|
1113
|
-
const entry: DxcompleteTicketEntry = {
|
|
1114
|
-
id: randomUUID(),
|
|
1115
|
-
body: input.body,
|
|
1116
|
-
createdAt: now,
|
|
1117
|
-
createdBy: actor.actorId,
|
|
1118
|
-
direction: "submitter_entry"
|
|
1119
|
-
};
|
|
1120
|
-
const record: DxcRecord = {
|
|
1121
|
-
_id: randomUUID(),
|
|
1122
|
-
recordType: DXCOMPLETE_TICKET_COLLECTION_NAME,
|
|
1123
|
-
title: input.title,
|
|
1124
|
-
fields: {
|
|
1125
|
-
ownerActorId: actor.actorId,
|
|
1126
|
-
entries: [entry]
|
|
1127
|
-
},
|
|
1128
|
-
links: [],
|
|
1129
|
-
createdAt: now,
|
|
1130
|
-
createdBy: actor.actorId,
|
|
1131
|
-
updatedAt: now,
|
|
1132
|
-
updatedBy: actor.actorId
|
|
1133
|
-
};
|
|
1134
|
-
|
|
1135
|
-
await db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME).insertOne(record);
|
|
1136
|
-
return record;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
export async function getDxcompleteTicket(db: Db, id: string, actor: ActorContext): Promise<DxcRecord> {
|
|
1140
|
-
const record = await db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME).findOne(ticketOwnerFilter(id, actor));
|
|
1141
|
-
|
|
1142
|
-
if (!record) {
|
|
1143
|
-
throw new Error(`DX Complete Ticket not found: ${id}`);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
return record;
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
export async function listDxcompleteTickets(
|
|
1150
|
-
db: Db,
|
|
1151
|
-
actor: ActorContext,
|
|
1152
|
-
limit: number,
|
|
1153
|
-
options: { includeArchived?: boolean } = {}
|
|
1154
|
-
): Promise<DxcRecord[]> {
|
|
1155
|
-
const filter: Record<string, unknown> = {
|
|
1156
|
-
"fields.ownerActorId": actor.actorId
|
|
1157
|
-
};
|
|
1158
|
-
|
|
1159
|
-
if (!options.includeArchived) {
|
|
1160
|
-
filter.archivedAt = { $exists: false };
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
return db
|
|
1164
|
-
.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME)
|
|
1165
|
-
.find(filter)
|
|
1166
|
-
.sort({ updatedAt: -1 })
|
|
1167
|
-
.limit(limit)
|
|
1168
|
-
.toArray();
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
|
-
export async function appendDxcompleteTicket(
|
|
1172
|
-
db: Db,
|
|
1173
|
-
input: AppendDxcompleteTicketInput,
|
|
1174
|
-
actor: ActorContext
|
|
1175
|
-
): Promise<DxcRecord> {
|
|
1176
|
-
const collection = db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME);
|
|
1177
|
-
const existing = await collection.findOne({
|
|
1178
|
-
...ticketOwnerFilter(input.id, actor),
|
|
1179
|
-
archivedAt: { $exists: false }
|
|
1180
|
-
});
|
|
1181
|
-
|
|
1182
|
-
if (!existing) {
|
|
1183
|
-
throw new Error(`Active DX Complete Ticket not found: ${input.id}`);
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
const now = new Date().toISOString();
|
|
1187
|
-
const entry: DxcompleteTicketEntry = {
|
|
1188
|
-
id: randomUUID(),
|
|
1189
|
-
body: input.body,
|
|
1190
|
-
createdAt: now,
|
|
1191
|
-
createdBy: actor.actorId,
|
|
1192
|
-
direction: "submitter_entry"
|
|
1193
|
-
};
|
|
1194
|
-
|
|
1195
|
-
await collection.updateOne(
|
|
1196
|
-
ticketOwnerFilter(input.id, actor),
|
|
1197
|
-
{
|
|
1198
|
-
$push: {
|
|
1199
|
-
"fields.entries": entry
|
|
1200
|
-
},
|
|
1201
|
-
$set: {
|
|
1202
|
-
updatedAt: now,
|
|
1203
|
-
updatedBy: actor.actorId
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
);
|
|
1207
|
-
|
|
1208
|
-
return getDxcompleteTicket(db, input.id, actor);
|
|
1209
|
-
}
|
|
1210
|
-
|
|
1211
|
-
export async function appendDxcompleteTicketReply(
|
|
1212
|
-
db: Db,
|
|
1213
|
-
input: AppendDxcompleteTicketReplyInput,
|
|
1214
|
-
actorId: string = RUNTIME_ACTOR_ID
|
|
1215
|
-
): Promise<DxcRecord> {
|
|
1216
|
-
const collection = db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME);
|
|
1217
|
-
const existing = await collection.findOne({
|
|
1218
|
-
_id: input.id,
|
|
1219
|
-
"fields.ownerActorId": input.addressedToActorId,
|
|
1220
|
-
archivedAt: { $exists: false }
|
|
1221
|
-
});
|
|
1222
|
-
|
|
1223
|
-
if (!existing) {
|
|
1224
|
-
throw new Error(`Active DX Complete Ticket not found for addressed actor: ${input.id}`);
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
const now = new Date().toISOString();
|
|
1228
|
-
const entry: DxcompleteTicketEntry = {
|
|
1229
|
-
id: randomUUID(),
|
|
1230
|
-
body: input.body,
|
|
1231
|
-
createdAt: now,
|
|
1232
|
-
createdBy: actorId,
|
|
1233
|
-
direction: "dxcomplete_reply",
|
|
1234
|
-
addressedToActorId: input.addressedToActorId
|
|
1235
|
-
};
|
|
1236
|
-
|
|
1237
|
-
await collection.updateOne(
|
|
1238
|
-
{ _id: input.id },
|
|
1239
|
-
{
|
|
1240
|
-
$push: {
|
|
1241
|
-
"fields.entries": entry
|
|
1242
|
-
},
|
|
1243
|
-
$set: {
|
|
1244
|
-
updatedAt: now,
|
|
1245
|
-
updatedBy: actorId
|
|
1246
|
-
}
|
|
1247
|
-
}
|
|
1248
|
-
);
|
|
1249
|
-
|
|
1250
|
-
const record = await collection.findOne({ _id: input.id });
|
|
1251
|
-
|
|
1252
|
-
if (!record) {
|
|
1253
|
-
throw new Error(`DX Complete Ticket not found after reply append: ${input.id}`);
|
|
1254
|
-
}
|
|
1255
|
-
|
|
1256
|
-
return record;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
export async function appendJournalNote(
|
|
1260
|
-
db: Db,
|
|
1261
|
-
input: AppendJournalNoteInput,
|
|
1262
|
-
actorId: string
|
|
1263
|
-
): Promise<DxcRecord> {
|
|
1264
|
-
return createRecord(
|
|
1265
|
-
db,
|
|
1266
|
-
"journal_entries",
|
|
1267
|
-
{
|
|
1268
|
-
workspaceId: input.workspaceId,
|
|
1269
|
-
title: input.tag ? `Journal note: ${input.tag}` : "Journal note",
|
|
1270
|
-
summary: input.body,
|
|
1271
|
-
allowManagedFields: true,
|
|
1272
|
-
fields: {
|
|
1273
|
-
kind: "note",
|
|
1274
|
-
body: input.body,
|
|
1275
|
-
...(input.tag ? { tag: input.tag } : {})
|
|
1276
|
-
}
|
|
1277
|
-
},
|
|
1278
|
-
actorId
|
|
1279
|
-
);
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
export async function readJournal(db: Db, input: ReadJournalInput): Promise<ReadJournalResult> {
|
|
1283
|
-
const limit = clampJournalReadLimit(input.limit);
|
|
1284
|
-
const filter: Record<string, unknown> = {
|
|
1285
|
-
workspaceId: readRequiredWorkspaceId(input.workspaceId, "journal_entries")
|
|
1286
|
-
};
|
|
1287
|
-
|
|
1288
|
-
if (!input.includeArchived) {
|
|
1289
|
-
filter.archivedAt = { $exists: false };
|
|
1290
|
-
filter["fields.kind"] = { $in: ["note", "summary"] };
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
const [entries, activeRawNoteCount] = await Promise.all([
|
|
1294
|
-
db
|
|
1295
|
-
.collection<DxcRecord>("journal_entries")
|
|
1296
|
-
.find(filter)
|
|
1297
|
-
.sort({ createdAt: 1 })
|
|
1298
|
-
.limit(limit)
|
|
1299
|
-
.toArray(),
|
|
1300
|
-
db.collection<DxcRecord>("journal_entries").countDocuments({
|
|
1301
|
-
workspaceId: input.workspaceId,
|
|
1302
|
-
archivedAt: { $exists: false },
|
|
1303
|
-
"fields.kind": "note"
|
|
1304
|
-
})
|
|
1305
|
-
]);
|
|
1306
|
-
|
|
1307
|
-
return {
|
|
1308
|
-
workspaceId: input.workspaceId,
|
|
1309
|
-
readTier: input.includeArchived ? "cold" : "hot",
|
|
1310
|
-
compaction: {
|
|
1311
|
-
activeRawNoteCount,
|
|
1312
|
-
threshold: JOURNAL_COMPACTION_THRESHOLD,
|
|
1313
|
-
recommended: activeRawNoteCount >= JOURNAL_COMPACTION_THRESHOLD
|
|
1314
|
-
},
|
|
1315
|
-
entries
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
export async function getJournalEntry(db: Db, input: GetJournalEntryInput): Promise<DxcRecord> {
|
|
1320
|
-
const entry = await getRecord(db, "journal_entries", input.id, { workspaceId: input.workspaceId });
|
|
1321
|
-
|
|
1322
|
-
if (!entry) {
|
|
1323
|
-
throw new Error(`Journal entry not found: journal_entries/${input.id}`);
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
return entry;
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
export async function appendJournalSummary(
|
|
1330
|
-
db: Db,
|
|
1331
|
-
input: AppendJournalSummaryInput,
|
|
1332
|
-
actorId: string
|
|
1333
|
-
): Promise<DxcRecord> {
|
|
1334
|
-
const coveredIds = [...new Set(input.covers.map((id) => id.trim()).filter(Boolean))];
|
|
1335
|
-
|
|
1336
|
-
if (coveredIds.length === 0) {
|
|
1337
|
-
throw new Error("append_journal_summary requires at least one covered journal entry id.");
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
const coveredEntries: DxcRecord[] = [];
|
|
1341
|
-
for (const id of coveredIds) {
|
|
1342
|
-
const entry = await getRecord(db, "journal_entries", id, { workspaceId: input.workspaceId });
|
|
1343
|
-
|
|
1344
|
-
if (!entry) {
|
|
1345
|
-
throw new Error(`Covered journal entry not found: journal_entries/${id}`);
|
|
1346
|
-
}
|
|
1347
|
-
|
|
1348
|
-
const kind = readJournalEntryKind(entry);
|
|
1349
|
-
if (!kind) {
|
|
1350
|
-
throw new Error(`Covered record is not a journal note or summary: journal_entries/${id}`);
|
|
1351
|
-
}
|
|
1352
|
-
|
|
1353
|
-
coveredEntries.push(entry);
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
const summary = await createRecord(
|
|
1357
|
-
db,
|
|
1358
|
-
"journal_entries",
|
|
1359
|
-
{
|
|
1360
|
-
workspaceId: input.workspaceId,
|
|
1361
|
-
title: input.tag ? `Journal summary: ${input.tag}` : "Journal summary",
|
|
1362
|
-
summary: input.body,
|
|
1363
|
-
allowManagedFields: true,
|
|
1364
|
-
fields: {
|
|
1365
|
-
kind: "summary",
|
|
1366
|
-
body: input.body,
|
|
1367
|
-
covers: coveredEntries.map((entry) => entry._id),
|
|
1368
|
-
...(input.tag ? { tag: input.tag } : {})
|
|
1369
|
-
}
|
|
1370
|
-
},
|
|
1371
|
-
actorId
|
|
1372
|
-
);
|
|
1373
|
-
|
|
1374
|
-
const now = new Date().toISOString();
|
|
1375
|
-
await db.collection<DxcRecord>("journal_entries").updateMany(
|
|
1376
|
-
{
|
|
1377
|
-
workspaceId: input.workspaceId,
|
|
1378
|
-
_id: { $in: coveredEntries.map((entry) => entry._id) },
|
|
1379
|
-
archivedAt: { $exists: false }
|
|
1380
|
-
},
|
|
1381
|
-
{
|
|
1382
|
-
$set: {
|
|
1383
|
-
archivedAt: now,
|
|
1384
|
-
archivedBy: actorId,
|
|
1385
|
-
archiveReason: `Covered by journal summary ${summary._id}`,
|
|
1386
|
-
"fields.coveredBySummaryId": summary._id,
|
|
1387
|
-
updatedAt: now,
|
|
1388
|
-
updatedBy: actorId
|
|
1389
|
-
}
|
|
1390
|
-
}
|
|
1391
|
-
);
|
|
1392
|
-
|
|
1393
|
-
return summary;
|
|
1394
|
-
}
|
|
1395
|
-
|
|
1396
|
-
function clampJournalReadLimit(limit: number | undefined): number {
|
|
1397
|
-
if (!limit) {
|
|
1398
|
-
return 100;
|
|
1399
|
-
}
|
|
1400
|
-
|
|
1401
|
-
return Math.min(Math.max(limit, 1), 500);
|
|
1402
|
-
}
|
|
1403
|
-
|
|
1404
|
-
function readJournalEntryKind(record: DxcRecord): JournalEntryKind | undefined {
|
|
1405
|
-
if (record.fields.kind === "note" || record.fields.kind === "summary") {
|
|
1406
|
-
return record.fields.kind;
|
|
1407
|
-
}
|
|
1408
|
-
|
|
1409
|
-
return undefined;
|
|
1410
|
-
}
|
|
1411
|
-
|
|
1412
|
-
export async function appendReviewNote(
|
|
1413
|
-
db: Db,
|
|
1414
|
-
input: AppendReviewNoteInput,
|
|
1415
|
-
actorId: string
|
|
1416
|
-
): Promise<DxcRecord> {
|
|
1417
|
-
const collection = db.collection<DxcRecord>(input.recordType);
|
|
1418
|
-
const filter = scopedRecordFilter(input.recordType, input.id, input.workspaceId);
|
|
1419
|
-
const existing = await collection.findOne(filter);
|
|
1420
|
-
|
|
1421
|
-
if (!existing) {
|
|
1422
|
-
throw new Error(`Record not found: ${input.recordType}/${input.id}`);
|
|
1423
|
-
}
|
|
1424
|
-
|
|
1425
|
-
const now = new Date().toISOString();
|
|
1426
|
-
const note: ReviewNote = {
|
|
1427
|
-
id: randomUUID(),
|
|
1428
|
-
body: input.body,
|
|
1429
|
-
createdAt: now,
|
|
1430
|
-
createdBy: actorId,
|
|
1431
|
-
...(input.important ? { important: true } : {})
|
|
1432
|
-
};
|
|
1433
|
-
|
|
1434
|
-
await collection.updateOne(filter, {
|
|
1435
|
-
$push: {
|
|
1436
|
-
"fields.reviewNotes": note
|
|
1437
|
-
},
|
|
1438
|
-
$set: {
|
|
1439
|
-
updatedAt: now,
|
|
1440
|
-
updatedBy: actorId
|
|
1441
|
-
}
|
|
1442
|
-
});
|
|
1443
|
-
|
|
1444
|
-
const updated = await getRecord(db, input.recordType, input.id, { workspaceId: input.workspaceId });
|
|
1445
|
-
if (!updated) {
|
|
1446
|
-
throw new Error(`Record not found after review note append: ${input.recordType}/${input.id}`);
|
|
1447
|
-
}
|
|
1448
|
-
|
|
1449
|
-
return updated;
|
|
1450
|
-
}
|
|
1451
|
-
|
|
1452
|
-
export async function appendChangeEvent(
|
|
1453
|
-
db: Db,
|
|
1454
|
-
input: AppendChangeEventInput,
|
|
1455
|
-
actorId: string
|
|
1456
|
-
): Promise<DxcRecord> {
|
|
1457
|
-
const collection = db.collection<DxcRecord>("changes");
|
|
1458
|
-
const filter = scopedRecordFilter("changes", input.changeId, input.workspaceId);
|
|
1459
|
-
const existing = await collection.findOne(filter);
|
|
1460
|
-
|
|
1461
|
-
if (!existing) {
|
|
1462
|
-
throw new Error(`Record not found: changes/${input.changeId}`);
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
|
-
const now = new Date().toISOString();
|
|
1466
|
-
const event: ChangeEvent = {
|
|
1467
|
-
...input.event,
|
|
1468
|
-
id: randomUUID(),
|
|
1469
|
-
eventType: input.eventType,
|
|
1470
|
-
createdAt: now,
|
|
1471
|
-
createdBy: actorId
|
|
1472
|
-
};
|
|
1473
|
-
|
|
1474
|
-
await collection.updateOne(filter, {
|
|
1475
|
-
$push: {
|
|
1476
|
-
"fields.events": event
|
|
1477
|
-
},
|
|
1478
|
-
$set: {
|
|
1479
|
-
updatedAt: now,
|
|
1480
|
-
updatedBy: actorId
|
|
1481
|
-
}
|
|
1482
|
-
});
|
|
1483
|
-
|
|
1484
|
-
const updated = await getRecord(db, "changes", input.changeId, { workspaceId: input.workspaceId });
|
|
1485
|
-
if (!updated) {
|
|
1486
|
-
throw new Error(`Record not found after change event append: changes/${input.changeId}`);
|
|
1487
|
-
}
|
|
1488
|
-
|
|
1489
|
-
return updated;
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
export async function appendDeferralEvent(
|
|
1493
|
-
db: Db,
|
|
1494
|
-
input: AppendDeferralEventInput,
|
|
1495
|
-
actorId: string
|
|
1496
|
-
): Promise<DxcRecord> {
|
|
1497
|
-
const collection = db.collection<DxcRecord>("deferrals");
|
|
1498
|
-
const filter = scopedRecordFilter("deferrals", input.deferralId, input.workspaceId);
|
|
1499
|
-
const existing = await collection.findOne(filter);
|
|
1500
|
-
|
|
1501
|
-
if (!existing) {
|
|
1502
|
-
throw new Error(`Record not found: deferrals/${input.deferralId}`);
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
const now = new Date().toISOString();
|
|
1506
|
-
const event: DeferralEvent = {
|
|
1507
|
-
...input.event,
|
|
1508
|
-
id: randomUUID(),
|
|
1509
|
-
eventType: input.eventType,
|
|
1510
|
-
createdAt: now,
|
|
1511
|
-
createdBy: actorId
|
|
1512
|
-
};
|
|
1513
|
-
const { conditions, status } = applyDeferralEventState(existing, event, actorId, now);
|
|
1514
|
-
|
|
1515
|
-
await collection.updateOne(filter, {
|
|
1516
|
-
$push: {
|
|
1517
|
-
"fields.conditionEvents": event
|
|
1518
|
-
},
|
|
1519
|
-
$set: {
|
|
1520
|
-
"fields.conditions": conditions,
|
|
1521
|
-
"fields.status": status,
|
|
1522
|
-
updatedAt: now,
|
|
1523
|
-
updatedBy: actorId
|
|
1524
|
-
}
|
|
1525
|
-
});
|
|
1526
|
-
|
|
1527
|
-
const updated = await getRecord(db, "deferrals", input.deferralId, { workspaceId: input.workspaceId });
|
|
1528
|
-
if (!updated) {
|
|
1529
|
-
throw new Error(`Record not found after deferral event append: deferrals/${input.deferralId}`);
|
|
1530
|
-
}
|
|
1531
|
-
|
|
1532
|
-
return updated;
|
|
1533
|
-
}
|
|
1534
|
-
|
|
1535
|
-
export async function appendDecisionEntry(
|
|
1536
|
-
db: Db,
|
|
1537
|
-
input: AppendDecisionEntryInput,
|
|
1538
|
-
actorId: string
|
|
1539
|
-
): Promise<DxcRecord> {
|
|
1540
|
-
const collection = db.collection<DxcRecord>("decisions");
|
|
1541
|
-
const filter = scopedRecordFilter("decisions", input.decisionId, input.workspaceId);
|
|
1542
|
-
const existing = await collection.findOne(filter);
|
|
1543
|
-
|
|
1544
|
-
if (!existing) {
|
|
1545
|
-
throw new Error(`Record not found: decisions/${input.decisionId}`);
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
assertDecisionEntryInput(input);
|
|
1549
|
-
|
|
1550
|
-
const now = new Date().toISOString();
|
|
1551
|
-
const entry: DecisionEntry = {
|
|
1552
|
-
id: randomUUID(),
|
|
1553
|
-
entryType: input.entryType,
|
|
1554
|
-
body: input.body,
|
|
1555
|
-
createdAt: now,
|
|
1556
|
-
createdBy: actorId,
|
|
1557
|
-
...(input.decidedBy ? { decidedBy: input.decidedBy } : {}),
|
|
1558
|
-
...(input.rationale ? { rationale: input.rationale } : {})
|
|
1559
|
-
};
|
|
1560
|
-
const update: Record<string, unknown> = {
|
|
1561
|
-
$push: {
|
|
1562
|
-
"fields.entries": entry
|
|
1563
|
-
},
|
|
1564
|
-
$set: {
|
|
1565
|
-
updatedAt: now,
|
|
1566
|
-
updatedBy: actorId
|
|
1567
|
-
}
|
|
1568
|
-
};
|
|
1569
|
-
|
|
1570
|
-
if (entry.entryType === "decision") {
|
|
1571
|
-
(update.$set as Record<string, unknown>)["fields.currentDecision"] = decisionEntryToCurrentDecision(entry);
|
|
1572
|
-
}
|
|
1573
|
-
|
|
1574
|
-
await collection.updateOne(filter, update);
|
|
1575
|
-
|
|
1576
|
-
const updated = await getRecord(db, "decisions", input.decisionId, { workspaceId: input.workspaceId });
|
|
1577
|
-
if (!updated) {
|
|
1578
|
-
throw new Error(`Record not found after decision entry append: decisions/${input.decisionId}`);
|
|
1579
|
-
}
|
|
1580
|
-
|
|
1581
|
-
return updated;
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
export async function appendTaskEntry(
|
|
1585
|
-
db: Db,
|
|
1586
|
-
input: AppendTaskEntryInput,
|
|
1587
|
-
actorId: string
|
|
1588
|
-
): Promise<DxcRecord> {
|
|
1589
|
-
const collection = db.collection<DxcRecord>("tasks");
|
|
1590
|
-
const filter = scopedRecordFilter("tasks", input.taskId, input.workspaceId);
|
|
1591
|
-
const existing = await collection.findOne(filter);
|
|
1592
|
-
|
|
1593
|
-
if (!existing) {
|
|
1594
|
-
throw new Error(`Record not found: tasks/${input.taskId}`);
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
assertTaskEntryInput(input);
|
|
1598
|
-
|
|
1599
|
-
const now = new Date().toISOString();
|
|
1600
|
-
const entry: TaskEntry = {
|
|
1601
|
-
id: randomUUID(),
|
|
1602
|
-
entryType: input.entryType,
|
|
1603
|
-
body: input.body,
|
|
1604
|
-
createdAt: now,
|
|
1605
|
-
createdBy: actorId,
|
|
1606
|
-
...(input.status ? { status: input.status } : {})
|
|
1607
|
-
};
|
|
1608
|
-
const update: Record<string, unknown> = {
|
|
1609
|
-
$push: {
|
|
1610
|
-
"fields.entries": entry
|
|
1611
|
-
},
|
|
1612
|
-
$set: {
|
|
1613
|
-
updatedAt: now,
|
|
1614
|
-
updatedBy: actorId
|
|
1615
|
-
}
|
|
1616
|
-
};
|
|
1617
|
-
|
|
1618
|
-
if (entry.entryType === "status_change") {
|
|
1619
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = taskEntryToCurrentStatus(entry);
|
|
1620
|
-
}
|
|
1621
|
-
|
|
1622
|
-
await collection.updateOne(filter, update);
|
|
1623
|
-
|
|
1624
|
-
const updated = await getRecord(db, "tasks", input.taskId, { workspaceId: input.workspaceId });
|
|
1625
|
-
if (!updated) {
|
|
1626
|
-
throw new Error(`Record not found after task entry append: tasks/${input.taskId}`);
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
return updated;
|
|
1630
|
-
}
|
|
1631
|
-
|
|
1632
|
-
export async function appendRiskEntry(
|
|
1633
|
-
db: Db,
|
|
1634
|
-
input: AppendRiskEntryInput,
|
|
1635
|
-
actorId: string
|
|
1636
|
-
): Promise<DxcRecord> {
|
|
1637
|
-
const collection = db.collection<DxcRecord>("risks");
|
|
1638
|
-
const filter = scopedRecordFilter("risks", input.riskId, input.workspaceId);
|
|
1639
|
-
const existing = await collection.findOne(filter);
|
|
1640
|
-
|
|
1641
|
-
if (!existing) {
|
|
1642
|
-
throw new Error(`Record not found: risks/${input.riskId}`);
|
|
1643
|
-
}
|
|
1644
|
-
|
|
1645
|
-
assertRiskEntryInput(input);
|
|
1646
|
-
|
|
1647
|
-
const now = new Date().toISOString();
|
|
1648
|
-
const entry: RiskEntry = {
|
|
1649
|
-
id: randomUUID(),
|
|
1650
|
-
entryType: input.entryType,
|
|
1651
|
-
body: input.body,
|
|
1652
|
-
createdAt: now,
|
|
1653
|
-
createdBy: actorId,
|
|
1654
|
-
...(input.likelihood ? { likelihood: input.likelihood } : {}),
|
|
1655
|
-
...(input.impact ? { impact: input.impact } : {}),
|
|
1656
|
-
...(input.treatment ? { treatment: input.treatment } : {}),
|
|
1657
|
-
...(input.treatmentRationale ? { treatmentRationale: input.treatmentRationale } : {})
|
|
1658
|
-
};
|
|
1659
|
-
const update: Record<string, unknown> = {
|
|
1660
|
-
$push: {
|
|
1661
|
-
"fields.entries": entry
|
|
1662
|
-
},
|
|
1663
|
-
$set: {
|
|
1664
|
-
updatedAt: now,
|
|
1665
|
-
updatedBy: actorId
|
|
1666
|
-
}
|
|
1667
|
-
};
|
|
1668
|
-
|
|
1669
|
-
if (entry.entryType === "identified" || entry.entryType === "reopened") {
|
|
1670
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = riskEntryToCurrentStatus(entry, "open");
|
|
1671
|
-
}
|
|
1672
|
-
|
|
1673
|
-
if (entry.entryType === "closed") {
|
|
1674
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = riskEntryToCurrentStatus(entry, "closed");
|
|
1675
|
-
}
|
|
1676
|
-
|
|
1677
|
-
if (entry.entryType === "assessment") {
|
|
1678
|
-
(update.$set as Record<string, unknown>)["fields.currentAssessment"] = riskEntryToCurrentAssessment(entry);
|
|
1679
|
-
}
|
|
1680
|
-
|
|
1681
|
-
if (entry.entryType === "treatment") {
|
|
1682
|
-
(update.$set as Record<string, unknown>)["fields.currentTreatment"] = riskEntryToCurrentTreatment(entry);
|
|
1683
|
-
}
|
|
1684
|
-
|
|
1685
|
-
await collection.updateOne(filter, update);
|
|
1686
|
-
|
|
1687
|
-
const updated = await getRecord(db, "risks", input.riskId, { workspaceId: input.workspaceId });
|
|
1688
|
-
if (!updated) {
|
|
1689
|
-
throw new Error(`Record not found after risk entry append: risks/${input.riskId}`);
|
|
1690
|
-
}
|
|
1691
|
-
|
|
1692
|
-
return updated;
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
export async function appendIncidentEntry(
|
|
1696
|
-
db: Db,
|
|
1697
|
-
input: AppendIncidentEntryInput,
|
|
1698
|
-
actorId: string
|
|
1699
|
-
): Promise<DxcRecord> {
|
|
1700
|
-
const collection = db.collection<DxcRecord>("incidents");
|
|
1701
|
-
const filter = scopedRecordFilter("incidents", input.incidentId, input.workspaceId);
|
|
1702
|
-
const existing = await collection.findOne(filter);
|
|
1703
|
-
|
|
1704
|
-
if (!existing) {
|
|
1705
|
-
throw new Error(`Record not found: incidents/${input.incidentId}`);
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
assertIncidentEntryInput(input);
|
|
1709
|
-
|
|
1710
|
-
const now = new Date().toISOString();
|
|
1711
|
-
const entry: IncidentEntry = {
|
|
1712
|
-
id: randomUUID(),
|
|
1713
|
-
entryType: input.entryType,
|
|
1714
|
-
body: input.body,
|
|
1715
|
-
createdAt: now,
|
|
1716
|
-
createdBy: actorId,
|
|
1717
|
-
...(input.severity ? { severity: input.severity } : {})
|
|
1718
|
-
};
|
|
1719
|
-
const update: Record<string, unknown> = {
|
|
1720
|
-
$push: {
|
|
1721
|
-
"fields.entries": entry
|
|
1722
|
-
},
|
|
1723
|
-
$set: {
|
|
1724
|
-
updatedAt: now,
|
|
1725
|
-
updatedBy: actorId
|
|
1726
|
-
}
|
|
1727
|
-
};
|
|
1728
|
-
|
|
1729
|
-
if (entry.entryType === "detected" || entry.entryType === "reopened") {
|
|
1730
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = incidentEntryToCurrentStatus(entry, "open");
|
|
1731
|
-
}
|
|
1732
|
-
|
|
1733
|
-
if (entry.entryType === "resolved") {
|
|
1734
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = incidentEntryToCurrentStatus(entry, "resolved");
|
|
1735
|
-
}
|
|
1736
|
-
|
|
1737
|
-
if (entry.entryType === "severity") {
|
|
1738
|
-
(update.$set as Record<string, unknown>)["fields.currentSeverity"] = incidentEntryToCurrentSeverity(entry);
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
await collection.updateOne(filter, update);
|
|
1742
|
-
|
|
1743
|
-
const updated = await getRecord(db, "incidents", input.incidentId, { workspaceId: input.workspaceId });
|
|
1744
|
-
if (!updated) {
|
|
1745
|
-
throw new Error(`Record not found after incident entry append: incidents/${input.incidentId}`);
|
|
1746
|
-
}
|
|
1747
|
-
|
|
1748
|
-
return updated;
|
|
1749
|
-
}
|
|
1750
|
-
|
|
1751
|
-
export async function appendProblemEntry(
|
|
1752
|
-
db: Db,
|
|
1753
|
-
input: AppendProblemEntryInput,
|
|
1754
|
-
actorId: string
|
|
1755
|
-
): Promise<DxcRecord> {
|
|
1756
|
-
const collection = db.collection<DxcRecord>("problems");
|
|
1757
|
-
const filter = scopedRecordFilter("problems", input.problemId, input.workspaceId);
|
|
1758
|
-
const existing = await collection.findOne(filter);
|
|
1759
|
-
|
|
1760
|
-
if (!existing) {
|
|
1761
|
-
throw new Error(`Record not found: problems/${input.problemId}`);
|
|
1762
|
-
}
|
|
1763
|
-
|
|
1764
|
-
assertProblemEntryInput(input);
|
|
1765
|
-
|
|
1766
|
-
const now = new Date().toISOString();
|
|
1767
|
-
const entry: ProblemEntry = {
|
|
1768
|
-
id: randomUUID(),
|
|
1769
|
-
entryType: input.entryType,
|
|
1770
|
-
body: input.body,
|
|
1771
|
-
createdAt: now,
|
|
1772
|
-
createdBy: actorId,
|
|
1773
|
-
...(input.rootCause ? { rootCause: input.rootCause } : {})
|
|
1774
|
-
};
|
|
1775
|
-
const update: Record<string, unknown> = {
|
|
1776
|
-
$push: {
|
|
1777
|
-
"fields.entries": entry
|
|
1778
|
-
},
|
|
1779
|
-
$set: {
|
|
1780
|
-
updatedAt: now,
|
|
1781
|
-
updatedBy: actorId
|
|
1782
|
-
}
|
|
1783
|
-
};
|
|
1784
|
-
|
|
1785
|
-
if (entry.entryType === "identified" || entry.entryType === "reopened") {
|
|
1786
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = problemEntryToCurrentStatus(entry, "open");
|
|
1787
|
-
}
|
|
1788
|
-
|
|
1789
|
-
if (entry.entryType === "known_error") {
|
|
1790
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = problemEntryToCurrentStatus(entry, "known_error");
|
|
1791
|
-
}
|
|
1792
|
-
|
|
1793
|
-
if (entry.entryType === "resolved") {
|
|
1794
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = problemEntryToCurrentStatus(entry, "resolved");
|
|
1795
|
-
}
|
|
1796
|
-
|
|
1797
|
-
if (entry.entryType === "root_cause") {
|
|
1798
|
-
(update.$set as Record<string, unknown>)["fields.currentRootCause"] = problemEntryToCurrentRootCause(entry);
|
|
1799
|
-
}
|
|
1800
|
-
|
|
1801
|
-
await collection.updateOne(filter, update);
|
|
1802
|
-
|
|
1803
|
-
const updated = await getRecord(db, "problems", input.problemId, { workspaceId: input.workspaceId });
|
|
1804
|
-
if (!updated) {
|
|
1805
|
-
throw new Error(`Record not found after problem entry append: problems/${input.problemId}`);
|
|
1806
|
-
}
|
|
1807
|
-
|
|
1808
|
-
return updated;
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
export async function appendSupportRequestEntry(
|
|
1812
|
-
db: Db,
|
|
1813
|
-
input: AppendSupportRequestEntryInput,
|
|
1814
|
-
actorId: string
|
|
1815
|
-
): Promise<DxcRecord> {
|
|
1816
|
-
const collection = db.collection<DxcRecord>("support_requests");
|
|
1817
|
-
const filter = scopedRecordFilter("support_requests", input.supportRequestId, input.workspaceId);
|
|
1818
|
-
const existing = await collection.findOne(filter);
|
|
1819
|
-
|
|
1820
|
-
if (!existing) {
|
|
1821
|
-
throw new Error(`Record not found: support_requests/${input.supportRequestId}`);
|
|
1822
|
-
}
|
|
1823
|
-
|
|
1824
|
-
assertSupportRequestEntryInput(input);
|
|
1825
|
-
|
|
1826
|
-
const now = new Date().toISOString();
|
|
1827
|
-
const entry: SupportRequestEntry = {
|
|
1828
|
-
id: randomUUID(),
|
|
1829
|
-
entryType: input.entryType,
|
|
1830
|
-
body: input.body,
|
|
1831
|
-
createdAt: now,
|
|
1832
|
-
createdBy: actorId,
|
|
1833
|
-
...(input.incidentId ? { incidentId: input.incidentId } : {})
|
|
1834
|
-
};
|
|
1835
|
-
const update: Record<string, unknown> = {
|
|
1836
|
-
$push: {
|
|
1837
|
-
"fields.entries": entry
|
|
1838
|
-
},
|
|
1839
|
-
$set: {
|
|
1840
|
-
updatedAt: now,
|
|
1841
|
-
updatedBy: actorId
|
|
1842
|
-
}
|
|
1843
|
-
};
|
|
1844
|
-
const currentStatus = supportRequestEntryToCurrentStatus(entry, readSupportRequestStatus(existing));
|
|
1845
|
-
if (currentStatus) {
|
|
1846
|
-
(update.$set as Record<string, unknown>)["fields.currentStatus"] = currentStatus;
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
await collection.updateOne(filter, update);
|
|
1850
|
-
|
|
1851
|
-
const updated = await getRecord(db, "support_requests", input.supportRequestId, { workspaceId: input.workspaceId });
|
|
1852
|
-
if (!updated) {
|
|
1853
|
-
throw new Error(`Record not found after support request entry append: support_requests/${input.supportRequestId}`);
|
|
1854
|
-
}
|
|
1855
|
-
|
|
1856
|
-
return updated;
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
export function decisionEntryToCurrentDecision(entry: DecisionEntry): Record<string, unknown> {
|
|
1860
|
-
return {
|
|
1861
|
-
entryId: entry.id,
|
|
1862
|
-
body: entry.body,
|
|
1863
|
-
createdAt: entry.createdAt,
|
|
1864
|
-
createdBy: entry.createdBy,
|
|
1865
|
-
...(entry.decidedBy ? { decidedBy: entry.decidedBy } : {}),
|
|
1866
|
-
...(entry.rationale ? { rationale: entry.rationale } : {})
|
|
1867
|
-
};
|
|
1868
|
-
}
|
|
1869
|
-
|
|
1870
|
-
export function taskEntryToCurrentStatus(entry: TaskEntry): Record<string, unknown> {
|
|
1871
|
-
if (entry.entryType !== "status_change" || !entry.status) {
|
|
1872
|
-
throw new Error("Task current status can only derive from a status_change entry.");
|
|
1873
|
-
}
|
|
1874
|
-
|
|
1875
|
-
return {
|
|
1876
|
-
entryId: entry.id,
|
|
1877
|
-
status: entry.status,
|
|
1878
|
-
body: entry.body,
|
|
1879
|
-
createdAt: entry.createdAt,
|
|
1880
|
-
createdBy: entry.createdBy
|
|
1881
|
-
};
|
|
1882
|
-
}
|
|
1883
|
-
|
|
1884
|
-
export function riskEntryToCurrentStatus(entry: RiskEntry, status: "open" | "closed"): Record<string, unknown> {
|
|
1885
|
-
return {
|
|
1886
|
-
entryId: entry.id,
|
|
1887
|
-
status,
|
|
1888
|
-
body: entry.body,
|
|
1889
|
-
createdAt: entry.createdAt,
|
|
1890
|
-
createdBy: entry.createdBy
|
|
1891
|
-
};
|
|
1892
|
-
}
|
|
1893
|
-
|
|
1894
|
-
export function riskEntryToCurrentAssessment(entry: RiskEntry): Record<string, unknown> {
|
|
1895
|
-
if (entry.entryType !== "assessment" || !entry.likelihood || !entry.impact) {
|
|
1896
|
-
throw new Error("Risk current assessment can only derive from an assessment entry with likelihood and impact.");
|
|
1897
|
-
}
|
|
1898
|
-
|
|
1899
|
-
return {
|
|
1900
|
-
entryId: entry.id,
|
|
1901
|
-
likelihood: entry.likelihood,
|
|
1902
|
-
impact: entry.impact,
|
|
1903
|
-
body: entry.body,
|
|
1904
|
-
createdAt: entry.createdAt,
|
|
1905
|
-
createdBy: entry.createdBy
|
|
1906
|
-
};
|
|
1907
|
-
}
|
|
1908
|
-
|
|
1909
|
-
export function riskEntryToCurrentTreatment(entry: RiskEntry): Record<string, unknown> {
|
|
1910
|
-
if (entry.entryType !== "treatment" || !entry.treatment) {
|
|
1911
|
-
throw new Error("Risk current treatment can only derive from a treatment entry.");
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
return {
|
|
1915
|
-
entryId: entry.id,
|
|
1916
|
-
treatment: entry.treatment,
|
|
1917
|
-
body: entry.body,
|
|
1918
|
-
createdAt: entry.createdAt,
|
|
1919
|
-
createdBy: entry.createdBy,
|
|
1920
|
-
...(entry.treatmentRationale ? { treatmentRationale: entry.treatmentRationale } : {})
|
|
1921
|
-
};
|
|
1922
|
-
}
|
|
1923
|
-
|
|
1924
|
-
export function incidentEntryToCurrentStatus(entry: IncidentEntry, status: "open" | "resolved"): Record<string, unknown> {
|
|
1925
|
-
return {
|
|
1926
|
-
entryId: entry.id,
|
|
1927
|
-
status,
|
|
1928
|
-
body: entry.body,
|
|
1929
|
-
createdAt: entry.createdAt,
|
|
1930
|
-
createdBy: entry.createdBy
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
export function incidentEntryToCurrentSeverity(entry: IncidentEntry): Record<string, unknown> {
|
|
1935
|
-
if (entry.entryType !== "severity" || !entry.severity) {
|
|
1936
|
-
throw new Error("Incident current severity can only derive from a severity entry.");
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
return {
|
|
1940
|
-
entryId: entry.id,
|
|
1941
|
-
severity: entry.severity,
|
|
1942
|
-
body: entry.body,
|
|
1943
|
-
createdAt: entry.createdAt,
|
|
1944
|
-
createdBy: entry.createdBy
|
|
1945
|
-
};
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
export function problemEntryToCurrentStatus(
|
|
1949
|
-
entry: ProblemEntry,
|
|
1950
|
-
status: "open" | "known_error" | "resolved"
|
|
1951
|
-
): Record<string, unknown> {
|
|
1952
|
-
return {
|
|
1953
|
-
entryId: entry.id,
|
|
1954
|
-
status,
|
|
1955
|
-
body: entry.body,
|
|
1956
|
-
createdAt: entry.createdAt,
|
|
1957
|
-
createdBy: entry.createdBy
|
|
1958
|
-
};
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
export function problemEntryToCurrentRootCause(entry: ProblemEntry): Record<string, unknown> {
|
|
1962
|
-
if (entry.entryType !== "root_cause") {
|
|
1963
|
-
throw new Error("Problem current root cause can only derive from a root_cause entry.");
|
|
1964
|
-
}
|
|
1965
|
-
|
|
1966
|
-
return {
|
|
1967
|
-
entryId: entry.id,
|
|
1968
|
-
rootCause: entry.rootCause ?? entry.body,
|
|
1969
|
-
body: entry.body,
|
|
1970
|
-
createdAt: entry.createdAt,
|
|
1971
|
-
createdBy: entry.createdBy
|
|
1972
|
-
};
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
export function supportRequestEntryToCurrentStatus(
|
|
1976
|
-
entry: SupportRequestEntry,
|
|
1977
|
-
previousStatus?: Record<string, unknown>
|
|
1978
|
-
): Record<string, unknown> | undefined {
|
|
1979
|
-
const status = supportRequestStatusForEntryType(entry.entryType);
|
|
1980
|
-
if (!status) {
|
|
1981
|
-
return previousStatus;
|
|
1982
|
-
}
|
|
1983
|
-
|
|
1984
|
-
return {
|
|
1985
|
-
entryId: entry.id,
|
|
1986
|
-
status,
|
|
1987
|
-
body: entry.body,
|
|
1988
|
-
createdAt: entry.createdAt,
|
|
1989
|
-
createdBy: entry.createdBy
|
|
1990
|
-
};
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
function supportRequestStatusForEntryType(entryType: SupportRequestEntryType): "open" | "triaged" | "escalated" | "resolved" | undefined {
|
|
1994
|
-
switch (entryType) {
|
|
1995
|
-
case "raised":
|
|
1996
|
-
case "reopened":
|
|
1997
|
-
return "open";
|
|
1998
|
-
case "triage":
|
|
1999
|
-
return "triaged";
|
|
2000
|
-
case "escalated":
|
|
2001
|
-
return "escalated";
|
|
2002
|
-
case "resolved":
|
|
2003
|
-
return "resolved";
|
|
2004
|
-
case "update":
|
|
2005
|
-
case "note":
|
|
2006
|
-
return undefined;
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
function readSupportRequestStatus(record: DxcRecord): Record<string, unknown> | undefined {
|
|
2011
|
-
return record.fields.currentStatus && typeof record.fields.currentStatus === "object"
|
|
2012
|
-
? (record.fields.currentStatus as Record<string, unknown>)
|
|
2013
|
-
: undefined;
|
|
2014
|
-
}
|
|
2015
|
-
|
|
2016
|
-
function assertDecisionEntryInput(input: AppendDecisionEntryInput): void {
|
|
2017
|
-
if (input.entryType !== "decision" && (input.decidedBy || input.rationale)) {
|
|
2018
|
-
throw new Error("decidedBy and rationale are only valid on decision entries.");
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
|
|
2022
|
-
function assertTaskEntryInput(input: AppendTaskEntryInput): void {
|
|
2023
|
-
if (input.entryType === "status_change" && !input.status) {
|
|
2024
|
-
throw new Error("status_change entries require status.");
|
|
2025
|
-
}
|
|
2026
|
-
|
|
2027
|
-
if (input.entryType !== "status_change" && input.status) {
|
|
2028
|
-
throw new Error("status is only valid on status_change entries.");
|
|
2029
|
-
}
|
|
2030
|
-
}
|
|
2031
|
-
|
|
2032
|
-
function assertRiskEntryInput(input: AppendRiskEntryInput): void {
|
|
2033
|
-
if (input.entryType === "assessment" && (!input.likelihood || !input.impact)) {
|
|
2034
|
-
throw new Error("assessment entries require likelihood and impact.");
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
if (input.entryType !== "assessment" && (input.likelihood || input.impact)) {
|
|
2038
|
-
throw new Error("likelihood and impact are only valid on assessment entries.");
|
|
2039
|
-
}
|
|
2040
|
-
|
|
2041
|
-
if (input.entryType === "treatment" && !input.treatment) {
|
|
2042
|
-
throw new Error("treatment entries require treatment.");
|
|
2043
|
-
}
|
|
2044
|
-
|
|
2045
|
-
if (input.entryType !== "treatment" && (input.treatment || input.treatmentRationale)) {
|
|
2046
|
-
throw new Error("treatment and treatmentRationale are only valid on treatment entries.");
|
|
2047
|
-
}
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
function assertIncidentEntryInput(input: AppendIncidentEntryInput): void {
|
|
2051
|
-
if (input.entryType === "severity" && !input.severity) {
|
|
2052
|
-
throw new Error("severity entries require severity.");
|
|
2053
|
-
}
|
|
2054
|
-
|
|
2055
|
-
if (input.entryType !== "severity" && input.severity) {
|
|
2056
|
-
throw new Error("severity is only valid on severity entries.");
|
|
2057
|
-
}
|
|
2058
|
-
}
|
|
2059
|
-
|
|
2060
|
-
function assertProblemEntryInput(input: AppendProblemEntryInput): void {
|
|
2061
|
-
if (input.entryType !== "root_cause" && input.rootCause) {
|
|
2062
|
-
throw new Error("rootCause is only valid on root_cause entries.");
|
|
2063
|
-
}
|
|
2064
|
-
}
|
|
2065
|
-
|
|
2066
|
-
function assertSupportRequestEntryInput(input: AppendSupportRequestEntryInput): void {
|
|
2067
|
-
if (input.entryType === "escalated" && !input.incidentId) {
|
|
2068
|
-
throw new Error("escalated support request entries require incidentId.");
|
|
2069
|
-
}
|
|
2070
|
-
|
|
2071
|
-
if (input.entryType !== "escalated" && input.incidentId) {
|
|
2072
|
-
throw new Error("incidentId is only valid on escalated support request entries.");
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
function applyDeferralEventState(
|
|
2077
|
-
existing: DxcRecord,
|
|
2078
|
-
event: DeferralEvent,
|
|
2079
|
-
actorId: string,
|
|
2080
|
-
now: string
|
|
2081
|
-
): { conditions: DeferralCondition[]; status: string } {
|
|
2082
|
-
const conditions = normalizeDeferralConditions(existing.fields.conditions);
|
|
2083
|
-
let status = typeof existing.fields.status === "string" ? existing.fields.status : "open";
|
|
2084
|
-
|
|
2085
|
-
switch (event.eventType) {
|
|
2086
|
-
case "condition_addressed":
|
|
2087
|
-
return {
|
|
2088
|
-
conditions: updateDeferralConditionState(conditions, event, "addressed", actorId, now),
|
|
2089
|
-
status
|
|
2090
|
-
};
|
|
2091
|
-
case "condition_reopened":
|
|
2092
|
-
return {
|
|
2093
|
-
conditions: updateDeferralConditionState(conditions, event, "open", actorId, now),
|
|
2094
|
-
status
|
|
2095
|
-
};
|
|
2096
|
-
case "condition_note_added":
|
|
2097
|
-
assertDeferralConditionExists(conditions, event);
|
|
2098
|
-
return { conditions, status };
|
|
2099
|
-
case "deferral_resolved":
|
|
2100
|
-
status = "resolved";
|
|
2101
|
-
return { conditions, status };
|
|
2102
|
-
case "deferral_abandoned":
|
|
2103
|
-
status = "abandoned";
|
|
2104
|
-
return { conditions, status };
|
|
2105
|
-
}
|
|
2106
|
-
}
|
|
2107
|
-
|
|
2108
|
-
function normalizeDeferralConditions(value: unknown): DeferralCondition[] {
|
|
2109
|
-
if (!Array.isArray(value)) {
|
|
2110
|
-
return [];
|
|
2111
|
-
}
|
|
2112
|
-
|
|
2113
|
-
return value.filter(isDeferralCondition);
|
|
2114
|
-
}
|
|
2115
|
-
|
|
2116
|
-
function isDeferralCondition(value: unknown): value is DeferralCondition {
|
|
2117
|
-
if (!value || typeof value !== "object") {
|
|
2118
|
-
return false;
|
|
2119
|
-
}
|
|
2120
|
-
|
|
2121
|
-
const condition = value as DeferralCondition;
|
|
2122
|
-
return (
|
|
2123
|
-
typeof condition.id === "string" &&
|
|
2124
|
-
typeof condition.statement === "string" &&
|
|
2125
|
-
(condition.state === "open" || condition.state === "addressed") &&
|
|
2126
|
-
typeof condition.createdAt === "string" &&
|
|
2127
|
-
typeof condition.createdBy === "string" &&
|
|
2128
|
-
typeof condition.updatedAt === "string" &&
|
|
2129
|
-
typeof condition.updatedBy === "string"
|
|
2130
|
-
);
|
|
2131
|
-
}
|
|
2132
|
-
|
|
2133
|
-
function updateDeferralConditionState(
|
|
2134
|
-
conditions: DeferralCondition[],
|
|
2135
|
-
event: DeferralEvent,
|
|
2136
|
-
state: "open" | "addressed",
|
|
2137
|
-
actorId: string,
|
|
2138
|
-
now: string
|
|
2139
|
-
): DeferralCondition[] {
|
|
2140
|
-
const conditionId = readRequiredDeferralEventString(event, "conditionId");
|
|
2141
|
-
let found = false;
|
|
2142
|
-
const updated = conditions.map((condition) => {
|
|
2143
|
-
if (condition.id !== conditionId) {
|
|
2144
|
-
return condition;
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
found = true;
|
|
2148
|
-
return {
|
|
2149
|
-
...condition,
|
|
2150
|
-
state,
|
|
2151
|
-
updatedAt: now,
|
|
2152
|
-
updatedBy: actorId
|
|
2153
|
-
};
|
|
2154
|
-
});
|
|
2155
|
-
|
|
2156
|
-
if (!found) {
|
|
2157
|
-
throw new Error(`Deferral condition not found: ${conditionId}`);
|
|
2158
|
-
}
|
|
2159
|
-
|
|
2160
|
-
return updated;
|
|
2161
|
-
}
|
|
2162
|
-
|
|
2163
|
-
function assertDeferralConditionExists(conditions: DeferralCondition[], event: DeferralEvent): void {
|
|
2164
|
-
const conditionId = readRequiredDeferralEventString(event, "conditionId");
|
|
2165
|
-
if (!conditions.some((condition) => condition.id === conditionId)) {
|
|
2166
|
-
throw new Error(`Deferral condition not found: ${conditionId}`);
|
|
2167
|
-
}
|
|
2168
|
-
}
|
|
2169
|
-
|
|
2170
|
-
function readRequiredDeferralEventString(event: DeferralEvent, key: string): string {
|
|
2171
|
-
const value = event[key];
|
|
2172
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
2173
|
-
throw new Error(`${event.eventType} requires ${key}.`);
|
|
2174
|
-
}
|
|
2175
|
-
return value;
|
|
2176
|
-
}
|
|
2177
|
-
|
|
2178
|
-
export async function listUnreadDxcompleteTicketReplies(
|
|
2179
|
-
db: Db,
|
|
2180
|
-
actor: ActorContext,
|
|
2181
|
-
limit: number
|
|
2182
|
-
): Promise<DxcompleteTicketUnreadReplyResult[]> {
|
|
2183
|
-
const records = await db
|
|
2184
|
-
.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME)
|
|
2185
|
-
.find({
|
|
2186
|
-
"fields.ownerActorId": actor.actorId,
|
|
2187
|
-
archivedAt: { $exists: false },
|
|
2188
|
-
"fields.entries": {
|
|
2189
|
-
$elemMatch: {
|
|
2190
|
-
direction: "dxcomplete_reply",
|
|
2191
|
-
addressedToActorId: actor.actorId,
|
|
2192
|
-
readAt: { $exists: false }
|
|
2193
|
-
}
|
|
2194
|
-
}
|
|
2195
|
-
})
|
|
2196
|
-
.sort({ updatedAt: -1 })
|
|
2197
|
-
.limit(limit)
|
|
2198
|
-
.toArray();
|
|
2199
|
-
|
|
2200
|
-
return records.map((record) => {
|
|
2201
|
-
const replies = normalizeTicketEntries(record.fields.entries).filter(
|
|
2202
|
-
(entry) =>
|
|
2203
|
-
entry.direction === "dxcomplete_reply" &&
|
|
2204
|
-
entry.addressedToActorId === actor.actorId &&
|
|
2205
|
-
!entry.readAt
|
|
2206
|
-
);
|
|
2207
|
-
const replySummaries = replies.map((entry) => ({
|
|
2208
|
-
id: entry.id,
|
|
2209
|
-
createdAt: entry.createdAt,
|
|
2210
|
-
createdBy: entry.createdBy,
|
|
2211
|
-
direction: "dxcomplete_reply" as const,
|
|
2212
|
-
...(entry.addressedToActorId ? { addressedToActorId: entry.addressedToActorId } : {})
|
|
2213
|
-
}));
|
|
2214
|
-
const newestReplyAt = replySummaries.reduce<string | undefined>(
|
|
2215
|
-
(current, entry) => (!current || entry.createdAt > current ? entry.createdAt : current),
|
|
2216
|
-
undefined
|
|
2217
|
-
);
|
|
2218
|
-
|
|
2219
|
-
return {
|
|
2220
|
-
ticketId: record._id,
|
|
2221
|
-
...(record.title ? { title: record.title } : {}),
|
|
2222
|
-
updatedAt: record.updatedAt,
|
|
2223
|
-
unreadReplyCount: replySummaries.length,
|
|
2224
|
-
...(newestReplyAt ? { newestReplyAt } : {}),
|
|
2225
|
-
replies: replySummaries
|
|
2226
|
-
};
|
|
2227
|
-
});
|
|
2228
|
-
}
|
|
2229
|
-
|
|
2230
|
-
export async function readDxcompleteTicket(
|
|
2231
|
-
db: Db,
|
|
2232
|
-
input: ReadDxcompleteTicketInput,
|
|
2233
|
-
actor: ActorContext
|
|
2234
|
-
): Promise<DxcRecord> {
|
|
2235
|
-
const collection = db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME);
|
|
2236
|
-
const existing = await collection.findOne(ticketOwnerFilter(input.id, actor));
|
|
2237
|
-
|
|
2238
|
-
if (!existing) {
|
|
2239
|
-
throw new Error(`DX Complete Ticket not found: ${input.id}`);
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
const now = new Date().toISOString();
|
|
2243
|
-
const entries = normalizeTicketEntries(existing.fields.entries);
|
|
2244
|
-
let changed = false;
|
|
2245
|
-
const nextEntries = entries.map((entry) => {
|
|
2246
|
-
const shouldMark =
|
|
2247
|
-
entry.direction === "dxcomplete_reply" &&
|
|
2248
|
-
entry.addressedToActorId === actor.actorId &&
|
|
2249
|
-
!entry.readAt;
|
|
2250
|
-
|
|
2251
|
-
if (!shouldMark) {
|
|
2252
|
-
return entry;
|
|
2253
|
-
}
|
|
2254
|
-
|
|
2255
|
-
changed = true;
|
|
2256
|
-
return {
|
|
2257
|
-
...entry,
|
|
2258
|
-
readAt: now
|
|
2259
|
-
};
|
|
2260
|
-
});
|
|
2261
|
-
|
|
2262
|
-
if (!changed) {
|
|
2263
|
-
return existing;
|
|
2264
|
-
}
|
|
2265
|
-
|
|
2266
|
-
await collection.updateOne(ticketOwnerFilter(input.id, actor), {
|
|
2267
|
-
$set: {
|
|
2268
|
-
"fields.entries": nextEntries,
|
|
2269
|
-
updatedAt: now,
|
|
2270
|
-
updatedBy: actor.actorId
|
|
2271
|
-
}
|
|
2272
|
-
});
|
|
2273
|
-
|
|
2274
|
-
return getDxcompleteTicket(db, input.id, actor);
|
|
2275
|
-
}
|
|
2276
|
-
|
|
2277
|
-
export async function archiveDxcompleteTicket(
|
|
2278
|
-
db: Db,
|
|
2279
|
-
input: ArchiveDxcompleteTicketInput,
|
|
2280
|
-
actor: ActorContext
|
|
2281
|
-
): Promise<DxcRecord> {
|
|
2282
|
-
const collection = db.collection<DxcRecord>(DXCOMPLETE_TICKET_COLLECTION_NAME);
|
|
2283
|
-
const existing = await collection.findOne(ticketOwnerFilter(input.id, actor));
|
|
2284
|
-
|
|
2285
|
-
if (!existing) {
|
|
2286
|
-
throw new Error(`DX Complete Ticket not found: ${input.id}`);
|
|
2287
|
-
}
|
|
2288
|
-
|
|
2289
|
-
if (existing.archivedAt) {
|
|
2290
|
-
return existing;
|
|
2291
|
-
}
|
|
2292
|
-
|
|
2293
|
-
const now = new Date().toISOString();
|
|
2294
|
-
await collection.updateOne(ticketOwnerFilter(input.id, actor), {
|
|
2295
|
-
$set: {
|
|
2296
|
-
archivedAt: now,
|
|
2297
|
-
updatedAt: now,
|
|
2298
|
-
updatedBy: actor.actorId
|
|
2299
|
-
}
|
|
2300
|
-
});
|
|
2301
|
-
|
|
2302
|
-
return getDxcompleteTicket(db, input.id, actor);
|
|
2303
|
-
}
|
|
2304
|
-
|
|
2305
|
-
export async function updateRecord(db: Db, input: UpdateRecordInput, actorId: string): Promise<DxcRecord> {
|
|
2306
|
-
const collection = db.collection<DxcRecord>(input.recordType);
|
|
2307
|
-
const recordFilter = scopedRecordFilter(input.recordType, input.id, input.workspaceId);
|
|
2308
|
-
const existing = await collection.findOne(recordFilter);
|
|
2309
|
-
|
|
2310
|
-
if (!existing) {
|
|
2311
|
-
throw new Error(`Record not found: ${input.recordType}/${input.id}`);
|
|
2312
|
-
}
|
|
2313
|
-
|
|
2314
|
-
const now = new Date().toISOString();
|
|
2315
|
-
const set: Record<string, unknown> = {
|
|
2316
|
-
updatedAt: now,
|
|
2317
|
-
updatedBy: actorId
|
|
2318
|
-
};
|
|
2319
|
-
const unset: Record<string, ""> = {};
|
|
2320
|
-
const versioned = recordTypeSupportsVersionHistory(input.recordType);
|
|
2321
|
-
|
|
2322
|
-
if (input.title !== undefined) {
|
|
2323
|
-
set.title = input.title;
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
if (input.summary !== undefined) {
|
|
2327
|
-
set.summary = input.summary;
|
|
2328
|
-
}
|
|
2329
|
-
|
|
2330
|
-
for (const [key, value] of Object.entries(input.fields ?? {})) {
|
|
2331
|
-
assertFieldName(key);
|
|
2332
|
-
assertNotReservedRelationshipField(input.recordType, key);
|
|
2333
|
-
if (input.allowManagedFields) {
|
|
2334
|
-
assertNotReviewNotesField(input.recordType, key);
|
|
2335
|
-
assertNotVersionHistoryField(input.recordType, key);
|
|
2336
|
-
} else {
|
|
2337
|
-
assertNotManagedField(input.recordType, key);
|
|
2338
|
-
assertNotVersionedTypedField(input.recordType, key);
|
|
2339
|
-
}
|
|
2340
|
-
set[`fields.${key}`] = value;
|
|
2341
|
-
}
|
|
2342
|
-
|
|
2343
|
-
for (const key of input.unsetFields ?? []) {
|
|
2344
|
-
assertFieldName(key);
|
|
2345
|
-
if (input.allowManagedFields) {
|
|
2346
|
-
assertNotReviewNotesField(input.recordType, key);
|
|
2347
|
-
assertNotVersionHistoryField(input.recordType, key);
|
|
2348
|
-
} else {
|
|
2349
|
-
assertNotManagedField(input.recordType, key);
|
|
2350
|
-
assertNotVersionedTypedField(input.recordType, key);
|
|
2351
|
-
}
|
|
2352
|
-
unset[`fields.${key}`] = "";
|
|
2353
|
-
}
|
|
2354
|
-
|
|
2355
|
-
if (versioned) {
|
|
2356
|
-
const previousSnapshot = createRecordVersionSnapshot(existing);
|
|
2357
|
-
const nextSnapshot = createRecordVersionSnapshot(applyRecordUpdate(existing, input));
|
|
2358
|
-
const changedFields = listSnapshotChanges(previousSnapshot, nextSnapshot);
|
|
2359
|
-
const existingVersionHistory = normalizeVersionHistory(existing.fields.versionHistory);
|
|
2360
|
-
|
|
2361
|
-
if (changedFields.length === 0) {
|
|
2362
|
-
return withDerivedRecordFields(db, existing);
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
set["fields.versionHistory"] = [
|
|
2366
|
-
...existingVersionHistory,
|
|
2367
|
-
createVersionHistoryEntry({
|
|
2368
|
-
existingVersionHistory,
|
|
2369
|
-
previousSnapshot,
|
|
2370
|
-
nextSnapshot,
|
|
2371
|
-
changedFields,
|
|
2372
|
-
actorId,
|
|
2373
|
-
createdAt: now,
|
|
2374
|
-
revisionNote: input.revisionNote
|
|
2375
|
-
})
|
|
2376
|
-
];
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
const update = Object.keys(unset).length > 0 ? { $set: set, $unset: unset } : { $set: set };
|
|
2380
|
-
await collection.updateOne(recordFilter, update);
|
|
2381
|
-
|
|
2382
|
-
const updated = await collection.findOne(recordFilter);
|
|
2383
|
-
if (!updated) {
|
|
2384
|
-
throw new Error(`Updated record not found: ${input.recordType}/${input.id}`);
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
return withDerivedRecordFields(db, updated);
|
|
2388
|
-
}
|
|
2389
|
-
|
|
2390
|
-
function applyRecordUpdate(record: DxcRecord, input: UpdateRecordInput): DxcRecord {
|
|
2391
|
-
const fields = { ...record.fields };
|
|
2392
|
-
|
|
2393
|
-
for (const [key, value] of Object.entries(input.fields ?? {})) {
|
|
2394
|
-
fields[key] = value;
|
|
2395
|
-
}
|
|
2396
|
-
|
|
2397
|
-
for (const key of input.unsetFields ?? []) {
|
|
2398
|
-
delete fields[key];
|
|
2399
|
-
}
|
|
2400
|
-
|
|
2401
|
-
return {
|
|
2402
|
-
...record,
|
|
2403
|
-
...(input.title !== undefined ? { title: input.title } : {}),
|
|
2404
|
-
...(input.summary !== undefined ? { summary: input.summary } : {}),
|
|
2405
|
-
fields
|
|
2406
|
-
};
|
|
2407
|
-
}
|
|
2408
|
-
|
|
2409
|
-
function createRecordVersionSnapshot(record: DxcRecord): RecordVersionSnapshot {
|
|
2410
|
-
return {
|
|
2411
|
-
...(record.title !== undefined ? { title: record.title } : {}),
|
|
2412
|
-
...(record.summary !== undefined ? { summary: record.summary } : {}),
|
|
2413
|
-
fields: Object.fromEntries(
|
|
2414
|
-
Object.entries(record.fields)
|
|
2415
|
-
.filter(([key]) => key !== VERSION_HISTORY_FIELD && key !== "reviewNotes")
|
|
2416
|
-
.map(([key, value]) => [key, cloneSnapshotValue(value)])
|
|
2417
|
-
)
|
|
2418
|
-
};
|
|
2419
|
-
}
|
|
2420
|
-
|
|
2421
|
-
function listSnapshotChanges(
|
|
2422
|
-
previousSnapshot: RecordVersionSnapshot,
|
|
2423
|
-
nextSnapshot: RecordVersionSnapshot
|
|
2424
|
-
): string[] {
|
|
2425
|
-
const changedFields: string[] = [];
|
|
2426
|
-
|
|
2427
|
-
if (!valuesEqual(previousSnapshot.title, nextSnapshot.title)) {
|
|
2428
|
-
changedFields.push("title");
|
|
2429
|
-
}
|
|
2430
|
-
|
|
2431
|
-
if (!valuesEqual(previousSnapshot.summary, nextSnapshot.summary)) {
|
|
2432
|
-
changedFields.push("summary");
|
|
2433
|
-
}
|
|
2434
|
-
|
|
2435
|
-
const fieldNames = new Set([
|
|
2436
|
-
...Object.keys(previousSnapshot.fields),
|
|
2437
|
-
...Object.keys(nextSnapshot.fields)
|
|
2438
|
-
]);
|
|
2439
|
-
|
|
2440
|
-
for (const fieldName of [...fieldNames].sort()) {
|
|
2441
|
-
if (!valuesEqual(previousSnapshot.fields[fieldName], nextSnapshot.fields[fieldName])) {
|
|
2442
|
-
changedFields.push(`fields.${fieldName}`);
|
|
2443
|
-
}
|
|
2444
|
-
}
|
|
2445
|
-
|
|
2446
|
-
return changedFields;
|
|
2447
|
-
}
|
|
2448
|
-
|
|
2449
|
-
function normalizeVersionHistory(value: unknown): RecordVersionHistoryEntry[] {
|
|
2450
|
-
if (!Array.isArray(value)) {
|
|
2451
|
-
return [];
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
return value.filter(isVersionHistoryEntry);
|
|
2455
|
-
}
|
|
2456
|
-
|
|
2457
|
-
function createVersionHistoryEntry(input: {
|
|
2458
|
-
existingVersionHistory: RecordVersionHistoryEntry[];
|
|
2459
|
-
previousSnapshot: RecordVersionSnapshot;
|
|
2460
|
-
nextSnapshot: RecordVersionSnapshot;
|
|
2461
|
-
changedFields: string[];
|
|
2462
|
-
actorId: string;
|
|
2463
|
-
createdAt: string;
|
|
2464
|
-
revisionNote?: string;
|
|
2465
|
-
}): RecordVersionHistoryEntry {
|
|
2466
|
-
const fromVersion = input.existingVersionHistory.reduce(
|
|
2467
|
-
(currentVersion, entry) => Math.max(currentVersion, entry.toVersion),
|
|
2468
|
-
1
|
|
2469
|
-
);
|
|
2470
|
-
|
|
2471
|
-
return {
|
|
2472
|
-
id: randomUUID(),
|
|
2473
|
-
fromVersion,
|
|
2474
|
-
toVersion: fromVersion + 1,
|
|
2475
|
-
createdAt: input.createdAt,
|
|
2476
|
-
createdBy: input.actorId,
|
|
2477
|
-
changedFields: input.changedFields,
|
|
2478
|
-
previousSnapshot: input.previousSnapshot,
|
|
2479
|
-
nextSnapshot: input.nextSnapshot,
|
|
2480
|
-
...(input.revisionNote ? { revisionNote: input.revisionNote } : {})
|
|
2481
|
-
};
|
|
2482
|
-
}
|
|
2483
|
-
|
|
2484
|
-
function isVersionHistoryEntry(value: unknown): value is RecordVersionHistoryEntry {
|
|
2485
|
-
if (!value || typeof value !== "object") {
|
|
2486
|
-
return false;
|
|
2487
|
-
}
|
|
2488
|
-
|
|
2489
|
-
const candidate = value as Partial<RecordVersionHistoryEntry>;
|
|
2490
|
-
return (
|
|
2491
|
-
typeof candidate.id === "string" &&
|
|
2492
|
-
typeof candidate.fromVersion === "number" &&
|
|
2493
|
-
typeof candidate.toVersion === "number" &&
|
|
2494
|
-
typeof candidate.createdAt === "string" &&
|
|
2495
|
-
typeof candidate.createdBy === "string" &&
|
|
2496
|
-
Array.isArray(candidate.changedFields) &&
|
|
2497
|
-
typeof candidate.previousSnapshot === "object" &&
|
|
2498
|
-
typeof candidate.nextSnapshot === "object"
|
|
2499
|
-
);
|
|
2500
|
-
}
|
|
2501
|
-
|
|
2502
|
-
function cloneSnapshotValue(value: unknown): unknown {
|
|
2503
|
-
if (Array.isArray(value)) {
|
|
2504
|
-
return value.map((entry) => cloneSnapshotValue(entry));
|
|
2505
|
-
}
|
|
2506
|
-
|
|
2507
|
-
if (value && typeof value === "object") {
|
|
2508
|
-
return Object.fromEntries(
|
|
2509
|
-
Object.entries(value as Record<string, unknown>)
|
|
2510
|
-
.sort(([left], [right]) => left.localeCompare(right))
|
|
2511
|
-
.map(([key, entry]) => [key, cloneSnapshotValue(entry)])
|
|
2512
|
-
);
|
|
2513
|
-
}
|
|
2514
|
-
|
|
2515
|
-
return value;
|
|
2516
|
-
}
|
|
2517
|
-
|
|
2518
|
-
function valuesEqual(left: unknown, right: unknown): boolean {
|
|
2519
|
-
return stableStringify(left) === stableStringify(right);
|
|
2520
|
-
}
|
|
2521
|
-
|
|
2522
|
-
function stableStringify(value: unknown): string {
|
|
2523
|
-
return JSON.stringify(cloneSnapshotValue(value));
|
|
2524
|
-
}
|
|
2525
|
-
|
|
2526
|
-
export async function archiveRecord(db: Db, input: ArchiveRecordInput, actorId: string): Promise<DxcRecord> {
|
|
2527
|
-
const collection = db.collection<DxcRecord>(input.recordType);
|
|
2528
|
-
const recordFilter = scopedRecordFilter(input.recordType, input.id, input.workspaceId);
|
|
2529
|
-
const existing = await collection.findOne(recordFilter);
|
|
2530
|
-
|
|
2531
|
-
if (!existing) {
|
|
2532
|
-
throw new Error(`Record not found: ${input.recordType}/${input.id}`);
|
|
2533
|
-
}
|
|
2534
|
-
|
|
2535
|
-
if ((input.supersededByType && !input.supersededById) || (!input.supersededByType && input.supersededById)) {
|
|
2536
|
-
throw new Error("supersededByType and supersededById must be provided together.");
|
|
2537
|
-
}
|
|
2538
|
-
|
|
2539
|
-
const supersedingRecord =
|
|
2540
|
-
input.supersededByType && input.supersededById
|
|
2541
|
-
? await getRecord(db, input.supersededByType, input.supersededById, {
|
|
2542
|
-
workspaceId: existing.workspaceId
|
|
2543
|
-
})
|
|
2544
|
-
: null;
|
|
2545
|
-
|
|
2546
|
-
if (input.supersededByType && input.supersededById && !supersedingRecord) {
|
|
2547
|
-
throw new Error(`Superseding record not found: ${input.supersededByType}/${input.supersededById}`);
|
|
2548
|
-
}
|
|
2549
|
-
|
|
2550
|
-
const now = new Date().toISOString();
|
|
2551
|
-
const links = [...existing.links];
|
|
2552
|
-
|
|
2553
|
-
if (input.supersededByType && input.supersededById) {
|
|
2554
|
-
links.push({
|
|
2555
|
-
toType: input.supersededByType,
|
|
2556
|
-
toId: supersedingRecord?._id ?? input.supersededById,
|
|
2557
|
-
relationship: "superseded_by",
|
|
2558
|
-
createdAt: now,
|
|
2559
|
-
createdBy: actorId
|
|
2560
|
-
});
|
|
2561
|
-
}
|
|
2562
|
-
|
|
2563
|
-
await collection.updateOne(
|
|
2564
|
-
recordFilter,
|
|
2565
|
-
{
|
|
2566
|
-
$set: {
|
|
2567
|
-
links,
|
|
2568
|
-
archivedAt: now,
|
|
2569
|
-
archivedBy: actorId,
|
|
2570
|
-
archiveReason: input.reason ?? "Archived",
|
|
2571
|
-
updatedAt: now,
|
|
2572
|
-
updatedBy: actorId
|
|
2573
|
-
}
|
|
2574
|
-
}
|
|
2575
|
-
);
|
|
2576
|
-
|
|
2577
|
-
const updated = await collection.findOne(recordFilter);
|
|
2578
|
-
if (!updated) {
|
|
2579
|
-
throw new Error(`Archived record not found: ${input.recordType}/${input.id}`);
|
|
2580
|
-
}
|
|
2581
|
-
|
|
2582
|
-
return withDerivedRecordFields(db, updated);
|
|
2583
|
-
}
|
|
2584
|
-
|
|
2585
|
-
export async function linkRecords(
|
|
2586
|
-
db: Db,
|
|
2587
|
-
input: {
|
|
2588
|
-
fromType: CollectionName;
|
|
2589
|
-
fromId: string;
|
|
2590
|
-
toType: CollectionName;
|
|
2591
|
-
toId: string;
|
|
2592
|
-
workspaceId?: string;
|
|
2593
|
-
relationship?: string;
|
|
2594
|
-
},
|
|
2595
|
-
actorId: string
|
|
2596
|
-
): Promise<DxcRecord> {
|
|
2597
|
-
const sourceCollection = db.collection<DxcRecord>(input.fromType);
|
|
2598
|
-
const source = await getRecord(db, input.fromType, input.fromId, {
|
|
2599
|
-
workspaceId: input.workspaceId
|
|
2600
|
-
});
|
|
2601
|
-
const target = await getRecord(db, input.toType, input.toId, {
|
|
2602
|
-
workspaceId: input.workspaceId
|
|
2603
|
-
});
|
|
2604
|
-
|
|
2605
|
-
if (!source) {
|
|
2606
|
-
throw new Error(`Source record not found: ${input.fromType}/${input.fromId}`);
|
|
2607
|
-
}
|
|
2608
|
-
|
|
2609
|
-
if (!target) {
|
|
2610
|
-
throw new Error(`Target record not found: ${input.toType}/${input.toId}`);
|
|
2611
|
-
}
|
|
2612
|
-
|
|
2613
|
-
assertLinkWorkspaceBoundary(source, target);
|
|
2614
|
-
|
|
2615
|
-
const now = new Date().toISOString();
|
|
2616
|
-
const link: RecordLink = {
|
|
2617
|
-
toType: input.toType,
|
|
2618
|
-
toId: target._id,
|
|
2619
|
-
relationship: input.relationship ?? "related_to",
|
|
2620
|
-
createdAt: now,
|
|
2621
|
-
createdBy: actorId
|
|
2622
|
-
};
|
|
2623
|
-
|
|
2624
|
-
await sourceCollection.updateOne(
|
|
2625
|
-
scopedRecordFilter(input.fromType, source._id, input.workspaceId),
|
|
2626
|
-
{
|
|
2627
|
-
$push: { links: link },
|
|
2628
|
-
$set: {
|
|
2629
|
-
updatedAt: now,
|
|
2630
|
-
updatedBy: actorId
|
|
2631
|
-
}
|
|
2632
|
-
}
|
|
2633
|
-
);
|
|
2634
|
-
|
|
2635
|
-
const updated = await sourceCollection.findOne(scopedRecordFilter(input.fromType, source._id, input.workspaceId));
|
|
2636
|
-
if (!updated) {
|
|
2637
|
-
throw new Error(`Updated source record not found: ${input.fromType}/${input.fromId}`);
|
|
2638
|
-
}
|
|
2639
|
-
|
|
2640
|
-
return withDerivedRecordFields(db, updated);
|
|
2641
|
-
}
|
|
2642
|
-
|
|
2643
|
-
export async function unlinkRecords(
|
|
2644
|
-
db: Db,
|
|
2645
|
-
input: UnlinkRecordsInput,
|
|
2646
|
-
actorId: string
|
|
2647
|
-
): Promise<DxcRecord> {
|
|
2648
|
-
const sourceCollection = db.collection<DxcRecord>(input.fromType);
|
|
2649
|
-
const source = await getRecord(db, input.fromType, input.fromId, {
|
|
2650
|
-
workspaceId: input.workspaceId
|
|
2651
|
-
});
|
|
2652
|
-
const target = await getRecord(db, input.toType, input.toId, {
|
|
2653
|
-
workspaceId: input.workspaceId
|
|
2654
|
-
});
|
|
2655
|
-
|
|
2656
|
-
if (!source) {
|
|
2657
|
-
throw new Error(`Source record not found: ${input.fromType}/${input.fromId}`);
|
|
2658
|
-
}
|
|
2659
|
-
|
|
2660
|
-
if (!target) {
|
|
2661
|
-
throw new Error(`Target record not found: ${input.toType}/${input.toId}`);
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
assertLinkWorkspaceBoundary(source, target);
|
|
2665
|
-
|
|
2666
|
-
const relationship = input.relationship ?? "related_to";
|
|
2667
|
-
const remainingLinks = source.links.filter(
|
|
2668
|
-
(link) =>
|
|
2669
|
-
!(
|
|
2670
|
-
link.toType === input.toType &&
|
|
2671
|
-
link.toId === target._id &&
|
|
2672
|
-
link.relationship === relationship
|
|
2673
|
-
)
|
|
2674
|
-
);
|
|
2675
|
-
|
|
2676
|
-
if (remainingLinks.length === source.links.length) {
|
|
2677
|
-
return source;
|
|
2678
|
-
}
|
|
2679
|
-
|
|
2680
|
-
const now = new Date().toISOString();
|
|
2681
|
-
await sourceCollection.updateOne(
|
|
2682
|
-
scopedRecordFilter(input.fromType, source._id, input.workspaceId),
|
|
2683
|
-
{
|
|
2684
|
-
$set: {
|
|
2685
|
-
links: remainingLinks,
|
|
2686
|
-
updatedAt: now,
|
|
2687
|
-
updatedBy: actorId
|
|
2688
|
-
}
|
|
2689
|
-
}
|
|
2690
|
-
);
|
|
2691
|
-
|
|
2692
|
-
const updated = await sourceCollection.findOne(scopedRecordFilter(input.fromType, source._id, input.workspaceId));
|
|
2693
|
-
if (!updated) {
|
|
2694
|
-
throw new Error(`Updated source record not found: ${input.fromType}/${input.fromId}`);
|
|
2695
|
-
}
|
|
2696
|
-
|
|
2697
|
-
return withDerivedRecordFields(db, updated);
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
function assertFieldName(key: string): void {
|
|
2701
|
-
if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) {
|
|
2702
|
-
throw new Error(`Invalid field name "${key}". Field updates only support top-level field names.`);
|
|
2703
|
-
}
|
|
2704
|
-
}
|
|
2705
|
-
|
|
2706
|
-
function assertNoReservedRelationshipFields(
|
|
2707
|
-
recordType: CollectionName,
|
|
2708
|
-
fields: Record<string, unknown> | undefined
|
|
2709
|
-
): void {
|
|
2710
|
-
for (const key of Object.keys(fields ?? {})) {
|
|
2711
|
-
assertNotReservedRelationshipField(recordType, key);
|
|
2712
|
-
}
|
|
2713
|
-
}
|
|
2714
|
-
|
|
2715
|
-
function assertNoReviewNotesField(recordType: CollectionName, fields: Record<string, unknown> | undefined): void {
|
|
2716
|
-
for (const key of Object.keys(fields ?? {})) {
|
|
2717
|
-
assertNotReviewNotesField(recordType, key);
|
|
2718
|
-
}
|
|
2719
|
-
}
|
|
2720
|
-
|
|
2721
|
-
function assertNoManagedField(recordType: CollectionName, fields: Record<string, unknown> | undefined): void {
|
|
2722
|
-
for (const key of Object.keys(fields ?? {})) {
|
|
2723
|
-
assertNotManagedField(recordType, key);
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
|
|
2727
|
-
function assertNotReservedRelationshipField(recordType: CollectionName, key: string): void {
|
|
2728
|
-
if (key === "workspaceId" && recordTypeRequiresWorkspace(recordType)) {
|
|
2729
|
-
throw new Error(
|
|
2730
|
-
`fields.${key} is reserved for the workspace boundary on ${recordType}. Use the top-level ${key} argument instead.`
|
|
2731
|
-
);
|
|
2732
|
-
}
|
|
2733
|
-
|
|
2734
|
-
if (key === "initiativeId" && REMOVED_INITIATIVE_FIELD_RECORD_TYPES.includes(recordType)) {
|
|
2735
|
-
throw new Error(
|
|
2736
|
-
`fields.${key} belongs to the removed Initiative layer on ${recordType}. Records are scoped directly to workspace now.`
|
|
2737
|
-
);
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
if ((RESERVED_RELATIONSHIP_FIELDS[recordType] ?? []).includes(key)) {
|
|
2741
|
-
throw new Error(
|
|
2742
|
-
`fields.${key} is reserved for relationships on ${recordType}. Use the typed relationship argument or link_records instead.`
|
|
2743
|
-
);
|
|
2744
|
-
}
|
|
2745
|
-
}
|
|
2746
|
-
|
|
2747
|
-
function assertNotManagedField(recordType: CollectionName, key: string): void {
|
|
2748
|
-
assertNotReviewNotesField(recordType, key);
|
|
2749
|
-
assertNotVersionHistoryField(recordType, key);
|
|
2750
|
-
assertNotChangeManagedField(recordType, key);
|
|
2751
|
-
assertNotCommitmentManagedField(recordType, key);
|
|
2752
|
-
assertNotDeferralManagedField(recordType, key);
|
|
2753
|
-
assertNotEstimateManagedField(recordType, key);
|
|
2754
|
-
assertNotBenefitsManagedField(recordType, key);
|
|
2755
|
-
assertNotEnvironmentManagedField(recordType, key);
|
|
2756
|
-
assertNotComponentManagedField(recordType, key);
|
|
2757
|
-
assertNotMaintenanceScheduleManagedField(recordType, key);
|
|
2758
|
-
assertNotSupportRequestLedgerManagedField(recordType, key);
|
|
2759
|
-
assertNotValueRealizationManagedField(recordType, key);
|
|
2760
|
-
assertNotDecisionLedgerManagedField(recordType, key);
|
|
2761
|
-
assertNotTaskLedgerManagedField(recordType, key);
|
|
2762
|
-
assertNotRiskLedgerManagedField(recordType, key);
|
|
2763
|
-
assertNotIncidentLedgerManagedField(recordType, key);
|
|
2764
|
-
assertNotProblemLedgerManagedField(recordType, key);
|
|
2765
|
-
assertNotDecisionInputManagedField(recordType, key);
|
|
2766
|
-
assertNotJournalEntryManagedField(recordType, key);
|
|
2767
|
-
}
|
|
2768
|
-
|
|
2769
|
-
function assertNotVersionedTypedField(recordType: CollectionName, key: string): void {
|
|
2770
|
-
if (!(VERSIONED_TYPED_FIELDS[recordType] ?? []).includes(key)) {
|
|
2771
|
-
return;
|
|
2772
|
-
}
|
|
2773
|
-
|
|
2774
|
-
throw new Error(`fields.${key} is managed on ${recordType}. Use ${versionedUpdateToolName(recordType)} to change it.`);
|
|
2775
|
-
}
|
|
2776
|
-
|
|
2777
|
-
function assertNotReviewNotesField(recordType: CollectionName, key: string): void {
|
|
2778
|
-
if (key !== "reviewNotes" || !recordTypeSupportsReviewNotes(recordType)) {
|
|
2779
|
-
return;
|
|
2780
|
-
}
|
|
2781
|
-
|
|
2782
|
-
throw new Error(
|
|
2783
|
-
`fields.reviewNotes is append-only on ${recordType}. Use append_review_note instead of setting or unsetting it directly.`
|
|
2784
|
-
);
|
|
2785
|
-
}
|
|
2786
|
-
|
|
2787
|
-
function assertNotVersionHistoryField(recordType: CollectionName, key: string): void {
|
|
2788
|
-
if (key !== VERSION_HISTORY_FIELD || !recordTypeSupportsVersionHistory(recordType)) {
|
|
2789
|
-
return;
|
|
2790
|
-
}
|
|
2791
|
-
|
|
2792
|
-
throw new Error(
|
|
2793
|
-
`fields.versionHistory is append-only on ${recordType}. Current content changes record version history automatically.`
|
|
2794
|
-
);
|
|
2795
|
-
}
|
|
2796
|
-
|
|
2797
|
-
function assertNotChangeManagedField(recordType: CollectionName, key: string): void {
|
|
2798
|
-
if (recordType !== "changes" || !(CHANGE_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2799
|
-
return;
|
|
2800
|
-
}
|
|
2801
|
-
|
|
2802
|
-
throw new Error(
|
|
2803
|
-
`fields.${key} is managed on changes. Use create_change for the baseline and append_change_event for event history or plan revisions.`
|
|
2804
|
-
);
|
|
2805
|
-
}
|
|
2806
|
-
|
|
2807
|
-
function assertNotCommitmentManagedField(recordType: CollectionName, key: string): void {
|
|
2808
|
-
if (recordType !== "commitments" || !(COMMITMENT_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2809
|
-
return;
|
|
2810
|
-
}
|
|
2811
|
-
|
|
2812
|
-
throw new Error(
|
|
2813
|
-
`fields.${key} is managed on commitments. Use create_commitment instead of setting or unsetting it directly.`
|
|
2814
|
-
);
|
|
2815
|
-
}
|
|
2816
|
-
|
|
2817
|
-
function assertNotDeferralManagedField(recordType: CollectionName, key: string): void {
|
|
2818
|
-
if (recordType !== "deferrals" || !(DEFERRAL_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2819
|
-
return;
|
|
2820
|
-
}
|
|
2821
|
-
|
|
2822
|
-
throw new Error(
|
|
2823
|
-
`fields.${key} is managed on deferrals. Use create_deferral for the baseline and append_deferral_event for condition history or resolution.`
|
|
2824
|
-
);
|
|
2825
|
-
}
|
|
2826
|
-
|
|
2827
|
-
function assertNotEstimateManagedField(recordType: CollectionName, key: string): void {
|
|
2828
|
-
if (recordType !== "estimates" || !(ESTIMATE_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2829
|
-
return;
|
|
2830
|
-
}
|
|
2831
|
-
|
|
2832
|
-
throw new Error(
|
|
2833
|
-
`fields.${key} is managed on estimates. Use create_estimate or update_estimate instead of setting or unsetting it directly.`
|
|
2834
|
-
);
|
|
2835
|
-
}
|
|
2836
|
-
|
|
2837
|
-
function assertNotBenefitsManagedField(recordType: CollectionName, key: string): void {
|
|
2838
|
-
if (recordType !== "benefits" || !(BENEFITS_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2839
|
-
return;
|
|
2840
|
-
}
|
|
2841
|
-
|
|
2842
|
-
throw new Error(
|
|
2843
|
-
`fields.${key} is managed on benefits. Use create_benefits or update_benefits instead of setting or unsetting it directly.`
|
|
2844
|
-
);
|
|
2845
|
-
}
|
|
2846
|
-
|
|
2847
|
-
function assertNotEnvironmentManagedField(recordType: CollectionName, key: string): void {
|
|
2848
|
-
if (recordType !== "environments" || !(ENVIRONMENT_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2849
|
-
return;
|
|
2850
|
-
}
|
|
2851
|
-
|
|
2852
|
-
throw new Error(
|
|
2853
|
-
`fields.${key} is managed on environments. Use create_environment or update_environment instead of setting or unsetting it directly.`
|
|
2854
|
-
);
|
|
2855
|
-
}
|
|
2856
|
-
|
|
2857
|
-
function assertNotComponentManagedField(recordType: CollectionName, key: string): void {
|
|
2858
|
-
if (recordType !== "components" || !(COMPONENT_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2859
|
-
return;
|
|
2860
|
-
}
|
|
2861
|
-
|
|
2862
|
-
throw new Error(
|
|
2863
|
-
`fields.${key} is managed on components. Use create_component or update_component instead of setting or unsetting it directly.`
|
|
2864
|
-
);
|
|
2865
|
-
}
|
|
2866
|
-
|
|
2867
|
-
function assertNotMaintenanceScheduleManagedField(recordType: CollectionName, key: string): void {
|
|
2868
|
-
if (recordType !== "maintenance_schedules" || !(MAINTENANCE_SCHEDULE_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2869
|
-
return;
|
|
2870
|
-
}
|
|
2871
|
-
|
|
2872
|
-
throw new Error(
|
|
2873
|
-
`fields.${key} is managed on maintenance schedules. Use create_maintenance_schedule or update_maintenance_schedule instead of setting or unsetting it directly.`
|
|
2874
|
-
);
|
|
2875
|
-
}
|
|
2876
|
-
|
|
2877
|
-
function assertNotSupportRequestLedgerManagedField(recordType: CollectionName, key: string): void {
|
|
2878
|
-
if (recordType !== "support_requests" || !(SUPPORT_REQUEST_LEDGER_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2879
|
-
return;
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
|
-
throw new Error(
|
|
2883
|
-
`fields.${key} is managed on support requests. Use create_support_request or append_support_request_entry instead of setting or unsetting it directly.`
|
|
2884
|
-
);
|
|
2885
|
-
}
|
|
2886
|
-
|
|
2887
|
-
function assertNotValueRealizationManagedField(recordType: CollectionName, key: string): void {
|
|
2888
|
-
if (recordType !== "value_realizations" || !(VALUE_REALIZATION_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2889
|
-
return;
|
|
2890
|
-
}
|
|
2891
|
-
|
|
2892
|
-
throw new Error(
|
|
2893
|
-
`fields.${key} is managed on value realizations. Use create_value_realization or update_value_realization instead of setting or unsetting it directly.`
|
|
2894
|
-
);
|
|
2895
|
-
}
|
|
2896
|
-
|
|
2897
|
-
function assertNotDecisionLedgerManagedField(recordType: CollectionName, key: string): void {
|
|
2898
|
-
if (recordType !== "decisions" || !(DECISION_LEDGER_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2899
|
-
return;
|
|
2900
|
-
}
|
|
2901
|
-
|
|
2902
|
-
throw new Error(
|
|
2903
|
-
`fields.${key} is managed on decisions. Use create_decision or append_decision_entry instead of setting or unsetting it directly.`
|
|
2904
|
-
);
|
|
2905
|
-
}
|
|
2906
|
-
|
|
2907
|
-
function assertNotTaskLedgerManagedField(recordType: CollectionName, key: string): void {
|
|
2908
|
-
if (recordType !== "tasks" || !(TASK_LEDGER_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2909
|
-
return;
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
throw new Error(
|
|
2913
|
-
`fields.${key} is managed on tasks. Use create_task or append_task_entry instead of setting or unsetting it directly.`
|
|
2914
|
-
);
|
|
2915
|
-
}
|
|
2916
|
-
|
|
2917
|
-
function assertNotRiskLedgerManagedField(recordType: CollectionName, key: string): void {
|
|
2918
|
-
if (recordType !== "risks" || !(RISK_LEDGER_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2919
|
-
return;
|
|
2920
|
-
}
|
|
2921
|
-
|
|
2922
|
-
throw new Error(
|
|
2923
|
-
`fields.${key} is managed on risks. Use create_risk or append_risk_entry instead of setting or unsetting it directly.`
|
|
2924
|
-
);
|
|
2925
|
-
}
|
|
2926
|
-
|
|
2927
|
-
function assertNotIncidentLedgerManagedField(recordType: CollectionName, key: string): void {
|
|
2928
|
-
if (recordType !== "incidents" || !(INCIDENT_LEDGER_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2929
|
-
return;
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
throw new Error(
|
|
2933
|
-
`fields.${key} is managed on incidents. Use create_incident or append_incident_entry instead of setting or unsetting it directly.`
|
|
2934
|
-
);
|
|
2935
|
-
}
|
|
2936
|
-
|
|
2937
|
-
function assertNotProblemLedgerManagedField(recordType: CollectionName, key: string): void {
|
|
2938
|
-
if (recordType !== "problems" || !(PROBLEM_LEDGER_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2939
|
-
return;
|
|
2940
|
-
}
|
|
2941
|
-
|
|
2942
|
-
throw new Error(
|
|
2943
|
-
`fields.${key} is managed on problems. Use create_problem or append_problem_entry instead of setting or unsetting it directly.`
|
|
2944
|
-
);
|
|
2945
|
-
}
|
|
2946
|
-
|
|
2947
|
-
function assertNotDecisionInputManagedField(recordType: CollectionName, key: string): void {
|
|
2948
|
-
if (recordType !== "decisions" || !(DECISION_INPUT_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2949
|
-
return;
|
|
2950
|
-
}
|
|
2951
|
-
|
|
2952
|
-
throw new Error(
|
|
2953
|
-
`fields.${key} is managed on decisions. Use link_decision_input to record decision inputs.`
|
|
2954
|
-
);
|
|
2955
|
-
}
|
|
2956
|
-
|
|
2957
|
-
function assertNotJournalEntryManagedField(recordType: CollectionName, key: string): void {
|
|
2958
|
-
if (recordType !== "journal_entries" || !(JOURNAL_ENTRY_MANAGED_FIELDS as readonly string[]).includes(key)) {
|
|
2959
|
-
return;
|
|
2960
|
-
}
|
|
2961
|
-
|
|
2962
|
-
throw new Error(
|
|
2963
|
-
`fields.${key} is managed on journal entries. Use append_journal_note or append_journal_summary instead of setting or unsetting it directly.`
|
|
2964
|
-
);
|
|
2965
|
-
}
|
|
2966
|
-
|
|
2967
|
-
function linkMatches(link: RecordLink, relationship: string | undefined): boolean {
|
|
2968
|
-
return !relationship || link.relationship === relationship;
|
|
2969
|
-
}
|
|
2970
|
-
|
|
2971
|
-
function recordTypeSupportsReviewNotes(recordType: CollectionName): recordType is ReviewableRecordType {
|
|
2972
|
-
return (REVIEW_NOTE_RECORD_TYPES as readonly string[]).includes(recordType);
|
|
2973
|
-
}
|
|
2974
|
-
|
|
2975
|
-
function recordTypeSupportsVersionHistory(recordType: CollectionName): boolean {
|
|
2976
|
-
return (VERSIONED_RECORD_TYPES as readonly string[]).includes(recordType);
|
|
2977
|
-
}
|
|
2978
|
-
|
|
2979
|
-
function versionedUpdateToolName(recordType: CollectionName): string {
|
|
2980
|
-
if (recordType === "environments") {
|
|
2981
|
-
return "update_environment";
|
|
2982
|
-
}
|
|
2983
|
-
|
|
2984
|
-
if (recordType === "components") {
|
|
2985
|
-
return "update_component";
|
|
2986
|
-
}
|
|
2987
|
-
|
|
2988
|
-
if (recordType === "maintenance_schedules") {
|
|
2989
|
-
return "update_maintenance_schedule";
|
|
2990
|
-
}
|
|
2991
|
-
|
|
2992
|
-
if (recordType === "value_realizations") {
|
|
2993
|
-
return "update_value_realization";
|
|
2994
|
-
}
|
|
2995
|
-
|
|
2996
|
-
if (recordType === "estimates") {
|
|
2997
|
-
return "update_estimate";
|
|
2998
|
-
}
|
|
2999
|
-
|
|
3000
|
-
if (recordType === "benefits") {
|
|
3001
|
-
return "update_benefits";
|
|
3002
|
-
}
|
|
3003
|
-
|
|
3004
|
-
if (recordType === "statements") {
|
|
3005
|
-
return "update_statement";
|
|
3006
|
-
}
|
|
3007
|
-
|
|
3008
|
-
return recordType === "expectations" ? "update_expectation" : "update_requirement";
|
|
3009
|
-
}
|
|
3010
|
-
|
|
3011
|
-
function isCurrentCollection(recordType: RuntimeCollectionName): recordType is CollectionName {
|
|
3012
|
-
return (COLLECTION_NAMES as readonly string[]).includes(recordType);
|
|
3013
|
-
}
|
|
3014
|
-
|
|
3015
|
-
function recordTypeRequiresWorkspace(recordType: RuntimeCollectionName): boolean {
|
|
3016
|
-
return recordType !== "workspaces" && recordType !== DXCOMPLETE_TICKET_COLLECTION_NAME;
|
|
3017
|
-
}
|
|
3018
|
-
|
|
3019
|
-
function readRequiredWorkspaceId(workspaceId: string | undefined, recordType: RuntimeCollectionName): string {
|
|
3020
|
-
const trimmed = workspaceId?.trim();
|
|
3021
|
-
|
|
3022
|
-
if (!trimmed) {
|
|
3023
|
-
throw new Error(`${recordType} records require workspaceId.`);
|
|
3024
|
-
}
|
|
3025
|
-
|
|
3026
|
-
return trimmed;
|
|
3027
|
-
}
|
|
3028
|
-
|
|
3029
|
-
function workspaceIdForCreate(recordType: CollectionName, workspaceId: string | undefined): string | undefined {
|
|
3030
|
-
if (!recordTypeRequiresWorkspace(recordType)) {
|
|
3031
|
-
if (workspaceId?.trim()) {
|
|
3032
|
-
throw new Error("Workspace records do not accept workspaceId.");
|
|
3033
|
-
}
|
|
3034
|
-
return undefined;
|
|
3035
|
-
}
|
|
3036
|
-
|
|
3037
|
-
return readRequiredWorkspaceId(workspaceId, recordType);
|
|
3038
|
-
}
|
|
3039
|
-
|
|
3040
|
-
async function allocateReadableId(
|
|
3041
|
-
db: Db,
|
|
3042
|
-
recordType: ReadableIdCollectionName,
|
|
3043
|
-
workspaceId: string | undefined,
|
|
3044
|
-
actorId: string,
|
|
3045
|
-
now: string,
|
|
3046
|
-
session: ClientSession
|
|
3047
|
-
): Promise<string> {
|
|
3048
|
-
const scopedWorkspaceId = readRequiredWorkspaceId(workspaceId, recordType);
|
|
3049
|
-
const sequenceId = `${scopedWorkspaceId}:${recordType}`;
|
|
3050
|
-
const sequence = await db.collection<ReadableIdSequenceRecord>(READABLE_ID_SEQUENCES_COLLECTION).findOneAndUpdate(
|
|
3051
|
-
{ _id: sequenceId },
|
|
3052
|
-
{
|
|
3053
|
-
$inc: { nextNumber: 1 },
|
|
3054
|
-
$set: {
|
|
3055
|
-
updatedAt: now,
|
|
3056
|
-
updatedBy: actorId
|
|
3057
|
-
},
|
|
3058
|
-
$setOnInsert: {
|
|
3059
|
-
_id: sequenceId,
|
|
3060
|
-
workspaceId: scopedWorkspaceId,
|
|
3061
|
-
recordType,
|
|
3062
|
-
createdAt: now,
|
|
3063
|
-
createdBy: actorId
|
|
3064
|
-
}
|
|
3065
|
-
},
|
|
3066
|
-
{
|
|
3067
|
-
upsert: true,
|
|
3068
|
-
returnDocument: "after",
|
|
3069
|
-
session
|
|
3070
|
-
}
|
|
3071
|
-
);
|
|
3072
|
-
|
|
3073
|
-
if (!sequence || !Number.isInteger(sequence.nextNumber) || sequence.nextNumber < 1) {
|
|
3074
|
-
throw new Error(`Readable ID sequence was not allocated for ${recordType}.`);
|
|
3075
|
-
}
|
|
3076
|
-
|
|
3077
|
-
return `${READABLE_ID_TYPE_CODES[recordType]}-${String(sequence.nextNumber).padStart(4, "0")}`;
|
|
3078
|
-
}
|
|
3079
|
-
|
|
3080
|
-
type ReadableIdSequenceRecord = {
|
|
3081
|
-
_id: string;
|
|
3082
|
-
workspaceId: string;
|
|
3083
|
-
recordType: ReadableIdCollectionName;
|
|
3084
|
-
nextNumber: number;
|
|
3085
|
-
createdAt: string;
|
|
3086
|
-
createdBy: string;
|
|
3087
|
-
updatedAt: string;
|
|
3088
|
-
updatedBy: string;
|
|
3089
|
-
};
|
|
3090
|
-
|
|
3091
|
-
function recordTypeSupportsReadableId(recordType: RuntimeCollectionName): recordType is ReadableIdCollectionName {
|
|
3092
|
-
return Object.hasOwn(READABLE_ID_TYPE_CODES, recordType);
|
|
3093
|
-
}
|
|
3094
|
-
|
|
3095
|
-
function normalizeReadableId(value: string): string {
|
|
3096
|
-
return value.trim().toUpperCase();
|
|
3097
|
-
}
|
|
3098
|
-
|
|
3099
|
-
function recordIdentityFilter(recordType: RuntimeCollectionName, id: string): Record<string, unknown> {
|
|
3100
|
-
if (!recordTypeSupportsReadableId(recordType)) {
|
|
3101
|
-
return { _id: id };
|
|
3102
|
-
}
|
|
3103
|
-
|
|
3104
|
-
return {
|
|
3105
|
-
$or: [
|
|
3106
|
-
{ _id: id },
|
|
3107
|
-
{ readableId: normalizeReadableId(id) }
|
|
3108
|
-
]
|
|
3109
|
-
};
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
function scopedRecordFilter(
|
|
3113
|
-
recordType: CollectionName,
|
|
3114
|
-
id: string,
|
|
3115
|
-
workspaceId: string | undefined
|
|
3116
|
-
): Record<string, unknown> {
|
|
3117
|
-
const filter = recordIdentityFilter(recordType, id);
|
|
3118
|
-
|
|
3119
|
-
if (recordTypeRequiresWorkspace(recordType)) {
|
|
3120
|
-
filter.workspaceId = readRequiredWorkspaceId(workspaceId, recordType);
|
|
3121
|
-
}
|
|
3122
|
-
|
|
3123
|
-
return filter;
|
|
3124
|
-
}
|
|
3125
|
-
|
|
3126
|
-
async function assertWorkspaceExists(db: Db, workspaceId: string): Promise<DxcRecord> {
|
|
3127
|
-
const workspace = await db.collection<DxcRecord>("workspaces").findOne({
|
|
3128
|
-
_id: workspaceId,
|
|
3129
|
-
archivedAt: { $exists: false }
|
|
3130
|
-
});
|
|
3131
|
-
|
|
3132
|
-
if (!workspace) {
|
|
3133
|
-
throw new Error(`Workspace not found: workspaces/${workspaceId}`);
|
|
3134
|
-
}
|
|
3135
|
-
|
|
3136
|
-
return workspace;
|
|
3137
|
-
}
|
|
3138
|
-
|
|
3139
|
-
function recordBelongsToWorkspace(record: DxcRecord, workspaceId: string | undefined): boolean {
|
|
3140
|
-
return !workspaceId || record.recordType === "workspaces" || record.workspaceId === workspaceId;
|
|
3141
|
-
}
|
|
3142
|
-
|
|
3143
|
-
function assertLinkWorkspaceBoundary(source: DxcRecord, target: DxcRecord): void {
|
|
3144
|
-
if (!source.workspaceId || !target.workspaceId) {
|
|
3145
|
-
return;
|
|
3146
|
-
}
|
|
3147
|
-
|
|
3148
|
-
if (source.workspaceId !== target.workspaceId) {
|
|
3149
|
-
throw new Error(
|
|
3150
|
-
`Cannot link records across workspaces: ${source.recordType}/${source._id} is in ${source.workspaceId}, while ${target.recordType}/${target._id} is in ${target.workspaceId}.`
|
|
3151
|
-
);
|
|
3152
|
-
}
|
|
3153
|
-
}
|
|
3154
|
-
|
|
3155
|
-
function ticketOwnerFilter(id: string, actor: ActorContext): Record<string, unknown> {
|
|
3156
|
-
return {
|
|
3157
|
-
_id: id,
|
|
3158
|
-
"fields.ownerActorId": actor.actorId
|
|
3159
|
-
};
|
|
3160
|
-
}
|
|
3161
|
-
|
|
3162
|
-
function normalizeTicketEntries(value: unknown): DxcompleteTicketEntry[] {
|
|
3163
|
-
if (!Array.isArray(value)) {
|
|
3164
|
-
return [];
|
|
3165
|
-
}
|
|
3166
|
-
|
|
3167
|
-
return value.flatMap((entry, index) => normalizeTicketEntry(entry, index));
|
|
3168
|
-
}
|
|
3169
|
-
|
|
3170
|
-
function normalizeTicketEntry(entry: unknown, index: number): DxcompleteTicketEntry[] {
|
|
3171
|
-
if (!entry || typeof entry !== "object") {
|
|
3172
|
-
return [];
|
|
3173
|
-
}
|
|
3174
|
-
|
|
3175
|
-
const candidate = entry as Record<string, unknown>;
|
|
3176
|
-
const body = typeof candidate.body === "string" ? candidate.body : "";
|
|
3177
|
-
|
|
3178
|
-
if (!body) {
|
|
3179
|
-
return [];
|
|
3180
|
-
}
|
|
3181
|
-
|
|
3182
|
-
const createdAt = typeof candidate.createdAt === "string" ? candidate.createdAt : new Date(0).toISOString();
|
|
3183
|
-
const createdBy = typeof candidate.createdBy === "string" ? candidate.createdBy : RUNTIME_ACTOR_ID;
|
|
3184
|
-
const direction =
|
|
3185
|
-
candidate.direction === "dxcomplete_reply" || candidate.direction === "submitter_entry"
|
|
3186
|
-
? candidate.direction
|
|
3187
|
-
: "submitter_entry";
|
|
3188
|
-
const normalized: DxcompleteTicketEntry = {
|
|
3189
|
-
id: typeof candidate.id === "string" && candidate.id ? candidate.id : `legacy-entry-${index + 1}`,
|
|
3190
|
-
body,
|
|
3191
|
-
createdAt,
|
|
3192
|
-
createdBy,
|
|
3193
|
-
direction
|
|
3194
|
-
};
|
|
3195
|
-
|
|
3196
|
-
if (typeof candidate.addressedToActorId === "string" && candidate.addressedToActorId) {
|
|
3197
|
-
normalized.addressedToActorId = candidate.addressedToActorId;
|
|
3198
|
-
}
|
|
3199
|
-
|
|
3200
|
-
if (typeof candidate.readAt === "string" && candidate.readAt) {
|
|
3201
|
-
normalized.readAt = candidate.readAt;
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
return [normalized];
|
|
3205
|
-
}
|