filegrc 0.15.1 → 0.15.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/model/v11.json +13 -20
- package/package.json +1 -1
- package/src/audit-preparation.js +105 -10
- package/src/cli.js +7 -0
- package/src/collection-review.js +124 -31
- package/src/collection-revision.js +6 -1
- package/src/collection-scope.js +121 -5
- package/src/files.js +13 -1
- package/src/git.js +86 -0
- package/src/index.js +2 -1
- package/src/program-path.js +1 -1
- package/src/program-readiness.js +91 -13
- package/src/retention-schedule-approval.js +36 -17
- package/src/retention.js +58 -20
- package/src/server.js +15 -1
- package/src/source-coverage.js +7 -0
- package/src/state.js +2 -0
- package/src/validate.js +33 -3
- package/src/web.js +72 -38
- package/src/workflow.js +3 -1
package/src/retention.js
CHANGED
|
@@ -10,18 +10,26 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
10
10
|
if (!loaded.model.resources["retention-schedule-item"]) return [];
|
|
11
11
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
12
12
|
const rules = loaded.resources.filter((record) => (
|
|
13
|
-
record.type === "retention-schedule-item"
|
|
13
|
+
record.type === "retention-schedule-item"
|
|
14
|
+
&& !["retired", "superseded"].includes(record.status)
|
|
14
15
|
));
|
|
15
|
-
const
|
|
16
|
-
const
|
|
16
|
+
const activeRules = rules.filter(({ status }) => status === "active");
|
|
17
|
+
const revisions = await resourceReviewRevisions(loaded, activeRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
|
|
18
|
+
const usableRules = activeRules.filter((rule) => retentionRuleIsCurrent(rule, revisions, byId, loaded));
|
|
19
|
+
const proposedRules = rules.filter(retentionRuleHasCoverageProposal);
|
|
17
20
|
const uses = retentionUses(loaded, program);
|
|
18
|
-
const items = rules.
|
|
21
|
+
const items = rules.map((rule) => readinessItem(
|
|
19
22
|
`retention-rule-${rule.id}`,
|
|
20
|
-
"action",
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
+
usableRules.includes(rule) ? "complete" : "action",
|
|
24
|
+
usableRules.includes(rule) ? `${rule.title} proposal is ready` : `Finish ${rule.title}`,
|
|
25
|
+
usableRules.includes(rule)
|
|
26
|
+
? "The owner completed this row and bound it to its current source revisions. It will become part of the authoritative schedule only when the complete schedule is approved."
|
|
27
|
+
: rule.status === "planned"
|
|
28
|
+
? "Finish the type, scope, cutoff, period, disposition, sources, and owner review for this proposed row before requesting schedule approval."
|
|
29
|
+
: "This row is incomplete or is not bound to every current source revision. Review it before requesting schedule approval.",
|
|
23
30
|
rule,
|
|
24
31
|
{
|
|
32
|
+
coverageState: usableRules.includes(rule) ? "proposed" : "incomplete",
|
|
25
33
|
sourceResourceIds: retentionReviewResourceIds(rule, loaded),
|
|
26
34
|
commands: [
|
|
27
35
|
`npx filegrc get ${rule.id} --mutation`,
|
|
@@ -39,31 +47,41 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
39
47
|
usesByInformationType.set(use.informationTypeId, grouped);
|
|
40
48
|
}
|
|
41
49
|
items.push(...[...usesByInformationType].map(([informationTypeId, informationTypeUses]) => {
|
|
42
|
-
const
|
|
50
|
+
const absentUses = informationTypeUses.filter((use) => (
|
|
51
|
+
!proposedRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
52
|
+
));
|
|
53
|
+
const pendingUses = informationTypeUses.filter((use) => (
|
|
43
54
|
!usableRules.some((rule) => ruleCoversUse(rule, use, program))
|
|
44
55
|
));
|
|
56
|
+
const approvalPending = !options.scheduleApproved;
|
|
45
57
|
const matches = usableRules.filter((rule) => (
|
|
46
58
|
informationTypeUses.some((use) => ruleCoversUse(rule, use, program))
|
|
47
59
|
));
|
|
48
60
|
const informationTypeTitle = byId.get(informationTypeId)?.title || informationTypeId;
|
|
49
|
-
const affectedTitles =
|
|
61
|
+
const affectedTitles = absentUses.map(({ resource }) => resource.title);
|
|
50
62
|
const affectedSummary = affectedTitles.length > 3
|
|
51
63
|
? `${affectedTitles.slice(0, 3).join(", ")}, and ${affectedTitles.length - 3} more`
|
|
52
64
|
: affectedTitles.join(", ");
|
|
53
65
|
return readinessItem(
|
|
54
66
|
`retention-use-${informationTypeId}`,
|
|
55
|
-
|
|
67
|
+
absentUses.length ? "action" : pendingUses.length || approvalPending ? "info" : "complete",
|
|
56
68
|
`Decide retention for ${informationTypeTitle}`,
|
|
57
|
-
|
|
69
|
+
absentUses.length
|
|
58
70
|
? `${affectedTitles.length} in-scope ${affectedTitles.length === 1 ? "resource uses" : "resources use"} this Information Type without an active, current retention decision, including ${affectedSummary}. Management must choose the cutoff, period, disposition, and whether one program-wide rule or separate scoped rules apply.`
|
|
59
|
-
:
|
|
71
|
+
: pendingUses.length
|
|
72
|
+
? `Proposed rows cover every in-scope type-and-scope pair. Finish the row review, then approve the complete schedule once instead of approving these coverage checks separately.`
|
|
73
|
+
: approvalPending
|
|
74
|
+
? "Completed row proposals cover every in-scope type-and-scope pair. Approve the complete schedule once; these derived checks are not separate approval work."
|
|
75
|
+
: `The approved schedule covers all ${informationTypeUses.length} in-scope ${informationTypeUses.length === 1 ? "use" : "uses"}.`,
|
|
60
76
|
{ type: "retention-schedule-item" },
|
|
61
77
|
{
|
|
78
|
+
progressUnit: false,
|
|
79
|
+
coverageState: absentUses.length ? "absent" : pendingUses.length || approvalPending ? "proposed" : "approved",
|
|
62
80
|
informationTypeId,
|
|
63
|
-
affectedResourceIds:
|
|
64
|
-
retentionScopeResourceIds:
|
|
81
|
+
affectedResourceIds: absentUses.map(({ resource }) => resource.id),
|
|
82
|
+
retentionScopeResourceIds: absentUses.length === informationTypeUses.length
|
|
65
83
|
? [program.id]
|
|
66
|
-
:
|
|
84
|
+
: absentUses.map(({ resource }) => resource.id),
|
|
67
85
|
retentionScheduleItemIds: matches.map(({ id }) => id),
|
|
68
86
|
commands: [
|
|
69
87
|
"npx filegrc guide retention-schedule-item --json",
|
|
@@ -75,6 +93,11 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
75
93
|
|
|
76
94
|
for (const coverage of loaded.resources.filter((record) => record.type === "source-coverage" && record.status === "active")) {
|
|
77
95
|
const linked = (coverage.retentionScheduleItemIds || []).map((id) => byId.get(id)).filter(Boolean);
|
|
96
|
+
const proposed = linked.filter((rule) => (
|
|
97
|
+
rule.type === "retention-schedule-item"
|
|
98
|
+
&& proposedRules.includes(rule)
|
|
99
|
+
&& (rule.scopeResourceIds || []).includes(coverage.id)
|
|
100
|
+
));
|
|
78
101
|
const matching = linked.filter((rule) => (
|
|
79
102
|
rule.type === "retention-schedule-item"
|
|
80
103
|
&& rule.status === "active"
|
|
@@ -83,13 +106,17 @@ export async function assessRetentionReadiness(loaded, program, options = {}) {
|
|
|
83
106
|
));
|
|
84
107
|
items.push(readinessItem(
|
|
85
108
|
`retention-source-coverage-${coverage.id}`,
|
|
86
|
-
matching.length ? "complete" : "action",
|
|
109
|
+
matching.length && options.scheduleApproved ? "complete" : proposed.length ? "info" : "action",
|
|
87
110
|
`Confirm retained evidence for ${coverage.title}`,
|
|
88
|
-
matching.length
|
|
111
|
+
matching.length && options.scheduleApproved
|
|
89
112
|
? `The source-coverage record references a current schedule item scoped to this population.`
|
|
90
|
-
:
|
|
113
|
+
: proposed.length
|
|
114
|
+
? "A proposed schedule row covers this evidence source. Finish that row and approve the complete schedule before relying on it."
|
|
115
|
+
: `No proposed schedule row covers ${coverage.id}. Add the genuine type-and-scope decision before schedule approval.`,
|
|
91
116
|
coverage,
|
|
92
117
|
{
|
|
118
|
+
progressUnit: false,
|
|
119
|
+
coverageState: matching.length && options.scheduleApproved ? "approved" : proposed.length ? "proposed" : "absent",
|
|
93
120
|
retentionScheduleItemIds: matching.map(({ id }) => id),
|
|
94
121
|
commands: [
|
|
95
122
|
`npx filegrc get ${coverage.id} --mutation`,
|
|
@@ -218,8 +245,11 @@ function reviewSource(loaded, entry) {
|
|
|
218
245
|
&& entry.record.workflowScope === "program"
|
|
219
246
|
) {
|
|
220
247
|
const approved = structuredClone(entry.record);
|
|
221
|
-
if (approved.status === "active") approved.status = "approved";
|
|
222
248
|
for (const field of [
|
|
249
|
+
"status",
|
|
250
|
+
"approverIds",
|
|
251
|
+
"approvedOn",
|
|
252
|
+
"approvedContentRevisions",
|
|
223
253
|
"activationBasis",
|
|
224
254
|
"activatedByIds",
|
|
225
255
|
"activatedOn",
|
|
@@ -252,6 +282,14 @@ function ruleCoversUse(rule, use, program) {
|
|
|
252
282
|
return scope.has(use.resource.id) || scope.has(program.id);
|
|
253
283
|
}
|
|
254
284
|
|
|
285
|
+
function retentionRuleHasCoverageProposal(rule) {
|
|
286
|
+
return Boolean(
|
|
287
|
+
(rule.informationTypeIds || []).length
|
|
288
|
+
&& (rule.scopeResourceIds || []).length
|
|
289
|
+
&& rule.scheduleDocumentId
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
255
293
|
export function retentionReviewResourceIds(rule, loaded) {
|
|
256
294
|
const programUseIds = [];
|
|
257
295
|
if (loaded) {
|
|
@@ -279,7 +317,7 @@ export function retentionRuleIsCurrent(rule, revisions, byId = new Map(), loaded
|
|
|
279
317
|
if (!(rule.informationTypeIds || []).length || !(rule.scopeResourceIds || []).length || !rule.scheduleDocumentId) return false;
|
|
280
318
|
const schedule = byId.get(rule.scheduleDocumentId);
|
|
281
319
|
if (schedule?.type !== "document" || schedule.documentKind !== "schedule" || schedule.workflowScope !== "program" || ["superseded", "retired"].includes(schedule.status)) return false;
|
|
282
|
-
if (!(rule.ownerIds || []).length
|
|
320
|
+
if (!(rule.ownerIds || []).length) return false;
|
|
283
321
|
if (!rule.reviewedSourceRevisions || typeof rule.reviewedSourceRevisions !== "object") return false;
|
|
284
322
|
if (!retentionCutoffIsComplete(rule.cutoff) || !retentionPeriodIsComplete(rule.retentionPeriod)) return false;
|
|
285
323
|
if (!["delete", "destroy", "erase", "anonymize", "transfer", "retain-permanently"].includes(rule.dispositionAction)) return false;
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
pushWorkspace,
|
|
34
34
|
retryBrowserSync,
|
|
35
35
|
runBrowserMutation,
|
|
36
|
+
synchronizeBrowserStartup,
|
|
36
37
|
withGitCommandCache,
|
|
37
38
|
withGitCommandDeadline
|
|
38
39
|
} from "./git.js";
|
|
@@ -777,6 +778,18 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
777
778
|
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
|
|
778
779
|
throw new Error("The server port must be an integer from 0 through 65535.");
|
|
779
780
|
}
|
|
781
|
+
let startupSynchronization;
|
|
782
|
+
try {
|
|
783
|
+
startupSynchronization = await synchronizeBrowserStartup(input, {
|
|
784
|
+
allowNonAuthoritativeWrites: options.allowNonAuthoritativeWrites === true
|
|
785
|
+
});
|
|
786
|
+
} catch (error) {
|
|
787
|
+
startupSynchronization = {
|
|
788
|
+
status: "failed",
|
|
789
|
+
updated: false,
|
|
790
|
+
message: error.message
|
|
791
|
+
};
|
|
792
|
+
}
|
|
780
793
|
const loaded = await loadWorkspace(input);
|
|
781
794
|
getResourceDefinition(loaded.model, "workspace");
|
|
782
795
|
const server = createFilegrcServer(loaded.root, {
|
|
@@ -806,7 +819,8 @@ export async function serveWorkspace(input = process.cwd(), options = {}) {
|
|
|
806
819
|
address: server.address(),
|
|
807
820
|
url: `http://${urlHost(host)}:${server.address().port}`,
|
|
808
821
|
requestedPort: port,
|
|
809
|
-
usedFallbackPort
|
|
822
|
+
usedFallbackPort,
|
|
823
|
+
startupSynchronization
|
|
810
824
|
};
|
|
811
825
|
}
|
|
812
826
|
|
package/src/source-coverage.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { resourceReviewRevisions, retentionReviewResourceIds, retentionRuleIsCurrent } from "./retention.js";
|
|
2
|
+
import { retentionScheduleIsAuthoritative } from "./collection-review.js";
|
|
3
|
+
import { modelSupports } from "../model/index.js";
|
|
4
|
+
import { resolveProgram } from "./program.js";
|
|
2
5
|
|
|
3
6
|
export async function sourceCoverageComplete(record, loaded, program = loaded.workspace) {
|
|
4
7
|
const structuredRetention = loaded.model.resources["retention-schedule-item"];
|
|
@@ -6,6 +9,10 @@ export async function sourceCoverageComplete(record, loaded, program = loaded.wo
|
|
|
6
9
|
return false;
|
|
7
10
|
}
|
|
8
11
|
if (structuredRetention) {
|
|
12
|
+
const retentionProgram = modelSupports(loaded.model, "program-scope")
|
|
13
|
+
? resolveProgram(loaded, program?.type === "program" ? program.id : undefined)
|
|
14
|
+
: program;
|
|
15
|
+
if (!retentionScheduleIsAuthoritative(loaded, retentionProgram)) return false;
|
|
9
16
|
const linkedRules = (record.retentionScheduleItemIds || []).map((id) => (
|
|
10
17
|
loaded.resources.find((resource) => resource.id === id && resource.type === "retention-schedule-item")
|
|
11
18
|
));
|
package/src/state.js
CHANGED
|
@@ -347,6 +347,8 @@ function serializeCollectionReviewAssessment(assessment) {
|
|
|
347
347
|
status: assessment.status,
|
|
348
348
|
complete: assessment.complete,
|
|
349
349
|
message: assessment.message,
|
|
350
|
+
...(assessment.recordProposals ? { recordProposals: assessment.recordProposals } : {}),
|
|
351
|
+
...(assessment.incompleteRecordProposals ? { incompleteRecordProposals: assessment.incompleteRecordProposals } : {}),
|
|
350
352
|
...(assessment.eligibleReviewerIds ? { eligibleReviewerIds: assessment.eligibleReviewerIds } : {}),
|
|
351
353
|
...(assessment.reviewerConflictIds ? { reviewerConflictIds: assessment.reviewerConflictIds } : {}),
|
|
352
354
|
...(assessment.approvalIssues ? { approvalIssues: assessment.approvalIssues } : {})
|
package/src/validate.js
CHANGED
|
@@ -3,7 +3,7 @@ import { createReadStream } from "node:fs";
|
|
|
3
3
|
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { performance } from "node:perf_hooks";
|
|
5
5
|
import { getResourceDefinition, modelSupports } from "../model/index.js";
|
|
6
|
-
import { scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
6
|
+
import { collectionRecordProposals, retentionScheduleReviewScope, scopedCollectionRecords, selectScopedCollectionRecords } from "./collection-scope.js";
|
|
7
7
|
import {
|
|
8
8
|
collectionRevision,
|
|
9
9
|
collectionRevisionMatches
|
|
@@ -1224,6 +1224,10 @@ function sameRouteProposal(proposal, finalized) {
|
|
|
1224
1224
|
return [...keys].every((key) => allowed.has(key) || JSON.stringify(proposal[key]) === JSON.stringify(finalized[key]));
|
|
1225
1225
|
}
|
|
1226
1226
|
|
|
1227
|
+
function sameStringIds(left = [], right = []) {
|
|
1228
|
+
return JSON.stringify([...left].sort()) === JSON.stringify([...right].sort());
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1227
1231
|
function validateTimestampTimezone(value, timezone, path, diagnostics, label) {
|
|
1228
1232
|
if (!value || !timezone) return;
|
|
1229
1233
|
const source = String(value);
|
|
@@ -1252,10 +1256,19 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1252
1256
|
const program = modelSupports(model, "program-scope")
|
|
1253
1257
|
? (record.scopeResourceIds || []).map((id) => byId.get(id)).find(({ type } = {}) => type === "program")
|
|
1254
1258
|
: null;
|
|
1259
|
+
const workspaceWideRetentionReview = record.resourceType === "retention-schedule-item"
|
|
1260
|
+
&& modelSupports(model, "retention-schedule-approval");
|
|
1261
|
+
const retentionScope = workspaceWideRetentionReview ? retentionScheduleReviewScope(loaded) : null;
|
|
1255
1262
|
const recordCount = scopedCollectionRecords(loaded, record.resourceType, program).length;
|
|
1256
1263
|
const currentPopulationIds = scopedCollectionRecords(loaded, record.resourceType, program)
|
|
1257
1264
|
.map(({ id }) => id)
|
|
1258
1265
|
.sort();
|
|
1266
|
+
const incompleteProposals = collectionRecordProposals(
|
|
1267
|
+
loaded,
|
|
1268
|
+
record.resourceType,
|
|
1269
|
+
scopedCollectionRecords(loaded, record.resourceType, program),
|
|
1270
|
+
program
|
|
1271
|
+
).filter(({ complete }) => !complete);
|
|
1259
1272
|
const currentRevision = collectionRevision(loaded, record.resourceType, {
|
|
1260
1273
|
program,
|
|
1261
1274
|
authoritativeSourceId: record.decision === "externally-managed"
|
|
@@ -1274,6 +1287,20 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1274
1287
|
currentRevision
|
|
1275
1288
|
}
|
|
1276
1289
|
);
|
|
1290
|
+
if (current && workspaceWideRetentionReview && !sameStringIds(record.scopeResourceIds, retentionScope.programIds)) {
|
|
1291
|
+
diagnostics.push(error(
|
|
1292
|
+
"invalid-retention-schedule-review-scope",
|
|
1293
|
+
path,
|
|
1294
|
+
"The current Data Retention Schedule review must cover every active Program as one workspace-wide approval."
|
|
1295
|
+
));
|
|
1296
|
+
}
|
|
1297
|
+
if (current && incompleteProposals.length) {
|
|
1298
|
+
diagnostics.push(error(
|
|
1299
|
+
"incomplete-collection-review-proposals",
|
|
1300
|
+
path,
|
|
1301
|
+
`Complete every ${configuration.title.toLowerCase()} record proposal before the collection review. Remaining: ${incompleteProposals.map(({ title }) => title).join(", ")}.`
|
|
1302
|
+
));
|
|
1303
|
+
}
|
|
1277
1304
|
if (current && record.resourceType === "control") {
|
|
1278
1305
|
const controls = scopedCollectionRecords(loaded, "control", program);
|
|
1279
1306
|
const controlIds = new Set(controls.map(({ id }) => id));
|
|
@@ -1299,8 +1326,11 @@ function validateCollectionReview(record, loaded, byId, path, diagnostics) {
|
|
|
1299
1326
|
}
|
|
1300
1327
|
if (current && record.resourceType === "retention-schedule-item" && modelSupports(model, "retention-schedule-approval")) {
|
|
1301
1328
|
const rows = scopedCollectionRecords(loaded, record.resourceType, program);
|
|
1302
|
-
for (const issue of retentionScheduleApprovalIssues(loaded,
|
|
1303
|
-
informationTypesReviewed:
|
|
1329
|
+
for (const issue of retentionScheduleApprovalIssues(loaded, retentionScope, rows, {
|
|
1330
|
+
informationTypesReviewed: retentionScope.programIds.every((id) => (
|
|
1331
|
+
collectionReviewIsCurrent(loaded, "information-type", byId.get(id))
|
|
1332
|
+
)),
|
|
1333
|
+
approvalReview: record
|
|
1304
1334
|
})) {
|
|
1305
1335
|
diagnostics.push(error(issue.code, path, issue.message));
|
|
1306
1336
|
}
|
package/src/web.js
CHANGED
|
@@ -639,7 +639,11 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
639
639
|
const documentEntry = documentEntries.length === 1 ? documentEntries[0] : null;
|
|
640
640
|
const rows = resourcesOfType("retention-schedule-item")
|
|
641
641
|
.sort((left, right) => left.record.title.localeCompare(right.record.title));
|
|
642
|
-
const
|
|
642
|
+
const completedProposalIds = new Set(state.programReadiness?.stages
|
|
643
|
+
?.find(({ id }) => id === "policies")
|
|
644
|
+
?.items.filter(({ id, status }) => id.startsWith("retention-rule-") && status === "complete")
|
|
645
|
+
.map(({ id }) => id.slice("retention-rule-".length)) || []);
|
|
646
|
+
const proposedRows = rows.filter(({ record }) => completedProposalIds.has(record.id)).length;
|
|
643
647
|
const documentReadiness = documentEntry ? retentionScheduleDocumentReadiness(documentEntry.record.id) : null;
|
|
644
648
|
const documentReady = documentEntries.length === 1 && documentReadiness?.status === "complete";
|
|
645
649
|
const documentAction = documentEntry
|
|
@@ -656,7 +660,10 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
656
660
|
const cutoff = retentionCutoffLabel(record.cutoff);
|
|
657
661
|
const period = retentionPeriodLabel(record.retentionPeriod);
|
|
658
662
|
const action = record.dispositionAction ? properCase(record.dispositionAction) : "Not selected";
|
|
659
|
-
|
|
663
|
+
const status = record.status === "active" && state.collectionReviews?.["retention-schedule-item"]?.status !== "current"
|
|
664
|
+
? "proposed"
|
|
665
|
+
: displayStatus(record);
|
|
666
|
+
return '<tr data-retention-row data-history="' + (["superseded", "retired"].includes(record.status) ? "true" : "false") + '"><td data-label="Rule" data-primary-field><a class="record-title" href="#/resource/retention-schedule-item/' + encodeURIComponent(record.id) + '?stage=policies">' + esc(record.title) + '</a><small>' + esc(record.description || record.id) + '</small></td><td data-label="Information types">' + esc(informationTypes) + '</td><td data-label="Scope">' + esc(scope) + '</td><td data-label="Retention"><strong>' + esc(period) + '</strong><small>From ' + esc(cutoff) + '</small></td><td data-label="Disposition">' + esc(action) + '</td><td data-label="Status">' + formatValue(status, "status", record.type, true) + '</td><td data-label="Next action">' + recordWorkflowCell("retention-schedule-item", entry, "?stage=policies") + '</td></tr>';
|
|
660
667
|
}).join("")
|
|
661
668
|
: '<tr><td colspan="7">' + empty("No retention rules are needed until an actual information use requires one.") + '</td></tr>';
|
|
662
669
|
const add = state.readOnly ? "" : '<button class="button primary" type="button" data-add-retention-row>Add schedule row</button>';
|
|
@@ -665,12 +672,12 @@ function renderRetentionSchedulePage(main, params = new URLSearchParams()) {
|
|
|
665
672
|
const showHistory = params.get("history") === "1";
|
|
666
673
|
const tableTools = '<div class="retention-table-tools"><label><span class="sr-only">Filter schedule rows</span><input type="search" data-retention-search placeholder="Filter schedule rows" value="' + esc(initialFilter) + '"></label><label class="history-toggle"><input type="checkbox" data-retention-history ' + (showHistory ? "checked" : "") + '> Show retired</label></div>';
|
|
667
674
|
main.innerHTML = '<div class="page retention-schedule-page"><nav class="breadcrumbs"><a href="#/stage/policies">Step 2</a><span>/</span><span>Data Retention Schedule</span></nav>' +
|
|
668
|
-
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>
|
|
669
|
-
'<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' +
|
|
670
|
-
'<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p></section>' +
|
|
675
|
+
'<div class="page-intro"><div><p class="kicker">Step 2</p><div class="page-title-line"><h2>Data Retention Schedule</h2>' + guideTrigger + '</div><p>Owners finish each proposed retention row first. A separate reviewer then approves the complete document and all proposed rows once.</p></div><div class="actions">' + documentAction + add + '</div></div>' + resourceGuide("retention-schedule-item") +
|
|
676
|
+
'<div class="retention-summary"><div><span class="retention-summary-label">Governing document</span>' + documentSummary + '</div><div><span class="retention-summary-label">Schedule revision</span><span><strong>' + proposedRows + ' completed ' + pluralize("proposal", proposedRows) + '</strong><small>Final approval follows every row proposal</small></span><span class="badge ' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "good" : "warn") + '">' + (state.collectionReviews?.["retention-schedule-item"]?.status === "current" ? "Approved" : "Approval needed") + '</span></div></div>' +
|
|
677
|
+
'<section class="retention-rows"><div class="section-head"><div><p class="kicker">Structured schedule</p><h2>Schedule rows</h2><p>Add a row when an Information Type and its operational scope need a distinct cutoff, period, or disposition rule.</p></div>' + tableTools + '</div><div class="record-table-wrap"><table class="record-table"><thead><tr><th>Rule</th><th>Information types</th><th>Scope</th><th>Retention</th><th>Disposition</th><th>Status</th><th>Next action</th></tr></thead><tbody>' + rowHtml + '</tbody></table></div><p class="retention-filter-empty" data-retention-empty hidden>No schedule rows match this filter.</p><p class="retention-row-count" data-retention-count aria-live="polite"></p><nav class="pagination retention-pagination" aria-label="Data Retention Schedule pages" hidden><button class="button" type="button" data-retention-page="previous">Previous</button><span class="page-status" aria-live="polite"></span><button class="button" type="button" data-retention-page="next">Next</button></nav></section>' +
|
|
671
678
|
renderRetentionScheduleIssues() + collectionReviewPanel("retention-schedule-item", true) + '</div>';
|
|
672
679
|
resourceGuideCleanup = setupResourceGuide(main);
|
|
673
|
-
wireRetentionScheduleTable(rows.length);
|
|
680
|
+
wireRetentionScheduleTable(rows.length, Number(params.get("page")) || 1);
|
|
674
681
|
main.querySelector("[data-review-collection]")?.addEventListener("click", () => openCollectionReviewDialog("retention-schedule-item"));
|
|
675
682
|
main.querySelector("[data-add-retention-document]")?.addEventListener("click", () => openEditor("document", null, {
|
|
676
683
|
seed: {
|
|
@@ -708,38 +715,54 @@ function openRetentionScheduleItemEditor(seed = {}) {
|
|
|
708
715
|
...(scheduleDocuments.length === 1 ? { scheduleDocumentId: scheduleDocuments[0].record.id } : {}),
|
|
709
716
|
...seed
|
|
710
717
|
},
|
|
711
|
-
description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, and
|
|
718
|
+
description: "Define one retention decision. Keep it planned until its scope, cutoff, period, disposition, sources, owner review, and current revision bindings are complete.",
|
|
712
719
|
saveLabel: "Save schedule row"
|
|
713
720
|
});
|
|
714
721
|
}
|
|
715
722
|
|
|
716
|
-
function wireRetentionScheduleTable(total) {
|
|
723
|
+
function wireRetentionScheduleTable(total, initialPage = 1) {
|
|
717
724
|
const table = root.querySelector(".retention-rows");
|
|
718
725
|
if (!table) return;
|
|
719
726
|
const search = table.querySelector("[data-retention-search]");
|
|
720
727
|
const historyToggle = table.querySelector("[data-retention-history]");
|
|
721
728
|
const count = table.querySelector("[data-retention-count]");
|
|
722
729
|
const noResults = table.querySelector("[data-retention-empty]");
|
|
730
|
+
const pagination = table.querySelector(".retention-pagination");
|
|
731
|
+
const pageStatus = pagination.querySelector(".page-status");
|
|
732
|
+
const previous = pagination.querySelector('[data-retention-page="previous"]');
|
|
733
|
+
const next = pagination.querySelector('[data-retention-page="next"]');
|
|
734
|
+
let pageNumber = Number.isInteger(initialPage) && initialPage > 0 ? initialPage : 1;
|
|
723
735
|
const filter = () => {
|
|
724
736
|
const query = search.value.trim().toLowerCase();
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
737
|
+
const filtered = [...table.querySelectorAll("[data-retention-row]")].filter((row) => (
|
|
738
|
+
(historyToggle.checked || row.dataset.history !== "true")
|
|
739
|
+
&& (!query || row.textContent.toLowerCase().includes(query))
|
|
740
|
+
));
|
|
741
|
+
const totalPages = Math.max(1, Math.ceil(filtered.length / LIST_PAGE_SIZE));
|
|
742
|
+
pageNumber = Math.min(pageNumber, totalPages);
|
|
743
|
+
const start = (pageNumber - 1) * LIST_PAGE_SIZE;
|
|
744
|
+
const visible = new Set(filtered.slice(start, start + LIST_PAGE_SIZE));
|
|
745
|
+
table.querySelectorAll("[data-retention-row]").forEach((row) => { row.hidden = !visible.has(row); });
|
|
746
|
+
noResults.hidden = filtered.length !== 0 || total === 0;
|
|
747
|
+
const firstVisible = filtered.length ? start + 1 : 0;
|
|
748
|
+
const lastVisible = Math.min(start + LIST_PAGE_SIZE, filtered.length);
|
|
749
|
+
count.textContent = firstVisible + "–" + lastVisible + " of " + filtered.length + " " + pluralize("row", filtered.length) + (filtered.length === total ? "" : " · " + total + " total");
|
|
750
|
+
pagination.hidden = totalPages === 1;
|
|
751
|
+
pageStatus.textContent = "Page " + pageNumber + " of " + totalPages;
|
|
752
|
+
previous.disabled = pageNumber === 1;
|
|
753
|
+
next.disabled = pageNumber === totalPages;
|
|
734
754
|
};
|
|
735
755
|
const updateLocation = () => {
|
|
736
756
|
const params = new URLSearchParams();
|
|
737
757
|
if (search.value.trim()) params.set("q", search.value.trim());
|
|
738
758
|
if (historyToggle.checked) params.set("history", "1");
|
|
759
|
+
if (pageNumber > 1) params.set("page", String(pageNumber));
|
|
739
760
|
history.replaceState(null, "", "#/retention-schedule" + (params.size ? "?" + params : ""));
|
|
740
761
|
};
|
|
741
|
-
search.addEventListener("input", () => { filter(); updateLocation(); });
|
|
742
|
-
historyToggle.addEventListener("change", () => { filter(); updateLocation(); });
|
|
762
|
+
search.addEventListener("input", () => { pageNumber = 1; filter(); updateLocation(); });
|
|
763
|
+
historyToggle.addEventListener("change", () => { pageNumber = 1; filter(); updateLocation(); });
|
|
764
|
+
previous.addEventListener("click", () => { pageNumber -= 1; filter(); updateLocation(); });
|
|
765
|
+
next.addEventListener("click", () => { pageNumber += 1; filter(); updateLocation(); });
|
|
743
766
|
filter();
|
|
744
767
|
}
|
|
745
768
|
|
|
@@ -759,9 +782,16 @@ function renderRetentionScheduleIssues() {
|
|
|
759
782
|
}
|
|
760
783
|
|
|
761
784
|
function retentionScheduleDocumentReadiness(documentId) {
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
785
|
+
const documentIssueCodes = new Set([
|
|
786
|
+
"missing-retention-schedule-document",
|
|
787
|
+
"multiple-retention-schedule-documents",
|
|
788
|
+
"incomplete-retention-schedule-document"
|
|
789
|
+
]);
|
|
790
|
+
const issue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues
|
|
791
|
+
?.find(({ code }) => documentIssueCodes.has(code));
|
|
792
|
+
return issue
|
|
793
|
+
? { status: "action", message: issue.message }
|
|
794
|
+
: { status: "complete", message: "The document proposal is complete. Final approval follows all completed row proposals." };
|
|
765
795
|
}
|
|
766
796
|
|
|
767
797
|
function referenceTitle(id) {
|
|
@@ -1162,27 +1192,34 @@ function collectionReviewPanel(type, force = false) {
|
|
|
1162
1192
|
const current = assessment.status === "current";
|
|
1163
1193
|
const scheduleReview = type === "retention-schedule-item" && modelSupports("retention-schedule-approval");
|
|
1164
1194
|
const scheduleBlocker = scheduleReview ? retentionScheduleApprovalBlocker() : null;
|
|
1195
|
+
const proposalBlocker = assessment.incompleteRecordProposals?.length
|
|
1196
|
+
? "Complete " + assessment.incompleteRecordProposals.length + " record " + (assessment.incompleteRecordProposals.length === 1 ? "proposal" : "proposals") + " before the collection review."
|
|
1197
|
+
: null;
|
|
1198
|
+
const reviewBlocker = scheduleBlocker || proposalBlocker;
|
|
1165
1199
|
const needsFirstRecord = collectionNeedsFirstRecord(type);
|
|
1166
1200
|
const reviewerNames = (assessment.review?.reviewedByIds || [])
|
|
1167
1201
|
.map((id) => state.resources.find(({ record }) => record.id === id)?.record.title || id);
|
|
1168
1202
|
const reviewNote = assessment.review?.rationale
|
|
1169
1203
|
? " Note: " + esc(assessment.review.rationale)
|
|
1170
1204
|
: "";
|
|
1171
|
-
const reviewSummary = current && !
|
|
1205
|
+
const reviewSummary = current && !reviewBlocker
|
|
1172
1206
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + "." + reviewNote + '</span></p>'
|
|
1173
|
-
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(
|
|
1174
|
-
const compactReviewSummary = current && !
|
|
1207
|
+
: '<p class="collection-review-result"><strong>' + (scheduleBlocker ? "Complete schedule first" : proposalBlocker ? "Complete proposals first" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Review again" : "Review required") + '</strong><span>' + esc(reviewBlocker || assessment.message) + '</span></p>';
|
|
1208
|
+
const compactReviewSummary = current && !reviewBlocker
|
|
1175
1209
|
? '<p class="collection-review-result"><strong>' + esc(properCase(assessment.review.decision)) + '</strong><span>Reviewed ' + esc(formatCalendarDate(assessment.review.reviewedOn)) + (reviewerNames.length ? " by " + esc(reviewerNames.join(", ")) : "") + ".</span></p>"
|
|
1176
1210
|
: reviewSummary;
|
|
1177
1211
|
const action = state.readOnly
|
|
1178
1212
|
? ""
|
|
1179
|
-
:
|
|
1180
|
-
? '<button class="button" type="button" disabled title="' + esc(
|
|
1213
|
+
: reviewBlocker
|
|
1214
|
+
? '<button class="button" type="button" disabled title="' + esc(reviewBlocker) + '">' + (scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>'
|
|
1181
1215
|
: needsFirstRecord
|
|
1182
1216
|
? '<a class="button primary" href="#/resources/' + encodeURIComponent(type) + '?new=1">Add first ' + esc(state.model.resources[type].title.toLowerCase()) + '</a>'
|
|
1183
1217
|
: '<button class="button ' + (current ? "" : "primary") + '" type="button" data-review-collection="' + esc(type) + '">' + (current ? scheduleReview ? "Approve a new revision" : "Review again" : scheduleReview ? "Review and approve schedule" : "Review and confirm") + '</button>';
|
|
1184
|
-
const status =
|
|
1185
|
-
const
|
|
1218
|
+
const status = reviewBlocker ? "Not ready" : current ? "Reviewed" : needsFirstRecord ? "Records required" : assessment.status === "stale" ? "Stale" : "Review required";
|
|
1219
|
+
const proposalSummary = assessment.recordProposals?.length
|
|
1220
|
+
? '<p class="collection-review-proposal-summary"><strong>' + assessment.recordProposals.filter(({ complete }) => complete).length + ' of ' + assessment.recordProposals.length + ' record proposals complete.</strong> The collection review follows every proposal.</p>'
|
|
1221
|
+
: "";
|
|
1222
|
+
const details = '<details ' + (current ? 'class="collection-review-details"' : "open") + '><summary>' + (current ? scheduleReview ? "Show approved revision" : "Show scope confirmation" : "What to review") + '</summary><div class="collection-review-detail-content"><div class="collection-review-head"><div><p class="kicker">' + (scheduleReview ? "Schedule approval" : "Scope confirmation") + '</p><h3>' + esc(configuration.title) + '</h3><p>' + esc(configuration.description) + '</p></div><span class="badge ' + (current ? "good" : "warn") + '">' + status + '</span></div>' + proposalSummary + '<ul>' + configuration.reviewPoints.map((point) => '<li>' + esc(point) + '</li>').join("") + '</ul>' + (current ? reviewSummary : "") + '</div></details>';
|
|
1186
1223
|
const summary = current
|
|
1187
1224
|
? '<div class="collection-review-current-row"><div class="collection-review-complete-summary"><span class="kicker">' + (scheduleReview ? "Approved schedule" : "Scope confirmation") + '</span>' + compactReviewSummary + '</div>' + details + action + '</div>'
|
|
1188
1225
|
: details;
|
|
@@ -1201,15 +1238,8 @@ function retentionScheduleApprovalBlocker() {
|
|
|
1201
1238
|
const document = documents.length === 1 ? documents[0] : null;
|
|
1202
1239
|
if (documents.length > 1) return "Keep exactly one current program Data Retention Schedule document before approval.";
|
|
1203
1240
|
if (!document) return "Add the governing Data Retention Schedule document first.";
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
}
|
|
1207
|
-
const documentReadiness = retentionScheduleDocumentReadiness(document.id);
|
|
1208
|
-
if (documentReadiness?.status !== "complete") {
|
|
1209
|
-
return documentReadiness?.message || "Complete the governing document before approving the schedule.";
|
|
1210
|
-
}
|
|
1211
|
-
const planned = resourcesOfType("retention-schedule-item").find(({ record }) => record.status === "planned")?.record;
|
|
1212
|
-
if (planned) return "Complete or retire the planned row: " + planned.title + ".";
|
|
1241
|
+
const approvalIssue = state.collectionReviews?.["retention-schedule-item"]?.approvalIssues?.[0];
|
|
1242
|
+
if (approvalIssue) return approvalIssue.message;
|
|
1213
1243
|
const incomplete = retentionReviewItems("policies", false).find((item) => (
|
|
1214
1244
|
item.status === "action" && item.id !== "collection-review-retention-schedule-item"
|
|
1215
1245
|
));
|
|
@@ -4752,6 +4782,9 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4752
4782
|
Array.isArray(group) ? { fields: group } : group
|
|
4753
4783
|
));
|
|
4754
4784
|
const oneOf = new Set(oneOfGroups.flatMap((group) => group.fields || []).filter((name) => !name.startsWith("$markdown:")));
|
|
4785
|
+
const hiddenFields = type === "retention-schedule-item" && modelSupports("retention-schedule-approval")
|
|
4786
|
+
? new Set(["approvedByIds", "approvedOn"])
|
|
4787
|
+
: new Set();
|
|
4755
4788
|
const activeOneOf = new Set(oneOfGroups
|
|
4756
4789
|
.filter((group) => !group.when || conditionMatches(record, group.when))
|
|
4757
4790
|
.flatMap((group) => group.fields || []));
|
|
@@ -4765,6 +4798,7 @@ function openEditor(type, entry = null, options = {}) {
|
|
|
4765
4798
|
...oneOf
|
|
4766
4799
|
])].filter((name) => (
|
|
4767
4800
|
!["id", "type"].includes(name)
|
|
4801
|
+
&& !hiddenFields.has(name)
|
|
4768
4802
|
&& fields[name]
|
|
4769
4803
|
&& !(options.occurrenceReview && ["members", "expectedCount", "completedCount", "conclusion"].includes(name))
|
|
4770
4804
|
));
|
package/src/workflow.js
CHANGED
|
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { modelSupports } from "../model/index.js";
|
|
5
5
|
import { applicabilityReviewIsCurrent } from "./applicability-scope.js";
|
|
6
|
+
import { retentionScheduleIsAuthoritative } from "./collection-review.js";
|
|
6
7
|
import { assessRequiredAppointments } from "./appointments.js";
|
|
7
8
|
import { assessSourceCoverageReadiness } from "./source-coverage.js";
|
|
8
9
|
import { coverageEnd, coverageStart } from "./coverage.js";
|
|
@@ -918,6 +919,7 @@ async function auditLifecycleFindings(loaded, audits, program) {
|
|
|
918
919
|
const byId = new Map(loaded.resources.map((record) => [record.id, record]));
|
|
919
920
|
const retentionRules = loaded.resources.filter((record) => record.type === "retention-schedule-item");
|
|
920
921
|
const retentionRevisions = await resourceReviewRevisions(loaded, retentionRules.flatMap((rule) => retentionReviewResourceIds(rule, loaded)));
|
|
922
|
+
const retentionScheduleApproved = retentionScheduleIsAuthoritative(loaded, target);
|
|
921
923
|
const findings = [];
|
|
922
924
|
for (const audit of audits) {
|
|
923
925
|
if (!(audit.controlIds || []).length) {
|
|
@@ -1045,7 +1047,7 @@ async function auditLifecycleFindings(loaded, audits, program) {
|
|
|
1045
1047
|
`${records.length} ${noun} remain open, so this engagement cannot be treated as closed.`
|
|
1046
1048
|
));
|
|
1047
1049
|
}
|
|
1048
|
-
const linkedRetentionCurrent = (audit.retentionScheduleItemIds || []).some((id) => {
|
|
1050
|
+
const linkedRetentionCurrent = retentionScheduleApproved && (audit.retentionScheduleItemIds || []).some((id) => {
|
|
1049
1051
|
const rule = byId.get(id);
|
|
1050
1052
|
return rule?.type === "retention-schedule-item"
|
|
1051
1053
|
&& rule.status === "active"
|