filegrc 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +42 -0
- package/bin/filegrc.js +8 -0
- package/model/index.js +13 -0
- package/model/v1.json +1378 -0
- package/package.json +29 -0
- package/src/agent.js +247 -0
- package/src/audit-preparation.js +906 -0
- package/src/build.js +27 -0
- package/src/cli.js +623 -0
- package/src/evidence-packet.js +1642 -0
- package/src/favicon.js +109 -0
- package/src/files.js +533 -0
- package/src/git.js +289 -0
- package/src/id.js +19 -0
- package/src/index.js +47 -0
- package/src/markdown.js +123 -0
- package/src/model-docs.js +119 -0
- package/src/mutation.js +15 -0
- package/src/obligations.js +595 -0
- package/src/paths.js +137 -0
- package/src/recurrence.js +89 -0
- package/src/resource-markdown.js +63 -0
- package/src/search.js +27 -0
- package/src/server.js +300 -0
- package/src/state.js +84 -0
- package/src/time.js +62 -0
- package/src/validate.js +496 -0
- package/src/web.js +2328 -0
- package/src/workspace.js +90 -0
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
import { createResourceId } from "./id.js";
|
|
2
|
+
import { createResourceAndLink, createResources, updateResource } from "./files.js";
|
|
3
|
+
import {
|
|
4
|
+
addCalendarDays,
|
|
5
|
+
calendarDayDifference,
|
|
6
|
+
calendarOccurrence,
|
|
7
|
+
calendarOccurrenceIndex,
|
|
8
|
+
parseCalendarDate,
|
|
9
|
+
validCalendarRecurrence
|
|
10
|
+
} from "./recurrence.js";
|
|
11
|
+
import { isRfc3339Timestamp } from "./time.js";
|
|
12
|
+
import { loadWorkspace } from "./workspace.js";
|
|
13
|
+
|
|
14
|
+
const COMPLETION_DATE_FIELDS = [
|
|
15
|
+
"completedOn",
|
|
16
|
+
"performedOn",
|
|
17
|
+
"reviewedOn",
|
|
18
|
+
"assessmentDate",
|
|
19
|
+
"meetingDate",
|
|
20
|
+
"scheduledOn",
|
|
21
|
+
"occurredOn",
|
|
22
|
+
"collectedOn",
|
|
23
|
+
"verifiedOn",
|
|
24
|
+
"approvedOn",
|
|
25
|
+
"submittedOn",
|
|
26
|
+
"closedOn",
|
|
27
|
+
"reportDate",
|
|
28
|
+
"periodEnd"
|
|
29
|
+
];
|
|
30
|
+
const MAX_PLANNED_ITEMS = 10_000;
|
|
31
|
+
const DEFAULT_EVENT_DEADLINE_DAYS = 30;
|
|
32
|
+
|
|
33
|
+
export function planObligations(resources, options = {}) {
|
|
34
|
+
const asOf = requireDate(options.asOf ?? new Date().toISOString().slice(0, 10), "as-of date");
|
|
35
|
+
const defaultNow = options.asOf ? `${asOf}T23:59:59Z` : new Date().toISOString();
|
|
36
|
+
const now = requireTimestamp(options.now ?? defaultNow, "current timestamp");
|
|
37
|
+
const through = requireDate(options.through ?? addCalendarDays(asOf, 90), "through date");
|
|
38
|
+
const requestedFrom = options.from ? requireDate(options.from, "from date") : null;
|
|
39
|
+
if (through < asOf && !requestedFrom) throw new Error("The through date must not be before the as-of date.");
|
|
40
|
+
if (requestedFrom && through < requestedFrom) throw new Error("The through date must not be before the from date.");
|
|
41
|
+
const records = resources.map((item) => item?.record ?? item).filter(Boolean);
|
|
42
|
+
const byId = new Map(records.map((record) => [record.id, record]));
|
|
43
|
+
const obligations = records.filter((record) => record.type === "obligation" && record.status === "active");
|
|
44
|
+
if (obligations.length > MAX_PLANNED_ITEMS) {
|
|
45
|
+
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} active obligations.`);
|
|
46
|
+
}
|
|
47
|
+
const calendarItems = [];
|
|
48
|
+
const triggerGroups = new Map();
|
|
49
|
+
let scannedCalendarOccurrences = 0;
|
|
50
|
+
const scanCalendarWindow = (recurrence, window, index) => {
|
|
51
|
+
if (scannedCalendarOccurrences++ >= MAX_PLANNED_ITEMS) {
|
|
52
|
+
throw new Error(`The obligation query must be narrowed; it scans more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} calendar occurrences.`);
|
|
53
|
+
}
|
|
54
|
+
return calendarWindow(recurrence, window, index);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
for (const obligation of obligations) {
|
|
58
|
+
if (obligation.recurrence?.mode === "event" && obligation.recurrence.eventType) {
|
|
59
|
+
const eventType = obligation.recurrence.eventType;
|
|
60
|
+
const group = triggerGroups.get(eventType) ?? {
|
|
61
|
+
eventType,
|
|
62
|
+
prompt: obligation.triggerPrompt || humanize(eventType),
|
|
63
|
+
policyIds: [],
|
|
64
|
+
obligationIds: [],
|
|
65
|
+
steps: []
|
|
66
|
+
};
|
|
67
|
+
group.policyIds.push(...(obligation.policyIds || []));
|
|
68
|
+
group.obligationIds.push(obligation.id);
|
|
69
|
+
group.steps.push({
|
|
70
|
+
obligationId: obligation.id,
|
|
71
|
+
title: obligation.title,
|
|
72
|
+
activityType: obligation.activityType,
|
|
73
|
+
ownerIds: obligation.ownerIds || [],
|
|
74
|
+
policyIds: obligation.policyIds || [],
|
|
75
|
+
controlIds: obligation.controlIds || [],
|
|
76
|
+
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
77
|
+
templateResourceId: obligation.templateResourceId || null,
|
|
78
|
+
completionResourceTypes: obligation.completionResourceTypes || [],
|
|
79
|
+
window: normalizedEventWindow(obligation.window)
|
|
80
|
+
});
|
|
81
|
+
triggerGroups.set(eventType, group);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const recurrence = {
|
|
86
|
+
...(obligation.recurrence || {}),
|
|
87
|
+
anchorDate: obligation.recurrence?.anchorDate || obligation.startsOn
|
|
88
|
+
};
|
|
89
|
+
if (!validCalendarRecurrence(recurrence)) continue;
|
|
90
|
+
const from = requestedFrom || recurrence.anchorDate;
|
|
91
|
+
let index = Math.max(0, calendarOccurrenceIndex(recurrence, from));
|
|
92
|
+
while (index > 0) {
|
|
93
|
+
const previousWindow = scanCalendarWindow(recurrence, obligation.window, index);
|
|
94
|
+
if (!previousWindow || previousWindow.overdueOn <= from) break;
|
|
95
|
+
index -= 1;
|
|
96
|
+
}
|
|
97
|
+
for (; ; index += 1) {
|
|
98
|
+
const window = scanCalendarWindow(recurrence, obligation.window, index);
|
|
99
|
+
if (!window || window.dueWindowStart > through) break;
|
|
100
|
+
if (obligation.endsOn && window.dueWindowStart > obligation.endsOn) break;
|
|
101
|
+
if (window.overdueOn <= from) continue;
|
|
102
|
+
const completions = (obligation.completionResourceIds || [])
|
|
103
|
+
.map((id) => byId.get(id))
|
|
104
|
+
.filter((record) => (
|
|
105
|
+
record
|
|
106
|
+
&& completionFallsInWindow(record, window)
|
|
107
|
+
&& completionTypeMatches(record, obligation.completionResourceTypes)
|
|
108
|
+
));
|
|
109
|
+
const status = occurrenceStatus(window, asOf, completions.length > 0);
|
|
110
|
+
if (status === "complete" && !options.includeComplete) continue;
|
|
111
|
+
if (calendarItems.length >= MAX_PLANNED_ITEMS) {
|
|
112
|
+
throw new Error(`The obligation query must be narrowed with a later from date; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} calendar occurrences.`);
|
|
113
|
+
}
|
|
114
|
+
calendarItems.push({
|
|
115
|
+
key: `${obligation.id}:${window.dueWindowStart}`,
|
|
116
|
+
kind: "calendar",
|
|
117
|
+
obligationId: obligation.id,
|
|
118
|
+
title: obligation.title,
|
|
119
|
+
activityType: obligation.activityType,
|
|
120
|
+
ownerIds: obligation.ownerIds || [],
|
|
121
|
+
policyIds: obligation.policyIds || [],
|
|
122
|
+
controlIds: obligation.controlIds || [],
|
|
123
|
+
scopeResourceIds: obligation.scopeResourceIds || [],
|
|
124
|
+
completionResourceIds: completions.map((record) => record.id),
|
|
125
|
+
status,
|
|
126
|
+
...window,
|
|
127
|
+
...relativeTiming(window, asOf)
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const events = records.filter((record) => record.type === "obligation-event");
|
|
133
|
+
if (events.length > MAX_PLANNED_ITEMS) {
|
|
134
|
+
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event runs.`);
|
|
135
|
+
}
|
|
136
|
+
const eventIds = new Set(events.map((event) => event.id));
|
|
137
|
+
const actionsBySource = new Map();
|
|
138
|
+
let eventActionCount = 0;
|
|
139
|
+
for (const record of records) {
|
|
140
|
+
if (record.type !== "action-item" || !eventIds.has(record.sourceResourceId)) continue;
|
|
141
|
+
if (++eventActionCount > MAX_PLANNED_ITEMS) {
|
|
142
|
+
throw new Error(`The obligation query must be narrowed; it includes more than ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} event actions.`);
|
|
143
|
+
}
|
|
144
|
+
if (!actionsBySource.has(record.sourceResourceId)) actionsBySource.set(record.sourceResourceId, []);
|
|
145
|
+
actionsBySource.get(record.sourceResourceId).push(record);
|
|
146
|
+
}
|
|
147
|
+
const eventRuns = events.map((event) => planEventRun(event, actionsBySource.get(event.id) || [], byId, asOf, now));
|
|
148
|
+
const eventItems = eventRuns
|
|
149
|
+
.filter((run) => run.status !== "canceled")
|
|
150
|
+
.flatMap((run) => run.actions)
|
|
151
|
+
.filter((item) => item.status !== "complete" || options.includeComplete);
|
|
152
|
+
if (calendarItems.length + eventItems.length > MAX_PLANNED_ITEMS) {
|
|
153
|
+
throw new Error(`The obligation query must be narrowed; it exceeds ${MAX_PLANNED_ITEMS.toLocaleString("en-US")} planned items.`);
|
|
154
|
+
}
|
|
155
|
+
const items = [...calendarItems, ...eventItems].sort(comparePlannedItems);
|
|
156
|
+
const counts = { overdue: 0, due: 0, upcoming: 0, complete: 0 };
|
|
157
|
+
for (const item of items) {
|
|
158
|
+
if (counts[item.status] !== undefined) counts[item.status] += 1;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
asOf,
|
|
163
|
+
through,
|
|
164
|
+
from: requestedFrom,
|
|
165
|
+
counts,
|
|
166
|
+
items,
|
|
167
|
+
calendarItems,
|
|
168
|
+
eventItems,
|
|
169
|
+
triggers: [...triggerGroups.values()].map((group) => ({
|
|
170
|
+
...group,
|
|
171
|
+
policyIds: [...new Set(group.policyIds)],
|
|
172
|
+
obligationIds: [...new Set(group.obligationIds)]
|
|
173
|
+
})).sort((a, b) => a.prompt.localeCompare(b.prompt)),
|
|
174
|
+
eventRuns
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function createObligationEvent(input, options) {
|
|
179
|
+
const loaded = await loadWorkspace(input);
|
|
180
|
+
const records = loaded.resources;
|
|
181
|
+
const eventType = String(options?.eventType || "").trim();
|
|
182
|
+
const occurredAt = options?.occurredAt ? requireTimestamp(options.occurredAt, "event timestamp") : null;
|
|
183
|
+
const timestampDate = timestampCalendarDate(occurredAt, loaded.workspace.timezone);
|
|
184
|
+
const occurredOn = requireDate(
|
|
185
|
+
options?.occurredOn || timestampDate,
|
|
186
|
+
"event date"
|
|
187
|
+
);
|
|
188
|
+
if (timestampDate && options?.occurredOn && occurredOn !== timestampDate) {
|
|
189
|
+
throw new Error(`The event date must match the event timestamp in ${loaded.workspace.timezone}.`);
|
|
190
|
+
}
|
|
191
|
+
const templates = records.filter((record) => (
|
|
192
|
+
record.type === "obligation"
|
|
193
|
+
&& record.status === "active"
|
|
194
|
+
&& record.recurrence?.mode === "event"
|
|
195
|
+
&& record.recurrence.eventType === eventType
|
|
196
|
+
));
|
|
197
|
+
if (!eventType || templates.length === 0) throw new Error(`No active obligations use event type "${eventType}".`);
|
|
198
|
+
if (templates.some((record) => Number.isInteger(normalizedEventWindow(record.window).endOffsetHours)) && !occurredAt) {
|
|
199
|
+
throw new Error(`Event type "${eventType}" has hour-based deadlines and requires an RFC 3339 occurredAt timestamp.`);
|
|
200
|
+
}
|
|
201
|
+
const subjectResourceIds = [...new Set((options.subjectResourceIds || []).map(String).filter(Boolean))];
|
|
202
|
+
const existingIds = records.map((record) => record.id);
|
|
203
|
+
const prompt = templates.find((record) => record.triggerPrompt)?.triggerPrompt || humanize(eventType);
|
|
204
|
+
const title = String(options.title || `${prompt.replace(/\?$/, "")} · ${occurredOn}`).trim();
|
|
205
|
+
const eventId = createResourceId("obligation-event", title, existingIds);
|
|
206
|
+
existingIds.push(eventId);
|
|
207
|
+
const actions = templates.map((obligation) => {
|
|
208
|
+
const id = createResourceId("action-item", `${eventId} ${obligation.title}`, existingIds);
|
|
209
|
+
existingIds.push(id);
|
|
210
|
+
const window = eventWindow(obligation, occurredOn, occurredAt, loaded.workspace.timezone);
|
|
211
|
+
return {
|
|
212
|
+
schemaVersion: 1,
|
|
213
|
+
id,
|
|
214
|
+
type: "action-item",
|
|
215
|
+
title: obligation.title,
|
|
216
|
+
status: "open",
|
|
217
|
+
assigneeIds: obligation.ownerIds || [],
|
|
218
|
+
sourceResourceId: eventId,
|
|
219
|
+
obligationId: obligation.id,
|
|
220
|
+
description: eventActionDescription(obligation, eventType),
|
|
221
|
+
dueWindowStart: window.dueWindowStart,
|
|
222
|
+
...(window.dueWindowEnd ? { dueWindowEnd: window.dueWindowEnd, dueOn: window.dueWindowEnd } : {}),
|
|
223
|
+
...(window.overdueOn ? { overdueOn: window.overdueOn } : {}),
|
|
224
|
+
...(window.dueWindowStartAt ? { dueWindowStartAt: window.dueWindowStartAt } : {}),
|
|
225
|
+
...(window.dueWindowEndAt ? { dueWindowEndAt: window.dueWindowEndAt, overdueAt: window.dueWindowEndAt } : {})
|
|
226
|
+
};
|
|
227
|
+
});
|
|
228
|
+
const event = {
|
|
229
|
+
schemaVersion: 1,
|
|
230
|
+
id: eventId,
|
|
231
|
+
type: "obligation-event",
|
|
232
|
+
title,
|
|
233
|
+
status: "open",
|
|
234
|
+
eventType,
|
|
235
|
+
occurredOn,
|
|
236
|
+
...(occurredAt ? { occurredAt } : {}),
|
|
237
|
+
ownerIds: [...new Set(templates.flatMap((record) => record.ownerIds || []))],
|
|
238
|
+
obligationIds: templates.map((record) => record.id),
|
|
239
|
+
actionItemIds: actions.map((record) => record.id),
|
|
240
|
+
...(subjectResourceIds.length ? { subjectResourceIds } : {})
|
|
241
|
+
};
|
|
242
|
+
await createResources(loaded.root, [event, ...actions]);
|
|
243
|
+
return { event, actions };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function completeObligationOccurrence(input, options) {
|
|
247
|
+
const loaded = await loadWorkspace(input);
|
|
248
|
+
const obligation = loaded.resources.find((record) => (
|
|
249
|
+
record.type === "obligation" && record.id === options?.obligationId
|
|
250
|
+
));
|
|
251
|
+
if (!obligation) throw new Error(`Obligation "${options?.obligationId ?? ""}" was not found.`);
|
|
252
|
+
assertExpectedCompletionType(obligation, options?.record);
|
|
253
|
+
return createResourceAndLink(loaded.root, options.record, {
|
|
254
|
+
type: "obligation",
|
|
255
|
+
id: obligation.id,
|
|
256
|
+
field: "completionResourceIds",
|
|
257
|
+
expectedRevision: options.expectedRevision
|
|
258
|
+
}, { content: options.content });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function completeObligationAction(input, options) {
|
|
262
|
+
const loaded = await loadWorkspace(input);
|
|
263
|
+
const action = loaded.resources.find((record) => (
|
|
264
|
+
record.type === "action-item" && record.id === options?.actionItemId
|
|
265
|
+
));
|
|
266
|
+
if (!action) throw new Error(`Action item "${options?.actionItemId ?? ""}" was not found.`);
|
|
267
|
+
if (!action.obligationId) throw new Error(`Action item "${action.id}" is not linked to an obligation.`);
|
|
268
|
+
const obligation = loaded.resources.find((record) => (
|
|
269
|
+
record.type === "obligation" && record.id === action.obligationId
|
|
270
|
+
));
|
|
271
|
+
if (!obligation) throw new Error(`Obligation "${action.obligationId}" was not found.`);
|
|
272
|
+
assertExpectedCompletionType(obligation, options?.record);
|
|
273
|
+
const completedOn = requireDate(options?.completedOn, "completion date");
|
|
274
|
+
const event = loaded.resources.find((record) => record.type === "obligation-event" && record.id === action.sourceResourceId);
|
|
275
|
+
if (event?.occurredOn && completedOn < event.occurredOn) {
|
|
276
|
+
throw new Error("The action completion date cannot be before its policy event date.");
|
|
277
|
+
}
|
|
278
|
+
return createResourceAndLink(loaded.root, options.record, {
|
|
279
|
+
type: "action-item",
|
|
280
|
+
id: action.id,
|
|
281
|
+
field: "completionResourceIds",
|
|
282
|
+
expectedRevision: options.expectedRevision,
|
|
283
|
+
patch: {
|
|
284
|
+
status: "done",
|
|
285
|
+
completedOn
|
|
286
|
+
}
|
|
287
|
+
}, { content: options.content });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export async function completeObligationEvent(input, options) {
|
|
291
|
+
const loaded = await loadWorkspace(input);
|
|
292
|
+
const event = loaded.resources.find((record) => (
|
|
293
|
+
record.type === "obligation-event" && record.id === options?.eventId
|
|
294
|
+
));
|
|
295
|
+
if (!event) throw new Error(`Obligation event "${options?.eventId ?? ""}" was not found.`);
|
|
296
|
+
const completedOn = requireDate(options?.completedOn, "completion date");
|
|
297
|
+
if (completedOn < event.occurredOn) {
|
|
298
|
+
throw new Error("The event completion date cannot be before its occurrence date.");
|
|
299
|
+
}
|
|
300
|
+
const plan = planObligations(loaded.resources, {
|
|
301
|
+
asOf: completedOn,
|
|
302
|
+
through: completedOn,
|
|
303
|
+
includeComplete: true
|
|
304
|
+
});
|
|
305
|
+
const run = plan.eventRuns.find((item) => item.id === event.id);
|
|
306
|
+
if (!run || run.actions.length === 0) {
|
|
307
|
+
throw new Error(`Obligation event "${event.id}" has no action checklist.`);
|
|
308
|
+
}
|
|
309
|
+
const incomplete = run.actions.filter((action) => action.status !== "complete");
|
|
310
|
+
if (incomplete.length) {
|
|
311
|
+
throw new Error(
|
|
312
|
+
`Obligation event "${event.id}" still has incomplete actions: ${incomplete.map((action) => action.actionItemId).join(", ")}.`
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return updateResource(loaded.root, "obligation-event", event.id, {
|
|
316
|
+
...event,
|
|
317
|
+
status: "complete",
|
|
318
|
+
completedOn
|
|
319
|
+
}, {
|
|
320
|
+
expectedRevision: options.expectedRevision
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function assertExpectedCompletionType(obligation, record) {
|
|
325
|
+
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
|
326
|
+
throw new Error("A completion resource record is required.");
|
|
327
|
+
}
|
|
328
|
+
const expected = obligation.completionResourceTypes ?? [];
|
|
329
|
+
if (expected.length && !expected.includes(record.type)) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`Obligation "${obligation.id}" expects a completion resource of type ${expected.join(" or ")}, not "${record.type ?? ""}".`
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function calendarWindow(recurrence, configuredWindow, index) {
|
|
337
|
+
const occurrence = calendarOccurrence(recurrence, index);
|
|
338
|
+
const next = calendarOccurrence(recurrence, index + 1);
|
|
339
|
+
if (!occurrence || !next) return null;
|
|
340
|
+
const startOffset = Number.isInteger(configuredWindow?.startOffsetDays) ? configuredWindow.startOffsetDays : 0;
|
|
341
|
+
const dueWindowStart = addCalendarDays(occurrence, startOffset);
|
|
342
|
+
const dueWindowEnd = Number.isInteger(configuredWindow?.endOffsetDays)
|
|
343
|
+
? addCalendarDays(occurrence, configuredWindow.endOffsetDays)
|
|
344
|
+
: addCalendarDays(next, -1);
|
|
345
|
+
const overdueOn = dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null;
|
|
346
|
+
if (!dueWindowStart || !dueWindowEnd || !overdueOn) return null;
|
|
347
|
+
return {
|
|
348
|
+
dueWindowStart,
|
|
349
|
+
dueWindowEnd,
|
|
350
|
+
overdueOn
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function eventWindow(obligation, occurredOn, occurredAt, timezone) {
|
|
355
|
+
const configuredWindow = normalizedEventWindow(obligation.window);
|
|
356
|
+
if (Number.isInteger(configuredWindow.endOffsetHours) && occurredAt) {
|
|
357
|
+
const startOffset = Number.isInteger(configuredWindow.startOffsetHours) ? configuredWindow.startOffsetHours : 0;
|
|
358
|
+
const dueWindowStartAt = addHours(occurredAt, startOffset);
|
|
359
|
+
const dueWindowEndAt = addHours(occurredAt, configuredWindow.endOffsetHours);
|
|
360
|
+
return {
|
|
361
|
+
dueWindowStart: timestampCalendarDate(dueWindowStartAt, timezone),
|
|
362
|
+
dueWindowEnd: timestampCalendarDate(dueWindowEndAt, timezone),
|
|
363
|
+
overdueOn: timestampCalendarDate(dueWindowEndAt, timezone),
|
|
364
|
+
dueWindowStartAt,
|
|
365
|
+
dueWindowEndAt,
|
|
366
|
+
overdueAt: dueWindowEndAt
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const startOffset = Number.isInteger(configuredWindow.startOffsetDays) ? configuredWindow.startOffsetDays : 0;
|
|
370
|
+
const dueWindowStart = addCalendarDays(occurredOn, startOffset);
|
|
371
|
+
const dueWindowEnd = addCalendarDays(occurredOn, configuredWindow.endOffsetDays);
|
|
372
|
+
const overdueOn = dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null;
|
|
373
|
+
if (!dueWindowStart || !dueWindowEnd || !overdueOn) {
|
|
374
|
+
throw new Error("Event deadline dates must fall within the supported calendar range.");
|
|
375
|
+
}
|
|
376
|
+
return {
|
|
377
|
+
dueWindowStart,
|
|
378
|
+
dueWindowEnd,
|
|
379
|
+
overdueOn
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function planEventRun(event, actionItems, byId, asOf, now) {
|
|
384
|
+
const actions = actionItems
|
|
385
|
+
.map((record) => {
|
|
386
|
+
const obligation = byId.get(record.obligationId);
|
|
387
|
+
const expectedCompletionTypes = obligation?.completionResourceTypes || [];
|
|
388
|
+
const linkedCompletionIds = [...new Set([
|
|
389
|
+
...(record.completionResourceIds || []),
|
|
390
|
+
...(record.evidenceIds || [])
|
|
391
|
+
])];
|
|
392
|
+
const matchingCompletionIds = linkedCompletionIds.filter((id) => completionTypeMatches(byId.get(id), expectedCompletionTypes));
|
|
393
|
+
const completionSatisfied = expectedCompletionTypes.length === 0
|
|
394
|
+
|| matchingCompletionIds.length > 0;
|
|
395
|
+
const complete = record.status === "done" && completionSatisfied;
|
|
396
|
+
const configuredWindow = normalizedEventWindow(obligation?.window);
|
|
397
|
+
const fallbackEndOffsetDays = Number.isInteger(configuredWindow.endOffsetDays)
|
|
398
|
+
? configuredWindow.endOffsetDays
|
|
399
|
+
: Math.ceil(configuredWindow.endOffsetHours / 24);
|
|
400
|
+
const dueWindowStart = record.dueWindowStart || event.occurredOn;
|
|
401
|
+
const dueWindowEnd = record.dueWindowEnd || record.dueOn || addCalendarDays(event.occurredOn, fallbackEndOffsetDays);
|
|
402
|
+
const dueWindowStartAt = record.dueWindowStartAt
|
|
403
|
+
|| (event.occurredAt && Number.isInteger(configuredWindow.startOffsetHours)
|
|
404
|
+
? addHours(event.occurredAt, configuredWindow.startOffsetHours)
|
|
405
|
+
: null);
|
|
406
|
+
const dueWindowEndAt = record.dueWindowEndAt
|
|
407
|
+
|| (event.occurredAt && Number.isInteger(configuredWindow.endOffsetHours)
|
|
408
|
+
? addHours(event.occurredAt, configuredWindow.endOffsetHours)
|
|
409
|
+
: null);
|
|
410
|
+
const window = {
|
|
411
|
+
dueWindowStart,
|
|
412
|
+
dueWindowEnd,
|
|
413
|
+
overdueOn: record.overdueOn || (dueWindowEnd ? addCalendarDays(dueWindowEnd, 1) : null),
|
|
414
|
+
dueWindowStartAt,
|
|
415
|
+
dueWindowEndAt,
|
|
416
|
+
overdueAt: record.overdueAt || dueWindowEndAt
|
|
417
|
+
};
|
|
418
|
+
const status = complete
|
|
419
|
+
? "complete"
|
|
420
|
+
: window.overdueAt && new Date(now) > new Date(window.overdueAt)
|
|
421
|
+
? "overdue"
|
|
422
|
+
: window.dueWindowStartAt && new Date(now) < new Date(window.dueWindowStartAt)
|
|
423
|
+
? "upcoming"
|
|
424
|
+
: !window.overdueAt && window.overdueOn && window.overdueOn <= asOf
|
|
425
|
+
? "overdue"
|
|
426
|
+
: window.dueWindowStart <= asOf
|
|
427
|
+
? "due"
|
|
428
|
+
: "upcoming";
|
|
429
|
+
return {
|
|
430
|
+
key: record.id,
|
|
431
|
+
kind: "event",
|
|
432
|
+
eventId: event.id,
|
|
433
|
+
actionItemId: record.id,
|
|
434
|
+
obligationId: record.obligationId,
|
|
435
|
+
title: record.title,
|
|
436
|
+
ownerIds: record.assigneeIds || [],
|
|
437
|
+
policyIds: obligation?.policyIds || [],
|
|
438
|
+
controlIds: obligation?.controlIds || [],
|
|
439
|
+
scopeResourceIds: obligation?.scopeResourceIds || [],
|
|
440
|
+
templateResourceId: obligation?.templateResourceId || null,
|
|
441
|
+
completionResourceIds: record.completionResourceIds || [],
|
|
442
|
+
evidenceIds: record.evidenceIds || [],
|
|
443
|
+
expectedCompletionTypes,
|
|
444
|
+
matchingCompletionIds,
|
|
445
|
+
missingCompletion: record.status === "done" && !completionSatisfied,
|
|
446
|
+
canceledAction: record.status === "canceled",
|
|
447
|
+
recordedStatus: record.status,
|
|
448
|
+
completedOn: record.completedOn || null,
|
|
449
|
+
status,
|
|
450
|
+
...window,
|
|
451
|
+
...relativeTiming(window, asOf),
|
|
452
|
+
...relativeTimestampTiming(window, now)
|
|
453
|
+
};
|
|
454
|
+
});
|
|
455
|
+
const derivedStatus = event.status === "canceled"
|
|
456
|
+
? "canceled"
|
|
457
|
+
: actions.length > 0 && actions.every((item) => item.status === "complete")
|
|
458
|
+
? "complete"
|
|
459
|
+
: actions.some((item) => item.status === "overdue")
|
|
460
|
+
? "overdue"
|
|
461
|
+
: actions.length > 0 && actions.every((item) => item.status === "upcoming")
|
|
462
|
+
? "upcoming"
|
|
463
|
+
: "due";
|
|
464
|
+
return {
|
|
465
|
+
id: event.id,
|
|
466
|
+
title: event.title,
|
|
467
|
+
eventType: event.eventType,
|
|
468
|
+
occurredOn: event.occurredOn,
|
|
469
|
+
occurredAt: event.occurredAt || null,
|
|
470
|
+
subjectResourceIds: event.subjectResourceIds || [],
|
|
471
|
+
actionItemIds: event.actionItemIds || [],
|
|
472
|
+
recordedStatus: event.status,
|
|
473
|
+
status: derivedStatus,
|
|
474
|
+
completeCount: actions.filter((item) => item.status === "complete").length,
|
|
475
|
+
actions
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function completionFallsInWindow(record, window) {
|
|
480
|
+
const date = completionDate(record);
|
|
481
|
+
return Boolean(date && date >= window.dueWindowStart && date <= window.dueWindowEnd);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function normalizedEventWindow(configuredWindow) {
|
|
485
|
+
const window = configuredWindow && !Array.isArray(configuredWindow) && typeof configuredWindow === "object"
|
|
486
|
+
? configuredWindow
|
|
487
|
+
: {};
|
|
488
|
+
if (Number.isInteger(window.endOffsetDays) || Number.isInteger(window.endOffsetHours)) return window;
|
|
489
|
+
if (Number.isInteger(window.startOffsetHours)) {
|
|
490
|
+
return {
|
|
491
|
+
...window,
|
|
492
|
+
endOffsetHours: window.startOffsetHours + (DEFAULT_EVENT_DEADLINE_DAYS * 24)
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
return {
|
|
496
|
+
...window,
|
|
497
|
+
startOffsetDays: Number.isInteger(window.startOffsetDays) ? window.startOffsetDays : 0,
|
|
498
|
+
endOffsetDays: DEFAULT_EVENT_DEADLINE_DAYS
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function completionTypeMatches(record, expectedTypes = []) {
|
|
503
|
+
return Boolean(record && (expectedTypes.length === 0 || expectedTypes.includes(record.type)));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function completionDate(record) {
|
|
507
|
+
for (const field of COMPLETION_DATE_FIELDS) {
|
|
508
|
+
if (parseCalendarDate(record[field])) return record[field];
|
|
509
|
+
}
|
|
510
|
+
return null;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function occurrenceStatus(window, asOf, complete) {
|
|
514
|
+
if (complete) return "complete";
|
|
515
|
+
if (window.overdueOn <= asOf) return "overdue";
|
|
516
|
+
if (window.dueWindowStart <= asOf) return "due";
|
|
517
|
+
return "upcoming";
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function relativeTiming(window, asOf) {
|
|
521
|
+
return {
|
|
522
|
+
daysUntilStart: window.dueWindowStart > asOf ? calendarDayDifference(asOf, window.dueWindowStart) : 0,
|
|
523
|
+
daysUntilOverdue: window.overdueOn && window.overdueOn > asOf ? calendarDayDifference(asOf, window.overdueOn) : 0,
|
|
524
|
+
daysOverdue: window.overdueOn && window.overdueOn <= asOf ? calendarDayDifference(window.overdueOn, asOf) : 0
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function relativeTimestampTiming(window, now) {
|
|
529
|
+
const result = {};
|
|
530
|
+
if (window.dueWindowStartAt) {
|
|
531
|
+
const startDifference = new Date(window.dueWindowStartAt) - new Date(now);
|
|
532
|
+
result.hoursUntilStart = startDifference > 0 ? Math.ceil(startDifference / 3_600_000) : 0;
|
|
533
|
+
}
|
|
534
|
+
if (window.overdueAt) {
|
|
535
|
+
const difference = new Date(window.overdueAt) - new Date(now);
|
|
536
|
+
Object.assign(result, {
|
|
537
|
+
hoursUntilOverdue: difference > 0 ? Math.ceil(difference / 3_600_000) : 0,
|
|
538
|
+
hoursOverdue: difference <= 0 ? Math.floor(Math.abs(difference) / 3_600_000) : 0
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return result;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function comparePlannedItems(a, b) {
|
|
545
|
+
const rank = { overdue: 0, due: 1, upcoming: 2, complete: 3 };
|
|
546
|
+
return (rank[a.status] - rank[b.status])
|
|
547
|
+
|| String(a.overdueAt || a.overdueOn || a.dueWindowEndAt || a.dueWindowEnd || a.dueWindowStartAt || a.dueWindowStart)
|
|
548
|
+
.localeCompare(String(b.overdueAt || b.overdueOn || b.dueWindowEndAt || b.dueWindowEnd || b.dueWindowStartAt || b.dueWindowStart))
|
|
549
|
+
|| a.title.localeCompare(b.title);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function eventActionDescription(obligation, eventType) {
|
|
553
|
+
const policy = obligation.policyIds?.length ? ` Policy sources: ${obligation.policyIds.join(", ")}.` : "";
|
|
554
|
+
const scope = obligation.scopeResourceIds?.length ? ` Review scoped resources: ${obligation.scopeResourceIds.join(", ")}.` : "";
|
|
555
|
+
const completion = obligation.completionResourceTypes?.length
|
|
556
|
+
? ` Link completion records of type ${obligation.completionResourceTypes.join(", ")} and any evidence before marking this done.`
|
|
557
|
+
: " Link the completion record and evidence before marking this done.";
|
|
558
|
+
return `Triggered by ${eventType}.${policy}${scope}${completion}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function requireDate(value, label) {
|
|
562
|
+
if (!parseCalendarDate(value)) throw new Error(`A valid ${label} is required.`);
|
|
563
|
+
return value;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function requireTimestamp(value, label) {
|
|
567
|
+
if (!isRfc3339Timestamp(value)) {
|
|
568
|
+
throw new Error(`A valid RFC 3339 ${label} is required.`);
|
|
569
|
+
}
|
|
570
|
+
return value;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function addHours(value, hours) {
|
|
574
|
+
const date = new Date(new Date(value).getTime() + hours * 3_600_000);
|
|
575
|
+
if (Number.isNaN(date.valueOf())) throw new Error("Event deadline timestamps must fall within the supported calendar range.");
|
|
576
|
+
const result = date.toISOString();
|
|
577
|
+
if (!isRfc3339Timestamp(result)) throw new Error("Event deadline timestamps must fall within the supported calendar range.");
|
|
578
|
+
return result;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function timestampCalendarDate(value, timezone) {
|
|
582
|
+
if (!value) return null;
|
|
583
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
584
|
+
timeZone: timezone,
|
|
585
|
+
year: "numeric",
|
|
586
|
+
month: "2-digit",
|
|
587
|
+
day: "2-digit"
|
|
588
|
+
}).formatToParts(new Date(value));
|
|
589
|
+
const fields = Object.fromEntries(parts.map((part) => [part.type, part.value]));
|
|
590
|
+
return `${fields.year}-${fields.month}-${fields.day}`;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function humanize(value) {
|
|
594
|
+
return String(value).replace(/[-_]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
|
|
595
|
+
}
|