diff --git a/apps/api/src/isms/documents/generate.ts b/apps/api/src/isms/documents/generate.ts index 14d71f8354..a1a3270587 100644 --- a/apps/api/src/isms/documents/generate.ts +++ b/apps/api/src/isms/documents/generate.ts @@ -5,6 +5,7 @@ import { deriveInterestedParties } from './interested-parties'; import { deriveRequirements } from './requirements'; import { deriveObjectives } from './objectives'; import { seedRolesIfMissing } from './roles'; +import { seedMetricsIfMissing } from './monitoring'; import { deriveNarrativeForType, isNarrativeType } from './registry'; import type { IsmsPlatformData } from './types'; @@ -255,6 +256,12 @@ export async function runDerivation({ await seedRolesIfMissing({ tx, documentId, memberCount: data.memberCount }); return; } + if (type === 'monitoring') { + // Idempotent seed only (same guarantee as roles): a regenerate can never + // clobber metric edits, deactivations, or measurement history. + await seedMetricsIfMissing({ tx, documentId }); + return; + } if (isNarrativeType(type)) { await generateNarrative({ tx, documentId, type, data }); return; diff --git a/apps/api/src/isms/documents/monitoring-defaults.ts b/apps/api/src/isms/documents/monitoring-defaults.ts new file mode 100644 index 0000000000..ead001fce8 --- /dev/null +++ b/apps/api/src/isms/documents/monitoring-defaults.ts @@ -0,0 +1,105 @@ +import type { SeedMetricDefinition } from './types'; + +/** + * The nine seeded monitoring metrics (CS-723), active from day one. Names, + * measured text, methods, cadences and targets follow the ticket and the + * clause-9.1 reference document; the customer edits any field, deactivates a + * metric, or adds custom ones. Seeding is idempotent by metricKey + * (seedMetricsIfMissing) and never overwrites customer edits. + */ +export const SEED_METRIC_DEFINITIONS: SeedMetricDefinition[] = [ + { + metricKey: 'training_completion', + name: 'Security awareness training completion', + whatIsMeasured: + 'Percentage of workforce members who have completed their assigned security-awareness training.', + method: + 'Comp AI training tracker: completed assignments over total assigned, per campaign with monthly follow-up.', + cadence: 'monthly', + target: '95% completion or above', + }, + { + metricKey: 'vendor_review_currency', + name: 'Vendor review currency', + whatIsMeasured: + 'Percentage of critical vendors with an in-date security review and a signed DPA.', + method: + 'Comp AI vendor register: critical vendors with a review inside its window and a signed DPA, over all critical vendors.', + cadence: 'quarterly', + target: '100% of critical vendors current', + }, + { + metricKey: 'finding_ageing', + name: 'Open audit findings and corrective-action ageing', + whatIsMeasured: + 'Count of open audit findings and the age of each open corrective action.', + method: + 'Corrective-action records in Comp AI: open findings and days since each was raised, against its due date.', + cadence: 'monthly', + target: 'No corrective action open beyond its due date', + }, + { + metricKey: 'risk_treatment_status', + name: 'Risk treatment status', + whatIsMeasured: + 'Progress of risk treatment plans across the risk register.', + method: + 'Comp AI risk register: risks by treatment status, highlighting high and critical risks without an active plan.', + cadence: 'quarterly', + target: 'All high and critical risks under active treatment', + }, + { + metricKey: 'policy_acknowledgement', + name: 'Policy acknowledgement coverage', + whatIsMeasured: + 'Percentage of workforce members who have acknowledged all policies assigned to them.', + method: + 'Comp AI policy tracker: members with all assigned policies acknowledged, over all members with assignments.', + cadence: 'quarterly', + target: '95% acknowledgement coverage or above', + }, + { + metricKey: 'uptime', + name: 'Production availability / uptime', + whatIsMeasured: 'Availability of production services over the period.', + method: + 'Cloud monitoring uptime reports (e.g. AWS / GCP / status page) for production services.', + cadence: 'monthly', + target: '99.9% availability or above', + }, + { + metricKey: 'vulnerability_remediation', + name: 'Critical and High vulnerability remediation time', + whatIsMeasured: + 'Time from detection to remediation for Critical and High severity vulnerabilities.', + method: + 'SAST / SCA findings and tracker SLA timers: days from detection to closure per Critical/High vulnerability.', + cadence: 'monthly', + target: 'Critical within 7 days; High within 30 days', + }, + { + metricKey: 'unauthorised_access', + name: 'Confirmed unauthorised-access incidents', + whatIsMeasured: + 'Confirmed incidents of unauthorised access to customer or company data.', + method: + 'Access logs, alerting and incident records: count of confirmed unauthorised-access incidents in the period.', + cadence: 'monthly', + target: '0 confirmed incidents', + }, + { + metricKey: 'incident_response_times', + name: 'Incident time-to-acknowledge and time-to-contain', + whatIsMeasured: + 'Time from detection to acknowledgement and to containment for security incidents.', + method: + 'Incident records measured against the severity SLA: acknowledgement and containment timestamps per incident.', + cadence: 'monthly', + target: 'Within the severity SLA for every incident', + }, +]; + +/** Stable keys of the nine seeded metrics. */ +export const SEED_METRIC_KEYS: string[] = SEED_METRIC_DEFINITIONS.map( + (metric) => metric.metricKey, +); diff --git a/apps/api/src/isms/documents/monitoring-export-data.spec.ts b/apps/api/src/isms/documents/monitoring-export-data.spec.ts new file mode 100644 index 0000000000..65d0fd9b6c --- /dev/null +++ b/apps/api/src/isms/documents/monitoring-export-data.spec.ts @@ -0,0 +1,147 @@ +import { db } from '@db'; +import { + loadMonitoringExtras, + mapMetrics, + type MetricWithExportIncludes, +} from './monitoring-export-data'; + +jest.mock('@db', () => ({ + db: { + member: { findMany: jest.fn() }, + ismsRole: { findFirst: jest.fn() }, + }, +})); + +const mockDb = jest.mocked(db); + +const baseMetric = { + id: 'met_1', + documentId: 'doc_1', + metricKey: 'uptime', + name: 'Production availability / uptime', + whatIsMeasured: 'Availability of production services.', + method: 'Cloud monitoring uptime reports.', + cadence: 'monthly', + monitorMemberId: null, + analyzeMemberId: null, + target: '≥ 99.9% availability', + objectiveId: null, + objective: null, + dataSource: 'manual', + isActive: true, + source: 'derived', + derivedFrom: 'seed:uptime', + position: 0, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), + measurements: [], +} as unknown as MetricWithExportIncludes; + +describe('loadMonitoringExtras', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves member names and the SPO fallback display', async () => { + (mockDb.member.findMany as jest.Mock).mockResolvedValue([ + { + id: 'mem_1', + deactivated: false, + user: { name: 'Jane Doe', email: 'jane@x.io' }, + }, + { + id: 'mem_2', + deactivated: true, + user: { name: null, email: 'gone@x.io' }, + }, + ]); + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue({ + assignments: [{ memberId: 'mem_1' }, { memberId: 'mem_2' }], + }); + + const extras = await loadMonitoringExtras({ organizationId: 'org_1' }); + + expect(extras.memberNames).toEqual({ + mem_1: 'Jane Doe', + mem_2: 'gone@x.io', + }); + // Deactivated SPO assignee is excluded from the fallback display. + expect(extras.spoDisplay).toBe('Security & Privacy Owner (Jane Doe)'); + }); + + it('falls back to the plain role label when the SPO is unassigned', async () => { + (mockDb.member.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.ismsRole.findFirst as jest.Mock).mockResolvedValue(null); + + const extras = await loadMonitoringExtras({ organizationId: 'org_1' }); + expect(extras.spoDisplay).toBe('Security & Privacy Owner (SPO)'); + }); +}); + +describe('mapMetrics', () => { + const extras = { + memberNames: { mem_1: 'Jane Doe' }, + spoDisplay: 'Security & Privacy Owner (SPO)', + }; + + it('excludes deactivated metrics from the published framework table', () => { + const rows = mapMetrics( + [baseMetric, { ...baseMetric, id: 'met_2', isActive: false }], + extras, + ); + expect(rows).toHaveLength(1); + }); + + it('resolves people: explicit member name, SPO fallback for null', () => { + const rows = mapMetrics( + [{ ...baseMetric, monitorMemberId: 'mem_1', analyzeMemberId: null }], + extras, + ); + expect(rows[0].monitorName).toBe('Jane Doe'); + expect(rows[0].analyzeName).toBe('Security & Privacy Owner (SPO)'); + }); + + it('renders the latest value with its period label', () => { + const rows = mapMetrics( + [ + { + ...baseMetric, + measurements: [ + { + periodStart: new Date('2026-07-01T00:00:00Z'), + value: '99.95%', + }, + ] as MetricWithExportIncludes['measurements'], + }, + ], + extras, + ); + expect(rows[0].currentValue).toBe('99.95% (July 2026)'); + }); + + it('renders an em-dash when the metric has no measurements', () => { + expect(mapMetrics([baseMetric], extras)[0].currentValue).toBe('—'); + }); + + it('prefers explicit target text, else the linked objective target', () => { + const withObjective = { + ...baseMetric, + target: null, + objective: { objective: 'Maintain uptime', target: '99.9% SLA' }, + } as unknown as MetricWithExportIncludes; + expect(mapMetrics([withObjective], extras)[0].target).toBe('99.9% SLA'); + + const explicitWins = { + ...withObjective, + target: 'Explicit target', + } as unknown as MetricWithExportIncludes; + expect(mapMetrics([explicitWins], extras)[0].target).toBe( + 'Explicit target', + ); + }); + + it('humanizes the cadence', () => { + expect(mapMetrics([baseMetric], extras)[0].cadence).toBe('Monthly'); + expect( + mapMetrics([{ ...baseMetric, cadence: null }], extras)[0].cadence, + ).toBeNull(); + }); +}); diff --git a/apps/api/src/isms/documents/monitoring-export-data.ts b/apps/api/src/isms/documents/monitoring-export-data.ts new file mode 100644 index 0000000000..0d159ec187 --- /dev/null +++ b/apps/api/src/isms/documents/monitoring-export-data.ts @@ -0,0 +1,147 @@ +import { db } from '@db'; +import type { Prisma } from '@db'; +import { + periodLabel, + toPeriodKey, + type MetricCadenceValue, +} from '../utils/metric-periods'; +import type { MetricExportRow } from './types'; + +/** + * Extra data the Monitoring document (9.1) needs at export time but that isn't + * on the metric rows: display names for the monitor/analyse members (metrics + * store a plain memberId, no FK) and the SPO fallback label for unassigned + * metrics (null memberId means "defaults to the SPO"). Resolved once and frozen + * into the version snapshot so a historical export re-renders byte-faithfully + * with the values and names that were current at publish. + */ +export interface MonitoringExtras { + /** memberId → display name (name, else email, else a placeholder). */ + memberNames: Record; + /** + * Rendered for metrics with no explicit monitor/analyse member: + * "Security & Privacy Owner (Jane Doe)" when the SPO role has active + * holders, else the plain role label. + */ + spoDisplay: string; +} + +type Client = Prisma.TransactionClient | typeof db; + +function memberDisplayName( + user: { name: string | null; email: string | null } | null, +): string { + return user?.name?.trim() || user?.email?.trim() || 'Unknown member'; +} + +/** Load the Monitoring document's export extras for an organization. */ +export async function loadMonitoringExtras({ + organizationId, + client, +}: { + organizationId: string; + client?: Client; +}): Promise { + const prisma = client ?? db; + + const [members, spoRole] = await Promise.all([ + prisma.member.findMany({ + where: { organizationId }, + select: { + id: true, + deactivated: true, + user: { select: { name: true, email: true } }, + }, + }), + prisma.ismsRole.findFirst({ + where: { + roleKey: 'spo', + document: { organizationId, type: 'roles_and_responsibilities' }, + }, + select: { assignments: { select: { memberId: true } } }, + }), + ]); + + const memberNames: Record = {}; + const activeIds = new Set(); + for (const member of members) { + memberNames[member.id] = memberDisplayName(member.user); + if (!member.deactivated) activeIds.add(member.id); + } + + const spoHolders = (spoRole?.assignments ?? []) + .filter((assignment) => activeIds.has(assignment.memberId)) + .map((assignment) => memberNames[assignment.memberId]) + .filter((name): name is string => !!name); + + return { + memberNames, + spoDisplay: + spoHolders.length > 0 + ? `Security & Privacy Owner (${spoHolders.join(', ')})` + : 'Security & Privacy Owner (SPO)', + }; +} + +/** The metric shape mapMetrics consumes (EXPORT_DOCUMENT_INCLUDE's metrics). */ +export type MetricWithExportIncludes = Prisma.IsmsMetricGetPayload<{ + include: { + objective: { select: { objective: true; target: true } }; + measurements: true; + }; +}>; + +const CADENCE_LABELS: Record = { + monthly: 'Monthly', + quarterly: 'Quarterly', +}; + +/** "99.95% (July 2026)" from the metric's most recent measurement, or "—". */ +function currentValueText(metric: MetricWithExportIncludes): string { + const latest = metric.measurements[0]; + if (!latest) return '—'; + const key = toPeriodKey(latest.periodStart); + if (!key) return latest.value; + const label = metric.cadence + ? periodLabel(metric.cadence as MetricCadenceValue, key) + : key; + return `${latest.value} (${label})`; +} + +/** The target column: explicit free text wins, else the linked objective. */ +function targetText(metric: MetricWithExportIncludes): string { + if (metric.target?.trim()) return metric.target; + if (metric.objective) { + return metric.objective.target || metric.objective.objective; + } + return ''; +} + +/** + * Map ACTIVE metric rows into export rows, resolving people and the current + * value. Deactivated metrics keep their history in the platform but are not + * part of the published framework document. + */ +export function mapMetrics( + metrics: MetricWithExportIncludes[], + extras: MonitoringExtras, +): MetricExportRow[] { + const personName = (memberId: string | null): string => { + if (!memberId) return extras.spoDisplay; + return extras.memberNames[memberId] ?? 'Former member'; + }; + + return metrics + .filter((metric) => metric.isActive) + .map((metric) => ({ + metricKey: metric.metricKey, + name: metric.name, + whatIsMeasured: metric.whatIsMeasured, + method: metric.method, + cadence: metric.cadence ? (CADENCE_LABELS[metric.cadence] ?? null) : null, + monitorName: personName(metric.monitorMemberId), + analyzeName: personName(metric.analyzeMemberId), + target: targetText(metric), + currentValue: currentValueText(metric), + })); +} diff --git a/apps/api/src/isms/documents/monitoring-export.spec.ts b/apps/api/src/isms/documents/monitoring-export.spec.ts new file mode 100644 index 0000000000..e79ee70250 --- /dev/null +++ b/apps/api/src/isms/documents/monitoring-export.spec.ts @@ -0,0 +1,76 @@ +import { buildExportSections } from './registry'; +import { generateIsmsExportFile } from '../utils/export-generator'; +import { buildExportMetadata } from '../utils/export-metadata'; +import { SEED_METRIC_DEFINITIONS } from './monitoring-defaults'; +import type { DocumentExportInput, MetricExportRow } from './types'; + +/** + * End-to-end render check for the Monitoring document (9.1): the section + * builder + both real renderers (jsPDF, docx) must produce non-empty files — + * including the widest table in the ISMS set (7 columns). Guards the whole + * export pipeline for the new type without needing a live org. + */ +function metricRow(index: number): MetricExportRow { + const seed = SEED_METRIC_DEFINITIONS[index]; + return { + metricKey: seed.metricKey, + name: seed.name, + whatIsMeasured: seed.whatIsMeasured, + method: seed.method, + cadence: seed.cadence === 'monthly' ? 'Monthly' : 'Quarterly', + monitorName: 'Security & Privacy Owner (Alex Petrisor)', + analyzeName: 'Security & Privacy Owner (Alex Petrisor)', + target: seed.target, + currentValue: index % 2 === 0 ? '99.95% (July 2026)' : '—', + }; +} + +const INPUT: DocumentExportInput = { + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + // All nine seeded metrics: the realistic full-width worst case. + metrics: SEED_METRIC_DEFINITIONS.map((_, index) => metricRow(index)), +}; + +function metadata() { + return buildExportMetadata({ + type: 'monitoring', + title: 'Monitoring, Measurement, Analysis and Evaluation', + frameworkName: 'ISO 27001', + version: 1, + status: 'approved', + preparedBy: 'Comp AI', + owner: null, + approverName: 'Raoul Plickat', + approvedAt: new Date('2026-05-26T00:00:00.000Z'), + declinedAt: null, + organizationName: 'Pressmaster AI Inc.', + primaryColor: '#004D3D', + }); +} + +describe('Monitoring document export', () => { + const sections = buildExportSections({ type: 'monitoring', input: INPUT }); + + it('renders a non-empty PDF with the full nine-metric table', async () => { + const result = await generateIsmsExportFile({ + sections, + metadata: metadata(), + format: 'pdf', + }); + expect(result.fileBuffer.length).toBeGreaterThan(0); + expect(result.mimeType).toBe('application/pdf'); + }); + + it('renders a non-empty DOCX', async () => { + const result = await generateIsmsExportFile({ + sections, + metadata: metadata(), + format: 'docx', + }); + expect(result.fileBuffer.length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/isms/documents/monitoring.spec.ts b/apps/api/src/isms/documents/monitoring.spec.ts new file mode 100644 index 0000000000..b685eb7cd5 --- /dev/null +++ b/apps/api/src/isms/documents/monitoring.spec.ts @@ -0,0 +1,198 @@ +import type { Prisma } from '@db'; +import { + buildMonitoringSections, + metricValidationMessages, + seedMetricsIfMissing, +} from './monitoring'; +import { SEED_METRIC_DEFINITIONS } from './monitoring-defaults'; +import type { MetricExportRow } from './types'; + +describe('metricValidationMessages (clause 9.1 submit gate)', () => { + it('requires at least one active metric', () => { + expect(metricValidationMessages({ metrics: [] })).toEqual([ + 'At least one metric must be active.', + ]); + expect( + metricValidationMessages({ + metrics: [{ name: 'Uptime', cadence: 'monthly', isActive: false }], + }), + ).toEqual(['At least one metric must be active.']); + }); + + it('requires a cadence on every ACTIVE metric only', () => { + const messages = metricValidationMessages({ + metrics: [ + { name: 'Uptime', cadence: 'monthly', isActive: true }, + { name: 'Custom A', cadence: null, isActive: true }, + { name: 'Custom B', cadence: null, isActive: false }, // inactive: exempt + ], + }); + expect(messages).toEqual(['"Custom A" needs a cadence.']); + }); + + it('passes with one active metric that has a cadence', () => { + expect( + metricValidationMessages({ + metrics: [{ name: 'Uptime', cadence: 'monthly', isActive: true }], + }), + ).toEqual([]); + }); +}); + +describe('seedMetricsIfMissing', () => { + const makeTx = (existing: Array<{ metricKey: string | null; position: number }>) => { + const tx = { + ismsMetric: { + findMany: jest.fn().mockResolvedValue(existing), + createMany: jest.fn().mockResolvedValue({ count: 0 }), + }, + }; + return tx as unknown as Prisma.TransactionClient & typeof tx; + }; + + it('seeds all nine defaults into an empty document', async () => { + const tx = makeTx([]); + await seedMetricsIfMissing({ tx, documentId: 'doc_1' }); + + const { data, skipDuplicates } = ( + tx.ismsMetric.createMany as jest.Mock + ).mock.calls[0][0]; + expect(skipDuplicates).toBe(true); + expect(data).toHaveLength(9); + expect(data.map((row: { metricKey: string }) => row.metricKey)).toEqual( + SEED_METRIC_DEFINITIONS.map((metric) => metric.metricKey), + ); + // People deliberately unassigned: null means "defaults to the SPO". + expect(data[0]).toMatchObject({ + documentId: 'doc_1', + source: 'derived', + derivedFrom: `seed:${SEED_METRIC_DEFINITIONS[0].metricKey}`, + position: 0, + }); + expect(data[0].monitorMemberId).toBeUndefined(); + }); + + it('creates only the missing seeds, after existing positions', async () => { + const tx = makeTx([ + { metricKey: 'training_completion', position: 0 }, + { metricKey: null, position: 5 }, // custom metric + ]); + await seedMetricsIfMissing({ tx, documentId: 'doc_1' }); + + const { data } = (tx.ismsMetric.createMany as jest.Mock).mock.calls[0][0]; + expect(data).toHaveLength(8); + expect( + data.map((row: { metricKey: string }) => row.metricKey), + ).not.toContain('training_completion'); + expect(data[0].position).toBe(6); + }); + + it('is a no-op when every seed already exists (never deletes/overwrites)', async () => { + const tx = makeTx( + SEED_METRIC_DEFINITIONS.map((metric, index) => ({ + metricKey: metric.metricKey, + position: index, + })), + ); + await seedMetricsIfMissing({ tx, documentId: 'doc_1' }); + expect(tx.ismsMetric.createMany).not.toHaveBeenCalled(); + }); +}); + +describe('buildMonitoringSections', () => { + const metric: MetricExportRow = { + metricKey: 'uptime', + name: 'Production availability / uptime', + whatIsMeasured: 'Availability of production services over the period.', + method: 'Cloud monitoring uptime reports.', + cadence: 'Monthly', + monitorName: 'Security & Privacy Owner (SPO)', + analyzeName: 'Jane Doe', + target: '≥ 99.9% availability', + currentValue: '99.95% (July 2026)', + }; + + it('renders the five ticket sections in order', () => { + const sections = buildMonitoringSections({ + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + metrics: [metric], + }); + expect(sections.map((section) => section.heading)).toEqual([ + 'Purpose', + 'Scope of what is measured', + 'Metrics, responsibilities, and cadence', + 'Analysis, evaluation, and reporting', + 'Sign-off', + ]); + }); + + it('renders one metrics-table row per metric with the 9.1(a)-(f) columns', () => { + const sections = buildMonitoringSections({ + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + metrics: [metric], + }); + const table = sections[2].table; + expect(table?.headers).toEqual([ + 'What is measured', + 'Method', + 'Cadence', + 'Who monitors', + 'Who analyses', + 'Target', + 'Current value', + ]); + expect(table?.rows).toEqual([ + [ + 'Availability of production services over the period.', + 'Cloud monitoring uptime reports.', + 'Monthly', + 'Security & Privacy Owner (SPO)', + 'Jane Doe', + '≥ 99.9% availability', + '99.95% (July 2026)', + ], + ]); + }); + + it('shows the empty text when no metrics are provided', () => { + const sections = buildMonitoringSections({ + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + }); + expect(sections[2].emptyText).toBe('No active metrics recorded.'); + expect(sections[2].table?.rows).toEqual([]); + }); + + it('falls back to the metric name and em-dashes for blank fields', () => { + const sections = buildMonitoringSections({ + contextIssues: [], + interestedParties: [], + requirements: [], + objectives: [], + narrative: null, + metrics: [ + { + ...metric, + whatIsMeasured: '', + cadence: null, + target: '', + }, + ], + }); + const row = sections[2].table?.rows[0]; + expect(row?.[0]).toBe('Production availability / uptime'); + expect(row?.[2]).toBe('—'); + expect(row?.[5]).toBe('—'); + }); +}); diff --git a/apps/api/src/isms/documents/monitoring.ts b/apps/api/src/isms/documents/monitoring.ts new file mode 100644 index 0000000000..c5c9f6c199 --- /dev/null +++ b/apps/api/src/isms/documents/monitoring.ts @@ -0,0 +1,167 @@ +import type { Prisma } from '@db'; +import type { IsmsExportSection } from '../utils/export-shared'; +import type { DocumentExportInput } from './types'; +import { SEED_METRIC_DEFINITIONS } from './monitoring-defaults'; + +type Tx = Prisma.TransactionClient; + +/** + * Clause-9.1 completeness check, shared by the submit-for-approval server gate + * and the client Submit button (monitoring-constants.ts mirrors it). The ticket + * requires only: at least one active metric, and a cadence on every active + * metric. Everything else is optional. Returns unmet requirements; empty = ready. + */ +export function metricValidationMessages({ + metrics, +}: { + metrics: Array<{ name: string; cadence: string | null; isActive: boolean }>; +}): string[] { + const active = metrics.filter((metric) => metric.isActive); + if (active.length === 0) { + return ['At least one metric must be active.']; + } + return active + .filter((metric) => !metric.cadence) + .map((metric) => `"${metric.name}" needs a cadence.`); +} + +/** + * Seed the nine default metrics for a Monitoring document, idempotently by + * `metricKey`. Only creates seed metrics that are missing — it NEVER deletes or + * overwrites, so a regenerate can never clobber the customer's edits, + * deactivations, or measurement history (same guarantee as seedRolesIfMissing; + * the destructive derived-row replace would cascade-delete IsmsMeasurement). + * Safe to call at document creation and on every generate. + * + * "Who monitors" / "Who analyses" are deliberately left null: null means + * "defaults to the SPO", resolved to the SPO role holder's name at display and + * export time. Seeding a member id here would be wrong — at provision time the + * SPO role usually has no assignee yet. + */ +export async function seedMetricsIfMissing({ + tx, + documentId, +}: { + tx: Tx; + documentId: string; +}): Promise { + const existing = await tx.ismsMetric.findMany({ + where: { documentId }, + select: { metricKey: true, position: true }, + }); + const existingKeys = new Set( + existing + .map((metric) => metric.metricKey) + .filter((key): key is string => !!key), + ); + const missing = SEED_METRIC_DEFINITIONS.filter( + (metric) => !existingKeys.has(metric.metricKey), + ); + if (missing.length === 0) return; + + const maxPosition = existing.reduce( + (max, metric) => Math.max(max, metric.position), + -1, + ); + + await tx.ismsMetric.createMany({ + data: missing.map((metric, index) => ({ + documentId, + metricKey: metric.metricKey, + name: metric.name, + whatIsMeasured: metric.whatIsMeasured, + method: metric.method, + cadence: metric.cadence, + target: metric.target, + source: 'derived' as const, + derivedFrom: `seed:${metric.metricKey}`, + position: maxPosition + 1 + index, + })), + // Belt-and-braces with @@unique([documentId, metricKey]): a concurrent + // provision/generate racing this seed is absorbed silently. + skipDuplicates: true, + }); +} + +// ---- Export section builder ------------------------------------------------- + +/** + * Build the Monitoring, Measurement, Analysis & Evaluation Framework document + * (clause 9.1). Contents and order follow the CS-723 ticket: Purpose, Scope, + * Metrics table, Analysis and reporting, Sign-off. `metrics` (active only, + * people and current values already resolved) is populated by + * loadMonitoringExtras at export-input assembly (see monitoring-export-data.ts). + * Historical values are deliberately NOT rendered — the document points to the + * platform's per-metric history, exportable to CSV for auditor sampling. + */ +export function buildMonitoringSections( + input: DocumentExportInput, +): IsmsExportSection[] { + const metrics = input.metrics ?? []; + + return [ + { + heading: 'Purpose', + paragraphs: [ + { + text: 'This framework defines how the organisation monitors, measures, analyses, and evaluates the performance and effectiveness of its information security processes, controls, and the Information Security Management System (ISMS) as a whole, in accordance with ISO/IEC 27001:2022, Clause 9.1. For each metric it records what is measured, the method, the cadence, who monitors, and who analyses and evaluates the results.', + }, + ], + }, + { + heading: 'Scope of what is measured', + intro: 'The framework covers, at minimum:', + bullets: [ + 'Effectiveness of the information security controls in the Statement of Applicability (SoA).', + 'Performance of key information security processes (access management, vulnerability management, incident response, vendor management, training, and availability).', + 'Progress against the information security objectives recorded in the Information Security Objectives and Plan.', + 'Overall effectiveness of the ISMS.', + ], + }, + { + heading: 'Metrics, responsibilities, and cadence', + intro: + 'For each metric the framework records what is measured, the method, the cadence, who monitors, who analyses, the target, and the current (most recent) value — as required by Clause 9.1(a)–(f). Measurement history is retained in Comp AI per metric and is exportable to CSV for auditor sampling.', + emptyText: 'No active metrics recorded.', + table: { + headers: [ + 'What is measured', + 'Method', + 'Cadence', + 'Who monitors', + 'Who analyses', + 'Target', + 'Current value', + ], + rows: metrics.map((metric) => [ + metric.whatIsMeasured || metric.name, + metric.method, + metric.cadence ?? '—', + metric.monitorName, + metric.analyzeName, + metric.target || '—', + metric.currentValue, + ]), + }, + }, + { + heading: 'Analysis, evaluation, and reporting', + paragraphs: [ + { + text: 'The Security & Privacy Owner consolidates the recorded measurements into a security performance report and evaluates each metric against its target. Where a metric is off-target, a corrective action is raised with an owner and a due date. The consolidated report is an input to the Management Review.', + }, + { + text: 'Measurement results, analysis, and evaluation are retained as documented information in Comp AI to provide evidence of monitoring and to support audit sampling. Each measurement records the period it covers, the value, who entered it, and an immutable "recorded on" date.', + }, + ], + }, + { + heading: 'Sign-off', + paragraphs: [ + { + text: 'This framework is owned by the Security & Privacy Owner and is reviewed at least annually and when the metric set, objectives, or tooling materially change. The approver and approval date are recorded in the document control table above.', + }, + ], + }, + ]; +} diff --git a/apps/api/src/isms/documents/registry.ts b/apps/api/src/isms/documents/registry.ts index d18b64c8b3..759e8b0e67 100644 --- a/apps/api/src/isms/documents/registry.ts +++ b/apps/api/src/isms/documents/registry.ts @@ -6,6 +6,7 @@ import { buildInterestedPartiesSections } from './interested-parties'; import { buildRequirementsSections } from './requirements'; import { buildObjectivesSections } from './objectives'; import { buildRolesSections } from './roles'; +import { buildMonitoringSections } from './monitoring'; import { buildScopeSections, deriveScopeNarrative, @@ -33,6 +34,7 @@ const EXPORT_SECTION_BUILDERS: Record< interested_parties_requirements: buildRequirementsSections, objectives_plan: buildObjectivesSections, roles_and_responsibilities: buildRolesSections, + monitoring: buildMonitoringSections, isms_scope: buildScopeSections, leadership_commitment: buildLeadershipSections, }; diff --git a/apps/api/src/isms/documents/snapshot.ts b/apps/api/src/isms/documents/snapshot.ts index eb04c6d8a1..e810cae04a 100644 --- a/apps/api/src/isms/documents/snapshot.ts +++ b/apps/api/src/isms/documents/snapshot.ts @@ -90,6 +90,10 @@ const TYPE_DRIFT_SOURCES: Record> = { // consumes is headcount, which sets the team-size band (small vs standard) and // so changes the team-size note + operational-responsibilities rendering. roles_and_responsibilities: ['members'], + // Monitoring (9.1) renders entirely from its own metrics register (seeded + // static defaults + customer edits + measurements); no platform snapshot + // input changes its rendering, so it can never be platform-drift stale. + monitoring: [], isms_scope: [ 'frameworks', 'vendors', diff --git a/apps/api/src/isms/documents/types.ts b/apps/api/src/isms/documents/types.ts index f5c58add49..53e13db03d 100644 --- a/apps/api/src/isms/documents/types.ts +++ b/apps/api/src/isms/documents/types.ts @@ -119,6 +119,33 @@ export interface OperationalOwnershipRow { owners: string[]; } +/** The nine seeded monitoring metrics and their pre-filled default text (9.1). */ +export interface SeedMetricDefinition { + metricKey: string; + name: string; + whatIsMeasured: string; + method: string; + cadence: 'monthly' | 'quarterly'; + target: string; +} + +/** A metric, resolved for export: fields + named people + current value. */ +export interface MetricExportRow { + metricKey: string | null; + name: string; + whatIsMeasured: string; + method: string; + /** Humanized cadence ("Monthly"/"Quarterly") or null when unset. */ + cadence: string | null; + /** Display name of who monitors / analyses (SPO fallback already applied). */ + monitorName: string; + analyzeName: string; + /** Free-text target, or the linked objective's target (frozen at build time). */ + target: string; + /** Most recent value with its period, e.g. "99.95% (July 2026)", or "—". */ + currentValue: string; +} + /** * The organization profile that fills the narrative parts of the Context of the * Organization document (clause 4.1) — overview table, mission, intended @@ -168,4 +195,6 @@ export interface DocumentExportInput { operationalOwnership?: OperationalOwnershipRow[]; /** Team-size band — only populated for the Roles document (5.3). */ band?: IsmsTeamSizeBand; + /** Active metrics with resolved people + values — only populated for the Monitoring document (9.1). */ + metrics?: MetricExportRow[]; } diff --git a/apps/api/src/isms/isms-measurement.service.spec.ts b/apps/api/src/isms/isms-measurement.service.spec.ts new file mode 100644 index 0000000000..fc3f653a75 --- /dev/null +++ b/apps/api/src/isms/isms-measurement.service.spec.ts @@ -0,0 +1,328 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsMeasurementService } from './isms-measurement.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { findFirst: jest.fn() }, + ismsMetric: { + findFirst: jest.fn(), + findMany: jest.fn(), + findUniqueOrThrow: jest.fn(), + }, + ismsMeasurement: { + findFirst: jest.fn(), + create: jest.fn(), + createMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +const activeMetric = { + id: 'met_1', + documentId: 'doc_1', + name: 'Uptime', + cadence: 'monthly', + isActive: true, +}; + +describe('IsmsMeasurementService', () => { + let service: IsmsMeasurementService; + + beforeEach(() => { + jest.clearAllMocks(); + jest.useFakeTimers().setSystemTime(new Date('2026-07-20T12:00:00Z')); + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + service = new IsmsMeasurementService(); + }); + + afterEach(() => jest.useRealTimers()); + + describe('create', () => { + const args = (dto: Record) => ({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: 'mem_1', + dto: dto as never, + }); + + it('records the caller as enteredBy and server-sets source', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue( + activeMetric, + ); + (mockDb.ismsMeasurement.create as jest.Mock).mockResolvedValue({}); + + await service.create( + args({ + metricId: 'met_1', + periodStart: '2026-07-01', + value: ' 99.95% ', + note: '', + }), + ); + + expect(mockDb.ismsMeasurement.create).toHaveBeenCalledWith({ + data: { + metricId: 'met_1', + documentId: 'doc_1', + periodStart: new Date('2026-07-01T00:00:00.000Z'), + value: '99.95%', // trimmed + note: null, // blank note normalized + enteredById: 'mem_1', + source: 'manual', + }, + }); + }); + + it('accepts a historical (backfill) period — recordedAt stays server-set', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue( + activeMetric, + ); + (mockDb.ismsMeasurement.create as jest.Mock).mockResolvedValue({}); + + await service.create( + args({ metricId: 'met_1', periodStart: '2026-02-01', value: '98%' }), + ); + + const data = (mockDb.ismsMeasurement.create as jest.Mock).mock + .calls[0][0].data; + // recordedAt is not client-writable: absent here, the DB default (now()) + // stamps the honest recording date. + expect(data.recordedAt).toBeUndefined(); + }); + + it('rejects a future period', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue( + activeMetric, + ); + await expect( + service.create( + args({ metricId: 'met_1', periodStart: '2026-08-01', value: '1' }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects a period not aligned to the metric cadence', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + ...activeMetric, + cadence: 'quarterly', + }); + await expect( + service.create( + args({ metricId: 'met_1', periodStart: '2026-06-01', value: '1' }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects recording on a metric with no cadence', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + ...activeMetric, + cadence: null, + }); + await expect( + service.create( + args({ metricId: 'met_1', periodStart: '2026-07-01', value: '1' }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects recording on a deactivated metric', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + ...activeMetric, + isActive: false, + }); + await expect( + service.create( + args({ metricId: 'met_1', periodStart: '2026-07-01', value: '1' }), + ), + ).rejects.toThrow(BadRequestException); + }); + + it('stores null enteredBy under API-key auth', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue( + activeMetric, + ); + (mockDb.ismsMeasurement.create as jest.Mock).mockResolvedValue({}); + + await service.create({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: null, + dto: { + metricId: 'met_1', + periodStart: '2026-07-01', + value: '1', + } as never, + }); + + expect( + (mockDb.ismsMeasurement.create as jest.Mock).mock.calls[0][0].data + .enteredById, + ).toBeNull(); + }); + }); + + describe('bulkCreate', () => { + it('creates all rows in one transaction with the caller as enteredBy', async () => { + (mockDb.ismsMetric.findMany as jest.Mock).mockResolvedValue([ + activeMetric, + { ...activeMetric, id: 'met_2', name: 'Vendors', cadence: 'quarterly' }, + ]); + (mockDb.ismsMeasurement.createMany as jest.Mock).mockResolvedValue({ + count: 3, + }); + + const result = await service.bulkCreate({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: 'mem_1', + dto: { + measurements: [ + { metricId: 'met_1', periodStart: '2026-07-01', value: '99.9%' }, + { metricId: 'met_1', periodStart: '2026-06-01', value: '99.8%' }, + { + metricId: 'met_2', + periodStart: '2026-04-01', + value: '100%', + note: 'Q2', + }, + ], + }, + }); + + expect(result).toEqual({ count: 3 }); + const { data } = (mockDb.ismsMeasurement.createMany as jest.Mock).mock + .calls[0][0]; + expect(data).toHaveLength(3); + expect(data[2]).toMatchObject({ + metricId: 'met_2', + enteredById: 'mem_1', + note: 'Q2', + }); + }); + + it('rejects the whole save when any row is invalid (no partial backfill)', async () => { + (mockDb.ismsMetric.findMany as jest.Mock).mockResolvedValue([ + activeMetric, + ]); + + await expect( + service.bulkCreate({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: 'mem_1', + dto: { + measurements: [ + { metricId: 'met_1', periodStart: '2026-07-01', value: 'ok' }, + { metricId: 'met_1', periodStart: '2026-08-01', value: 'future' }, + ], + }, + }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.ismsMeasurement.createMany).not.toHaveBeenCalled(); + }); + + it('rejects a metric that is not in the document', async () => { + (mockDb.ismsMetric.findMany as jest.Mock).mockResolvedValue([]); + await expect( + service.bulkCreate({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: 'mem_1', + dto: { + measurements: [ + { metricId: 'met_x', periodStart: '2026-07-01', value: '1' }, + ], + }, + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('update (corrections)', () => { + it('updates value and note but never recordedAt/enteredById/source', async () => { + (mockDb.ismsMeasurement.findFirst as jest.Mock).mockResolvedValue({ + id: 'msr_1', + metricId: 'met_1', + documentId: 'doc_1', + }); + (mockDb.ismsMeasurement.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + measurementId: 'msr_1', + organizationId: 'org_1', + dto: { value: ' 97% ', note: 'corrected' } as never, + }); + + const { data } = (mockDb.ismsMeasurement.update as jest.Mock).mock + .calls[0][0]; + expect(data).toEqual({ + periodStart: undefined, + value: '97%', + note: 'corrected', + }); + expect(Object.keys(data)).not.toEqual( + expect.arrayContaining(['recordedAt', 'enteredById', 'source']), + ); + }); + + it('validates a changed period against the metric cadence', async () => { + (mockDb.ismsMeasurement.findFirst as jest.Mock).mockResolvedValue({ + id: 'msr_1', + metricId: 'met_1', + documentId: 'doc_1', + }); + (mockDb.ismsMetric.findUniqueOrThrow as jest.Mock).mockResolvedValue({ + ...activeMetric, + cadence: 'quarterly', + }); + + await expect( + service.update({ + measurementId: 'msr_1', + organizationId: 'org_1', + dto: { periodStart: '2026-06-01' } as never, + }), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('remove', () => { + it('deletes a measurement scoped to the organization', async () => { + (mockDb.ismsMeasurement.findFirst as jest.Mock).mockResolvedValue({ + id: 'msr_1', + documentId: 'doc_1', + }); + (mockDb.ismsMeasurement.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove({ + measurementId: 'msr_1', + organizationId: 'org_1', + }); + + expect(result).toEqual({ success: true }); + expect(mockDb.ismsMeasurement.findFirst).toHaveBeenCalledWith({ + where: { + id: 'msr_1', + metric: { document: { organizationId: 'org_1' } }, + }, + }); + }); + + it('throws NotFoundException for a measurement outside the org', async () => { + (mockDb.ismsMeasurement.findFirst as jest.Mock).mockResolvedValue(null); + await expect( + service.remove({ measurementId: 'msr_1', organizationId: 'org_1' }), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/isms/isms-measurement.service.ts b/apps/api/src/isms/isms-measurement.service.ts new file mode 100644 index 0000000000..84b2c11576 --- /dev/null +++ b/apps/api/src/isms/isms-measurement.service.ts @@ -0,0 +1,300 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { IsmsMetric } from '@db'; +import { lockDocument } from './utils/document-lock'; +import { + isAlignedPeriodStart, + periodStartFor, + toPeriodKey, + type MetricCadenceValue, +} from './utils/metric-periods'; +import type { + BulkCreateMeasurementInput, + CreateMeasurementInput, + UpdateMeasurementInput, +} from './registers/register-registry'; + +/** + * Measurement history for the ISMS Monitoring register (clause 9.1, CS-723). + * Measurements are timestamped records, never overwrites: `recordedAt` and + * `enteredById` are server-set here and NEVER client-writable or updatable — + * the guardrail that keeps backfill honest (a late entry for a historical + * period visibly carries today's recording date). + * + * Recording a value deliberately does NOT invalidate document approval: + * measurements are operational records, not framework changes. The published + * version froze the values that were current at publish; monthly entry must + * not revert the approved 9.1 framework document to draft. (Metric FIELD + * edits, which change the framework, do invalidate — see IsmsMetricService.) + */ +@Injectable() +export class IsmsMeasurementService { + async create({ + documentId, + organizationId, + memberId, + dto, + }: { + documentId: string; + organizationId: string; + /** The caller's member id (session auth); null under API-key auth. */ + memberId?: string | null; + dto: CreateMeasurementInput; + }) { + await this.requireDocument({ documentId, organizationId }); + const metric = await this.requireMetricInDocument({ + metricId: dto.metricId, + documentId, + }); + const periodKey = this.validatedPeriodKey({ + metric, + periodStart: dto.periodStart, + }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, documentId); + return tx.ismsMeasurement.create({ + data: this.rowData({ + metricId: metric.id, + documentId, + periodKey, + value: dto.value, + note: dto.note, + memberId, + }), + }); + }); + } + + /** + * One-save entry for the "Metrics due" and backfill views: every row is + * validated up front and created in a single transaction, so a bad row + * rejects the whole save instead of leaving a partial backfill. + */ + async bulkCreate({ + documentId, + organizationId, + memberId, + dto, + }: { + documentId: string; + organizationId: string; + memberId?: string | null; + dto: BulkCreateMeasurementInput; + }) { + await this.requireDocument({ documentId, organizationId }); + + const metricIds = [...new Set(dto.measurements.map((row) => row.metricId))]; + const metrics = await db.ismsMetric.findMany({ + where: { id: { in: metricIds }, documentId }, + }); + const metricsById = new Map(metrics.map((metric) => [metric.id, metric])); + + const rows = dto.measurements.map((row) => { + const metric = metricsById.get(row.metricId); + if (!metric) { + throw new NotFoundException('Metric not found in document'); + } + this.assertMetricActive(metric); + return this.rowData({ + metricId: metric.id, + documentId, + periodKey: this.validatedPeriodKey({ + metric, + periodStart: row.periodStart, + }), + value: row.value, + note: row.note, + memberId, + }); + }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, documentId); + const created = await tx.ismsMeasurement.createMany({ data: rows }); + return { count: created.count }; + }); + } + + /** + * Corrections may edit the value, note, or covered period — never + * `recordedAt`, `enteredById`, or `source`. + */ + async update({ + measurementId, + organizationId, + dto, + }: { + measurementId: string; + organizationId: string; + dto: UpdateMeasurementInput; + }) { + const measurement = await this.requireMeasurement({ + measurementId, + organizationId, + }); + + let periodKey: string | undefined; + if (dto.periodStart !== undefined) { + const metric = await db.ismsMetric.findUniqueOrThrow({ + where: { id: measurement.metricId }, + }); + periodKey = this.validatedPeriodKey({ + metric, + periodStart: dto.periodStart, + }); + } + + return db.$transaction(async (tx) => { + await lockDocument(tx, measurement.documentId); + return tx.ismsMeasurement.update({ + where: { id: measurementId }, + data: { + periodStart: periodKey ? new Date(`${periodKey}T00:00:00.000Z`) : undefined, + value: dto.value === undefined ? undefined : dto.value.trim(), + note: dto.note === undefined ? undefined : dto.note, + }, + }); + }); + } + + async remove({ + measurementId, + organizationId, + }: { + measurementId: string; + organizationId: string; + }) { + const measurement = await this.requireMeasurement({ + measurementId, + organizationId, + }); + await db.$transaction(async (tx) => { + await lockDocument(tx, measurement.documentId); + await tx.ismsMeasurement.delete({ where: { id: measurementId } }); + }); + return { success: true }; + } + + /** Shared create payload: recordedAt/source come from the server, never the client. */ + private rowData({ + metricId, + documentId, + periodKey, + value, + note, + memberId, + }: { + metricId: string; + documentId: string; + periodKey: string; + value: string; + note: string | null | undefined; + memberId: string | null | undefined; + }) { + return { + metricId, + documentId, + periodStart: new Date(`${periodKey}T00:00:00.000Z`), + value: value.trim(), + note: note?.trim() || null, + enteredById: memberId ?? null, + source: 'manual', + }; + } + + private assertMetricActive(metric: IsmsMetric): void { + if (!metric.isActive) { + throw new BadRequestException( + 'This metric is deactivated; reactivate it to record measurements.', + ); + } + } + + /** + * A measurement's period must be a first-of-period date aligned to the + * metric's cadence, and must not be in the future. Requires a cadence — + * without one there is no period grid to validate against. + */ + private validatedPeriodKey({ + metric, + periodStart, + }: { + metric: IsmsMetric; + periodStart: string; + }): string { + if (!metric.cadence) { + throw new BadRequestException( + `Set a cadence on "${metric.name}" before recording measurements.`, + ); + } + const cadence = metric.cadence as MetricCadenceValue; + const key = toPeriodKey(periodStart); + if (!key || !isAlignedPeriodStart(cadence, key)) { + throw new BadRequestException( + `Invalid period for "${metric.name}": expected the first day of a ${ + cadence === 'monthly' ? 'month' : 'quarter' + } (YYYY-MM-DD).`, + ); + } + if (key > periodStartFor(cadence, new Date())) { + throw new BadRequestException( + `Cannot record a future period for "${metric.name}".`, + ); + } + return key; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId, type: 'monitoring' }, + }); + if (!document) { + throw new NotFoundException('ISMS monitoring document not found'); + } + return document; + } + + private async requireMetricInDocument({ + metricId, + documentId, + }: { + metricId: string; + documentId: string; + }) { + const metric = await db.ismsMetric.findFirst({ + where: { id: metricId, documentId }, + }); + if (!metric) { + throw new NotFoundException('Metric not found in document'); + } + this.assertMetricActive(metric); + return metric; + } + + private async requireMeasurement({ + measurementId, + organizationId, + }: { + measurementId: string; + organizationId: string; + }) { + const measurement = await db.ismsMeasurement.findFirst({ + where: { id: measurementId, metric: { document: { organizationId } } }, + }); + if (!measurement) { + throw new NotFoundException('Measurement not found'); + } + return measurement; + } +} diff --git a/apps/api/src/isms/isms-metric.service.spec.ts b/apps/api/src/isms/isms-metric.service.spec.ts new file mode 100644 index 0000000000..b258544ed6 --- /dev/null +++ b/apps/api/src/isms/isms-metric.service.spec.ts @@ -0,0 +1,188 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { db } from '@db'; +import { IsmsMetricService } from './isms-metric.service'; + +jest.mock('@db', () => { + const db = { + ismsDocument: { + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + member: { findFirst: jest.fn() }, + ismsObjective: { findFirst: jest.fn() }, + ismsMetric: { + findFirst: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + $executeRaw: jest.fn(), + $transaction: jest.fn((cb: (tx: unknown) => unknown) => cb(db)), + }; + return { db }; +}); + +const mockDb = jest.mocked(db); + +describe('IsmsMetricService', () => { + let service: IsmsMetricService; + + beforeEach(() => { + jest.clearAllMocks(); + (mockDb.ismsDocument.findUnique as jest.Mock).mockResolvedValue({ + status: 'draft', + }); + service = new IsmsMetricService(); + }); + + describe('create', () => { + const args = { + documentId: 'doc_1', + organizationId: 'org_1', + dto: { name: 'Custom metric', cadence: 'monthly' as const }, + }; + + it('throws NotFoundException when the monitoring document is missing', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue(null); + await expect(service.create(args)).rejects.toThrow(NotFoundException); + expect(mockDb.ismsDocument.findFirst).toHaveBeenCalledWith({ + where: { id: 'doc_1', organizationId: 'org_1', type: 'monitoring' }, + }); + }); + + it('creates a custom (manual, metricKey null, active) metric', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + position: 8, + }); + (mockDb.ismsMetric.create as jest.Mock).mockResolvedValue({ + id: 'met_1', + }); + + await service.create(args); + + expect(mockDb.ismsMetric.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + metricKey: null, + source: 'manual', + isActive: true, + cadence: 'monthly', + position: 9, + }), + }); + }); + + it('rejects a monitor member that is not an active org member', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + ...args, + dto: { ...args.dto, monitorMemberId: 'mem_x' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.member.findFirst).toHaveBeenCalledWith({ + where: { id: 'mem_x', organizationId: 'org_1', deactivated: false }, + }); + }); + + it('rejects an objective link outside the organization', async () => { + (mockDb.ismsDocument.findFirst as jest.Mock).mockResolvedValue({ + id: 'doc_1', + }); + (mockDb.ismsObjective.findFirst as jest.Mock).mockResolvedValue(null); + + await expect( + service.create({ + ...args, + dto: { ...args.dto, objectiveId: 'obj_other_org' }, + }), + ).rejects.toThrow(NotFoundException); + expect(mockDb.ismsObjective.findFirst).toHaveBeenCalledWith({ + where: { id: 'obj_other_org', document: { organizationId: 'org_1' } }, + }); + }); + }); + + describe('update', () => { + it('flips source to manual and supports deactivation', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + id: 'met_1', + documentId: 'doc_1', + source: 'derived', + }); + (mockDb.ismsMetric.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + metricId: 'met_1', + organizationId: 'org_1', + dto: { isActive: false }, + }); + + expect(mockDb.ismsMetric.update).toHaveBeenCalledWith({ + where: { id: 'met_1' }, + data: expect.objectContaining({ isActive: false, source: 'manual' }), + }); + }); + + it('supports clearing people back to the SPO default with null', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + id: 'met_1', + documentId: 'doc_1', + }); + (mockDb.ismsMetric.update as jest.Mock).mockResolvedValue({}); + + await service.update({ + metricId: 'met_1', + organizationId: 'org_1', + dto: { monitorMemberId: null }, + }); + + expect(mockDb.member.findFirst).not.toHaveBeenCalled(); + expect(mockDb.ismsMetric.update).toHaveBeenCalledWith({ + where: { id: 'met_1' }, + data: expect.objectContaining({ monitorMemberId: null }), + }); + }); + }); + + describe('remove', () => { + it('blocks hard-deleting a seeded metric (deactivate instead)', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + id: 'met_1', + documentId: 'doc_1', + metricKey: 'uptime', + }); + + await expect( + service.remove({ metricId: 'met_1', organizationId: 'org_1' }), + ).rejects.toThrow(BadRequestException); + expect(mockDb.ismsMetric.delete).not.toHaveBeenCalled(); + }); + + it('deletes a custom metric', async () => { + (mockDb.ismsMetric.findFirst as jest.Mock).mockResolvedValue({ + id: 'met_1', + documentId: 'doc_1', + metricKey: null, + }); + (mockDb.ismsMetric.delete as jest.Mock).mockResolvedValue({}); + + const result = await service.remove({ + metricId: 'met_1', + organizationId: 'org_1', + }); + + expect(result).toEqual({ success: true }); + expect(mockDb.ismsMetric.delete).toHaveBeenCalledWith({ + where: { id: 'met_1' }, + }); + }); + }); +}); diff --git a/apps/api/src/isms/isms-metric.service.ts b/apps/api/src/isms/isms-metric.service.ts new file mode 100644 index 0000000000..a51df4b119 --- /dev/null +++ b/apps/api/src/isms/isms-metric.service.ts @@ -0,0 +1,232 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db } from '@db'; +import type { Prisma } from '@db'; +import { invalidateApprovalIfNeeded } from './utils/approval'; +import { lockDocument } from './utils/document-lock'; +import type { + CreateMetricInput, + UpdateMetricInput, +} from './registers/register-registry'; + +/** + * CRUD for the ISMS Monitoring metrics register (clause 9.1, CS-723). The nine + * seeded metrics are created idempotently by seedMetricsIfMissing; this service + * handles custom-metric creation and edits to any metric's framework fields. + * Editing flips source to 'manual' (records the override). Seeded metrics + * cannot be hard-deleted — deactivating (isActive=false) is the honest + * "remove": history is kept and the seed never resurrects the row. + */ +@Injectable() +export class IsmsMetricService { + async create({ + documentId, + organizationId, + dto, + }: { + documentId: string; + organizationId: string; + dto: CreateMetricInput; + }) { + await this.requireDocument({ documentId, organizationId }); + const monitorMemberId = await this.resolveMember({ + memberId: dto.monitorMemberId, + organizationId, + }); + const analyzeMemberId = await this.resolveMember({ + memberId: dto.analyzeMemberId, + organizationId, + }); + const objectiveId = await this.resolveObjective({ + objectiveId: dto.objectiveId, + organizationId, + }); + + return db.$transaction(async (tx) => { + await lockDocument(tx, documentId); + const position = + dto.position ?? (await this.nextPosition({ tx, documentId })); + await invalidateApprovalIfNeeded({ tx, documentId }); + return tx.ismsMetric.create({ + data: { + documentId, + metricKey: null, // customer-added custom metric + name: dto.name, + whatIsMeasured: dto.whatIsMeasured ?? '', + method: dto.method ?? '', + cadence: dto.cadence ?? null, + monitorMemberId: monitorMemberId ?? null, + analyzeMemberId: analyzeMemberId ?? null, + target: dto.target ?? null, + objectiveId: objectiveId ?? null, + isActive: dto.isActive ?? true, + source: 'manual', + position, + }, + }); + }); + } + + async update({ + metricId, + organizationId, + dto, + }: { + metricId: string; + organizationId: string; + dto: UpdateMetricInput; + }) { + const metric = await this.requireMetric({ metricId, organizationId }); + const monitorMemberId = await this.resolveMember({ + memberId: dto.monitorMemberId, + organizationId, + }); + const analyzeMemberId = await this.resolveMember({ + memberId: dto.analyzeMemberId, + organizationId, + }); + const objectiveId = await this.resolveObjective({ + objectiveId: dto.objectiveId, + organizationId, + }); + + return db.$transaction(async (tx) => { + // Same per-document lock submit/approve take, so an edit can't race the + // submission's completeness check. + await lockDocument(tx, metric.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: metric.documentId }); + return tx.ismsMetric.update({ + where: { id: metricId }, + data: { + name: dto.name ?? undefined, + whatIsMeasured: dto.whatIsMeasured ?? undefined, + method: dto.method ?? undefined, + cadence: dto.cadence === undefined ? undefined : dto.cadence, + monitorMemberId: + dto.monitorMemberId === undefined ? undefined : monitorMemberId, + analyzeMemberId: + dto.analyzeMemberId === undefined ? undefined : analyzeMemberId, + target: dto.target === undefined ? undefined : dto.target, + objectiveId: dto.objectiveId === undefined ? undefined : objectiveId, + isActive: dto.isActive ?? undefined, + position: dto.position ?? undefined, + source: 'manual', + }, + }); + }); + } + + async remove({ + metricId, + organizationId, + }: { + metricId: string; + organizationId: string; + }) { + const metric = await this.requireMetric({ metricId, organizationId }); + if (metric.metricKey) { + throw new BadRequestException( + 'Seeded metrics cannot be removed; deactivate them instead.', + ); + } + await db.$transaction(async (tx) => { + await lockDocument(tx, metric.documentId); + await invalidateApprovalIfNeeded({ tx, documentId: metric.documentId }); + // Cascades to the metric's measurements. + await tx.ismsMetric.delete({ where: { id: metricId } }); + }); + return { success: true }; + } + + private async resolveMember({ + memberId, + organizationId, + }: { + memberId: string | null | undefined; + organizationId: string; + }): Promise { + if (memberId === undefined) return undefined; + const trimmed = memberId?.trim(); + if (!trimmed) return null; + // Who-monitors / who-analyses must be active People members. + const member = await db.member.findFirst({ + where: { id: trimmed, organizationId, deactivated: false }, + }); + if (!member) { + throw new NotFoundException('Active member not found in organization'); + } + return trimmed; + } + + private async resolveObjective({ + objectiveId, + organizationId, + }: { + objectiveId: string | null | undefined; + organizationId: string; + }): Promise { + if (objectiveId === undefined) return undefined; + const trimmed = objectiveId?.trim(); + if (!trimmed) return null; + // Target links must point at an objective in THIS org's Objectives Plan. + const objective = await db.ismsObjective.findFirst({ + where: { id: trimmed, document: { organizationId } }, + }); + if (!objective) { + throw new NotFoundException('Objective not found in organization'); + } + return trimmed; + } + + private async nextPosition({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const last = await tx.ismsMetric.findFirst({ + where: { documentId }, + orderBy: { position: 'desc' }, + select: { position: true }, + }); + return (last?.position ?? -1) + 1; + } + + private async requireDocument({ + documentId, + organizationId, + }: { + documentId: string; + organizationId: string; + }) { + // Scope to the Monitoring document type: metric rows must never attach to + // another ISMS document (they'd be invisible to the Clause 9.1 export). + const document = await db.ismsDocument.findFirst({ + where: { id: documentId, organizationId, type: 'monitoring' }, + }); + if (!document) { + throw new NotFoundException('ISMS monitoring document not found'); + } + return document; + } + + private async requireMetric({ + metricId, + organizationId, + }: { + metricId: string; + organizationId: string; + }) { + const metric = await db.ismsMetric.findFirst({ + where: { id: metricId, document: { organizationId } }, + }); + if (!metric) { + throw new NotFoundException('Metric not found'); + } + return metric; + } +} diff --git a/apps/api/src/isms/isms-registers.controller.spec.ts b/apps/api/src/isms/isms-registers.controller.spec.ts index 548b2eb604..eed5933b68 100644 --- a/apps/api/src/isms/isms-registers.controller.spec.ts +++ b/apps/api/src/isms/isms-registers.controller.spec.ts @@ -12,6 +12,8 @@ import { IsmsRequirementService } from './isms-requirement.service'; import { IsmsObjectiveService } from './isms-objective.service'; import { IsmsRoleService } from './isms-role.service'; import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; +import { IsmsMetricService } from './isms-metric.service'; +import { IsmsMeasurementService } from './isms-measurement.service'; import { IsmsNarrativeService } from './isms-narrative.service'; jest.mock('../auth/auth.server', () => ({ @@ -46,6 +48,12 @@ jest.mock('./isms-role.service', () => ({ jest.mock('./isms-role-assignment.service', () => ({ IsmsRoleAssignmentService: class {}, })); +jest.mock('./isms-metric.service', () => ({ + IsmsMetricService: class {}, +})); +jest.mock('./isms-measurement.service', () => ({ + IsmsMeasurementService: class {}, +})); jest.mock('./isms-narrative.service', () => ({ IsmsNarrativeService: class {}, })); @@ -86,6 +94,17 @@ describe('IsmsRegistersController', () => { update: jest.fn(), remove: jest.fn(), }; + const metricService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + }; + const measurementService = { + create: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + bulkCreate: jest.fn(), + }; const narrativeService = { save: jest.fn() }; const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; @@ -103,6 +122,8 @@ describe('IsmsRegistersController', () => { { provide: IsmsObjectiveService, useValue: objectiveService }, { provide: IsmsRoleService, useValue: roleService }, { provide: IsmsRoleAssignmentService, useValue: roleAssignmentService }, + { provide: IsmsMetricService, useValue: metricService }, + { provide: IsmsMeasurementService, useValue: measurementService }, { provide: IsmsNarrativeService, useValue: narrativeService }, ], }) @@ -128,6 +149,7 @@ describe('IsmsRegistersController', () => { 'interested-parties', reqWith(dto), 'org_1', + undefined, ); expect(interestedPartyService.create).toHaveBeenCalledWith({ documentId: 'doc_1', @@ -148,6 +170,7 @@ describe('IsmsRegistersController', () => { 'context-issues', reqWith(body), 'org_1', + undefined, ); expect(contextIssueService.create).toHaveBeenCalledWith({ documentId: 'doc_1', @@ -163,6 +186,7 @@ describe('IsmsRegistersController', () => { 'requirements', reqWith(body), 'org_1', + undefined, ); expect(requirementService.create).toHaveBeenCalledWith({ documentId: 'doc_1', @@ -173,7 +197,13 @@ describe('IsmsRegistersController', () => { it('dispatches objectives create with parsed dto', async () => { const body = { objective: 'o' }; - await controller.createRow('doc_1', 'objectives', reqWith(body), 'org_1'); + await controller.createRow( + 'doc_1', + 'objectives', + reqWith(body), + 'org_1', + undefined, + ); expect(objectiveService.create).toHaveBeenCalledWith({ documentId: 'doc_1', organizationId: 'org_1', @@ -181,10 +211,100 @@ describe('IsmsRegistersController', () => { }); }); + it('dispatches metrics create without forwarding the member id', async () => { + const body = { name: 'Custom metric', cadence: 'monthly' }; + await controller.createRow( + 'doc_1', + 'metrics', + reqWith(body), + 'org_1', + 'mem_1', + ); + expect(metricService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + dto: body, + }); + }); + + it('dispatches measurements create with the caller member id as enteredBy', async () => { + const body = { metricId: 'met_1', periodStart: '2026-07-01', value: '5' }; + await controller.createRow( + 'doc_1', + 'measurements', + reqWith(body), + 'org_1', + 'mem_1', + ); + expect(measurementService.create).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: 'mem_1', + dto: body, + }); + }); + + it('passes memberId null under API-key auth (no session member)', async () => { + const body = { metricId: 'met_1', periodStart: '2026-07-01', value: '5' }; + await controller.createRow( + 'doc_1', + 'measurements', + reqWith(body), + 'org_1', + undefined, + ); + expect(measurementService.create).toHaveBeenCalledWith( + expect.objectContaining({ memberId: null }), + ); + }); + it('throws BadRequestException for an unknown register', async () => { await expect( - controller.createRow('doc_1', 'nope', reqWith({}), 'org_1'), + controller.createRow('doc_1', 'nope', reqWith({}), 'org_1', undefined), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('bulkCreateMeasurements', () => { + it('parses the body and dispatches to the measurement service', async () => { + const measurements = [ + { metricId: 'met_1', periodStart: '2026-07-01', value: '99.9%' }, + { metricId: 'met_2', periodStart: '2026-04-01', value: '0', note: 'n' }, + ]; + await controller.bulkCreateMeasurements( + 'doc_1', + reqWith({ measurements }), + 'org_1', + 'mem_1', + ); + expect(measurementService.bulkCreate).toHaveBeenCalledWith({ + documentId: 'doc_1', + organizationId: 'org_1', + memberId: 'mem_1', + dto: { + measurements: [ + { metricId: 'met_1', periodStart: '2026-07-01', value: '99.9%' }, + { + metricId: 'met_2', + periodStart: '2026-04-01', + value: '0', + note: 'n', + }, + ], + }, + }); + }); + + it('rejects an empty measurements array', async () => { + await expect( + controller.bulkCreateMeasurements( + 'doc_1', + reqWith({ measurements: [] }), + 'org_1', + 'mem_1', + ), ).rejects.toBeInstanceOf(BadRequestException); + expect(measurementService.bulkCreate).not.toHaveBeenCalled(); }); }); @@ -273,6 +393,7 @@ describe('IsmsRegistersController', () => { 'createRow', 'updateRow', 'deleteRow', + 'bulkCreateMeasurements', 'saveNarrative', ] as const) { expect(permissionsFor(method)).toEqual([ diff --git a/apps/api/src/isms/isms-registers.controller.ts b/apps/api/src/isms/isms-registers.controller.ts index 957e6da398..e0a3a4e470 100644 --- a/apps/api/src/isms/isms-registers.controller.ts +++ b/apps/api/src/isms/isms-registers.controller.ts @@ -19,7 +19,7 @@ import { ApiSecurity, ApiTags, } from '@nestjs/swagger'; -import { OrganizationId } from '@/auth/auth-context.decorator'; +import { MemberId, OrganizationId } from '@/auth/auth-context.decorator'; import { HybridAuthGuard } from '@/auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; import { RequirePermission } from '../auth/require-permission.decorator'; @@ -29,9 +29,12 @@ import { IsmsRequirementService } from './isms-requirement.service'; import { IsmsObjectiveService } from './isms-objective.service'; import { IsmsRoleService } from './isms-role.service'; import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; +import { IsmsMetricService } from './isms-metric.service'; +import { IsmsMeasurementService } from './isms-measurement.service'; import { IsmsNarrativeService } from './isms-narrative.service'; import { createRegisterRegistry, + parseMeasurementBulkBody, type IsmsRegisterKey, type RegisterHandler, } from './registers/register-registry'; @@ -95,11 +98,52 @@ const REGISTER_ROW_BODY = { gap: { type: 'string', nullable: true }, remediationAction: { type: 'string', nullable: true }, remediationDueDate: { type: 'string', nullable: true }, + // Monitoring register (9.1) metrics + measurements + whatIsMeasured: { type: 'string' }, + method: { type: 'string' }, + monitorMemberId: { type: 'string', nullable: true }, + analyzeMemberId: { type: 'string', nullable: true }, + objectiveId: { type: 'string', nullable: true }, + isActive: { type: 'boolean' }, + metricId: { type: 'string' }, + periodStart: { + type: 'string', + description: + 'First day of the covered period (YYYY-MM-DD), aligned to the metric cadence', + }, + value: { type: 'string' }, + note: { type: 'string', nullable: true }, position: { type: 'integer', minimum: 0 }, }, }, } as const; +const MEASUREMENT_BULK_BODY = { + description: + 'Measurements to record in one save (Metrics due / backfill views)', + schema: { + type: 'object', + properties: { + measurements: { + type: 'array', + minItems: 1, + maxItems: 200, + items: { + type: 'object', + properties: { + metricId: { type: 'string' }, + periodStart: { type: 'string' }, + value: { type: 'string' }, + note: { type: 'string', nullable: true }, + }, + required: ['metricId', 'periodStart', 'value'], + }, + }, + }, + required: ['measurements'], + }, +} as const; + const NARRATIVE_BODY = { description: 'Singleton document narrative payload', schema: { @@ -136,6 +180,8 @@ export class IsmsRegistersController { objectiveService: IsmsObjectiveService, roleService: IsmsRoleService, roleAssignmentService: IsmsRoleAssignmentService, + metricService: IsmsMetricService, + private readonly measurementService: IsmsMeasurementService, private readonly narrativeService: IsmsNarrativeService, ) { this.registry = createRegisterRegistry({ @@ -145,6 +191,8 @@ export class IsmsRegistersController { objectives: objectiveService, roles: roleService, roleAssignments: roleAssignmentService, + metrics: metricService, + measurements: this.measurementService, }); } @@ -169,11 +217,15 @@ export class IsmsRegistersController { // Read req.body directly: the global ValidationPipe mangles nested JSON. @Req() req: Request, @OrganizationId() organizationId: string, + // Session-auth member; undefined under API-key auth. Measurements record + // it as the immutable enteredById. + @MemberId() memberId: string | undefined, ) { return this.resolve(register).create({ documentId: id, organizationId, data: req.body, + memberId: memberId ?? null, }); } @@ -210,6 +262,33 @@ export class IsmsRegistersController { return this.resolve(register).remove({ rowId, organizationId }); } + // --- Monitoring (9.1): one-save bulk measurement entry --- + + @Post('documents/:id/measurements/bulk') + @HttpCode(HttpStatus.CREATED) + @RequirePermission('evidence', 'update') + @ApiOperation({ + summary: + 'Record measurements for several metrics/periods in one save (Metrics due / backfill)', + }) + @ApiConsumes('application/json') + @ApiBody(MEASUREMENT_BULK_BODY) + @ApiOkResponse({ description: 'Measurements recorded' }) + async bulkCreateMeasurements( + @Param('id') id: string, + // Read req.body directly: the global ValidationPipe mangles nested JSON. + @Req() req: Request, + @OrganizationId() organizationId: string, + @MemberId() memberId: string | undefined, + ) { + return this.measurementService.bulkCreate({ + documentId: id, + organizationId, + memberId: memberId ?? null, + dto: parseMeasurementBulkBody(req.body), + }); + } + // --- Singleton narrative (4.3 scope, 5.1 leadership) --- @Post('documents/:id/narrative') diff --git a/apps/api/src/isms/isms-version.service.spec.ts b/apps/api/src/isms/isms-version.service.spec.ts index 8a29625c43..2c25de491a 100644 --- a/apps/api/src/isms/isms-version.service.spec.ts +++ b/apps/api/src/isms/isms-version.service.spec.ts @@ -25,6 +25,7 @@ jest.mock('./utils/export-payload', () => ({ buildExportInput: jest.fn(() => ({ rows: [] })), resolveOrgProfile: jest.fn(), resolveRolesExtras: jest.fn(), + resolveMonitoringExtras: jest.fn(), parseExportSnapshot: jest.fn(() => null), })); jest.mock('./utils/export-metadata', () => ({ diff --git a/apps/api/src/isms/isms-version.service.ts b/apps/api/src/isms/isms-version.service.ts index d22055f92f..6d71c6a2f1 100644 --- a/apps/api/src/isms/isms-version.service.ts +++ b/apps/api/src/isms/isms-version.service.ts @@ -15,6 +15,7 @@ import { buildExportInput, parseExportSnapshot, renderSnapshot, + resolveMonitoringExtras, resolveOrgProfile, resolveRolesExtras, type IsmsExportSnapshot, @@ -71,7 +72,13 @@ export class IsmsVersionService { // and the register rows written in this transaction share one point in time. const orgProfile = await resolveOrgProfile(document, tx); const rolesExtras = await resolveRolesExtras(document, tx); - const input = buildExportInput({ document, orgProfile, rolesExtras }); + const monitoringExtras = await resolveMonitoringExtras(document, tx); + const input = buildExportInput({ + document, + orgProfile, + rolesExtras, + monitoringExtras, + }); const metadata = buildExportMetadata({ type: document.type, title: document.title, diff --git a/apps/api/src/isms/isms.module.ts b/apps/api/src/isms/isms.module.ts index 68cb5e841f..d7df28867b 100644 --- a/apps/api/src/isms/isms.module.ts +++ b/apps/api/src/isms/isms.module.ts @@ -11,6 +11,8 @@ import { IsmsRequirementService } from './isms-requirement.service'; import { IsmsObjectiveService } from './isms-objective.service'; import { IsmsRoleService } from './isms-role.service'; import { IsmsRoleAssignmentService } from './isms-role-assignment.service'; +import { IsmsMetricService } from './isms-metric.service'; +import { IsmsMeasurementService } from './isms-measurement.service'; import { IsmsNarrativeService } from './isms-narrative.service'; import { IsmsProfileController } from './wizard/isms-profile.controller'; import { IsmsProfileService } from './wizard/isms-profile.service'; @@ -36,6 +38,8 @@ import { AttachmentsModule } from '../attachments/attachments.module'; IsmsObjectiveService, IsmsRoleService, IsmsRoleAssignmentService, + IsmsMetricService, + IsmsMeasurementService, IsmsNarrativeService, IsmsProfileService, ], @@ -50,6 +54,8 @@ import { AttachmentsModule } from '../attachments/attachments.module'; IsmsObjectiveService, IsmsRoleService, IsmsRoleAssignmentService, + IsmsMetricService, + IsmsMeasurementService, IsmsNarrativeService, IsmsProfileService, ], diff --git a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts index 8326f12b8c..5ffa16ced4 100644 --- a/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts +++ b/apps/api/src/isms/isms.service.ensure-setup-fallback.spec.ts @@ -72,7 +72,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template const result = await service.ensureSetup(dto); expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); - expect(createManyData()).toHaveLength(6); + expect(createManyData()).toHaveLength(7); // Definition-derived docs carry no templateId. expect(createManyData()[0].templateId).toBeNull(); expect(result.success).toBe(true); @@ -96,7 +96,7 @@ describe('IsmsService ensureSetup fallback to ISMS_TYPE_DEFINITIONS (no template await service.ensureSetup(dto); - expect(createManyData()).toHaveLength(7); + expect(createManyData()).toHaveLength(8); expect(createManyData()[0].requirementId).toBeNull(); }); }); diff --git a/apps/api/src/isms/isms.service.spec.ts b/apps/api/src/isms/isms.service.spec.ts index 7d801200fa..ccb4ed9ebb 100644 --- a/apps/api/src/isms/isms.service.spec.ts +++ b/apps/api/src/isms/isms.service.spec.ts @@ -11,6 +11,7 @@ jest.mock('@db', () => ({ findMany: jest.fn(), createMany: jest.fn(), }, + ismsMetric: { findMany: jest.fn() }, control: { findMany: jest.fn() }, ismsDocumentControlLink: { createMany: jest.fn() }, }, @@ -114,6 +115,79 @@ describe('IsmsService ensureSetup', () => { }); }); + it('reports overdueMetricCount on the monitoring document row (CS-723)', async () => { + const { addPeriods, periodStartFor } = jest.requireActual< + typeof import('./utils/metric-periods') + >('./utils/metric-periods'); + const current = periodStartFor('monthly', new Date()); + + ( + mockDb.frameworkEditorFramework.findUnique as jest.Mock + ).mockResolvedValue({ id: 'fw_1', requirements: [] }); + (mockDb.ismsDocument.findMany as jest.Mock).mockResolvedValueOnce([ + { + id: 'doc_mon', + type: 'monitoring', + status: 'draft', + requirementId: null, + currentVersionId: null, + }, + { + id: 'doc_ctx', + type: 'context_of_organization', + status: 'draft', + requirementId: null, + currentVersionId: null, + }, + ]); + (mockDb.ismsMetric.findMany as jest.Mock).mockResolvedValue([ + { + // Latest measurement three periods back → overdue. + cadence: 'monthly', + createdAt: new Date(`${addPeriods('monthly', current, -6)}T00:00:00Z`), + measurements: [ + { + periodStart: new Date( + `${addPeriods('monthly', current, -3)}T00:00:00Z`, + ), + }, + ], + }, + { + // Previous period recorded → within cadence. + cadence: 'monthly', + createdAt: new Date(`${addPeriods('monthly', current, -6)}T00:00:00Z`), + measurements: [ + { + periodStart: new Date( + `${addPeriods('monthly', current, -1)}T00:00:00Z`, + ), + }, + ], + }, + ]); + + const result = await service.ensureSetup({ ...dto, canWrite: false }); + + const monitoringRow = result.documents.find( + (doc) => doc.type === 'monitoring', + ); + const contextRow = result.documents.find( + (doc) => doc.type === 'context_of_organization', + ); + expect(monitoringRow).toMatchObject({ overdueMetricCount: 1 }); + expect(contextRow).not.toHaveProperty('overdueMetricCount'); + expect(mockDb.ismsMetric.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + documentId: 'doc_mon', + isActive: true, + cadence: { not: null }, + }, + }), + ); + }); + describe('template-driven (templates seeded)', () => { beforeEach(() => { ( @@ -127,7 +201,7 @@ describe('IsmsService ensureSetup', () => { }); }); - it('creates docs from templates with templateId set', async () => { + it('creates docs from templates with templateId set, plus definition fallbacks for untemplated types', async () => { mockTemplates.mockResolvedValue([ { id: 'tpl_ctx', @@ -146,13 +220,19 @@ describe('IsmsService ensureSetup', () => { await service.ensureSetup(dto); expect(mockDb.ismsDocument.createMany).toHaveBeenCalledTimes(1); - expect(createManyData()).toHaveLength(1); + // 1 template-driven + 7 definition fallbacks: a type shipped before its + // template seed re-runs (e.g. monitoring, CS-723) still provisions. + expect(createManyData()).toHaveLength(8); expect(createManyData()[0]).toMatchObject({ type: 'context_of_organization', title: 'Context of the Organization', templateId: 'tpl_ctx', requirementId: 'req_41', // resolved via clause fallback "4.1" }); + const monitoring = createManyData().find( + (doc: { type: string }) => doc.type === 'monitoring', + ); + expect(monitoring).toMatchObject({ templateId: null }); }); it('prefers an explicit framework requirement link over clause match', async () => { @@ -226,8 +306,13 @@ describe('IsmsService ensureSetup', () => { await service.ensureSetup(dto); - expect(createManyData()).toHaveLength(1); + // objectives (template) + 6 definition fallbacks; the existing + // context_of_organization is skipped. + expect(createManyData()).toHaveLength(7); expect(createManyData()[0].type).toBe('objectives_plan'); + expect( + createManyData().map((doc: { type: string }) => doc.type), + ).not.toContain('context_of_organization'); }); it('auto-derives org control links from the template control links', async () => { @@ -310,10 +395,19 @@ describe('IsmsService ensureSetup', () => { controlLinks: [{ controlTemplateId: 'ct_1' }], }, ]); - // Document already exists, so no create and no control derivation runs; - // any manual control links the org added are left untouched. + // Every type already exists, so no create and no control derivation + // runs; any manual control links the org added are left untouched. (mockDb.ismsDocument.findMany as jest.Mock) - .mockResolvedValueOnce([{ type: 'context_of_organization' }]) + .mockResolvedValueOnce([ + { type: 'context_of_organization' }, + { type: 'interested_parties_register' }, + { type: 'interested_parties_requirements' }, + { type: 'isms_scope' }, + { type: 'leadership_commitment' }, + { type: 'roles_and_responsibilities' }, + { type: 'objectives_plan' }, + { type: 'monitoring' }, + ]) .mockResolvedValueOnce([]); await service.ensureSetup(dto); diff --git a/apps/api/src/isms/isms.service.ts b/apps/api/src/isms/isms.service.ts index c1b2110ada..ac6b470d6c 100644 --- a/apps/api/src/isms/isms.service.ts +++ b/apps/api/src/isms/isms.service.ts @@ -11,9 +11,19 @@ import { deriveControlLinks, resolveDocumentPlans } from './utils/ensure-setup-p import { collectPlatformData } from './documents/data-source'; import { runDerivation } from './documents/generate'; import { roleValidationMessages, seedRolesIfMissing } from './documents/roles'; +import { + metricValidationMessages, + seedMetricsIfMissing, +} from './documents/monitoring'; import { updateDraftSnapshot } from './utils/draft-snapshot'; import { EXPORT_DOCUMENT_INCLUDE } from './utils/export-payload'; import { lockDocument } from './utils/document-lock'; +import { + isMetricOverdue, + periodStartFor, + toPeriodKey, + type MetricCadenceValue, +} from './utils/metric-periods'; import { IsmsVersionService } from './isms-version.service'; /** @@ -61,6 +71,11 @@ export class IsmsService { where: { organizationId, frameworkId }, }); + const monitoringDoc = documents.find((doc) => doc.type === 'monitoring'); + const overdueMetricCount = monitoringDoc + ? await this.countOverdueMetrics(monitoringDoc.id) + : 0; + return { success: true, documents: documents.map((doc) => ({ @@ -75,10 +90,48 @@ export class IsmsService { // versioned artifact on their next approval. hasApprovedVersion: doc.currentVersionId != null || doc.status === 'approved', + // The ISMS overview's "Metrics overdue" tile (CS-723); only meaningful + // on the monitoring document row. + ...(doc.type === 'monitoring' ? { overdueMetricCount } : {}), })), }; } + /** + * Metrics whose most recent measurement's period is older than the cadence + * allows (the CS-723 overdue signal — see isMetricOverdue). Powers the ISMS + * overview tile; the Monitoring page derives the same state client-side. + */ + private async countOverdueMetrics(documentId: string): Promise { + const metrics = await db.ismsMetric.findMany({ + where: { documentId, isActive: true, cadence: { not: null } }, + select: { + cadence: true, + createdAt: true, + measurements: { + orderBy: { periodStart: 'desc' }, + take: 1, + select: { periodStart: true }, + }, + }, + }); + const now = new Date(); + return metrics.filter((metric) => { + const cadence = metric.cadence as MetricCadenceValue; + const latest = metric.measurements[0] + ? toPeriodKey(metric.measurements[0].periodStart) + : null; + return isMetricOverdue({ + cadence, + latestMeasured: latest, + // Anchor is only consulted when there are no measurements at all, so + // the creation period is sufficient here. + anchor: periodStartFor(cadence, metric.createdAt), + now, + }); + }).length; + } + /** * Create any missing ISMS documents for the (org, framework), then derive * control links for just the newly-created types so manual links on existing @@ -152,6 +205,15 @@ export class IsmsService { seedRolesIfMissing({ tx, documentId: rolesDoc.id, memberCount }), ); } + + // Same first-load guarantee for Monitoring (9.1): the nine default metrics + // are active from day one. Idempotent by metricKey. + const monitoringDoc = created.find((doc) => doc.type === 'monitoring'); + if (monitoringDoc) { + await db.$transaction((tx) => + seedMetricsIfMissing({ tx, documentId: monitoringDoc.id }), + ); + } } async getDocument({ @@ -177,6 +239,17 @@ export class IsmsService { orderBy: { position: 'asc' }, include: { assignments: { orderBy: { position: 'asc' } } }, }, + metrics: { + orderBy: { position: 'asc' }, + include: { + // Full history, newest first: the Monitoring page renders it and + // derives due/overdue/backfill periods client-side from it. + measurements: { + orderBy: [{ periodStart: 'desc' }, { recordedAt: 'desc' }], + }, + objective: { select: { id: true, objective: true, target: true } }, + }, + }, controlLinks: { select: { id: true, @@ -224,6 +297,10 @@ export class IsmsService { if (document.type === 'roles_and_responsibilities') { await this.assertRolesComplete({ tx, documentId, organizationId }); } + // Clause 9.1: at least one active metric, each with a cadence (CS-723). + if (document.type === 'monitoring') { + await this.assertMonitoringComplete({ tx, documentId }); + } return tx.ismsDocument.update({ where: { id: documentId }, @@ -429,6 +506,25 @@ export class IsmsService { } } + private async assertMonitoringComplete({ + tx, + documentId, + }: { + tx: Prisma.TransactionClient; + documentId: string; + }) { + const metrics = await tx.ismsMetric.findMany({ + where: { documentId }, + select: { name: true, cadence: true, isActive: true }, + }); + const messages = metricValidationMessages({ metrics }); + if (messages.length > 0) { + throw new BadRequestException( + `This Clause 9.1 document is not ready to submit. ${messages.join(' ')}`, + ); + } + } + private async requireDocument({ documentId, organizationId, diff --git a/apps/api/src/isms/registers/register-registry.ts b/apps/api/src/isms/registers/register-registry.ts index 67b0c711bb..b4d8b764f2 100644 --- a/apps/api/src/isms/registers/register-registry.ts +++ b/apps/api/src/isms/registers/register-registry.ts @@ -6,6 +6,8 @@ import type { IsmsObjectiveService } from '../isms-objective.service'; import type { IsmsRequirementService } from '../isms-requirement.service'; import type { IsmsRoleService } from '../isms-role.service'; import type { IsmsRoleAssignmentService } from '../isms-role-assignment.service'; +import type { IsmsMetricService } from '../isms-metric.service'; +import type { IsmsMeasurementService } from '../isms-measurement.service'; /** * One generic dispatch for every ISMS register row (context issues, interested @@ -25,6 +27,7 @@ const COMPETENCE_BASIS = [ 'experience', 'combination', ] as const; +const METRIC_CADENCE = ['monthly', 'quarterly'] as const; const schemas = { contextIssueCreate: z.object({ @@ -134,8 +137,55 @@ const schemas = { remediationDueDate: z.string().nullish(), position, }), + metricCreate: z.object({ + name: z.string().min(1), + whatIsMeasured: z.string().optional(), + method: z.string().optional(), + // Nullish: a custom metric can be drafted without a cadence; the clause-9.1 + // submit gate requires one on every active metric. + cadence: z.enum(METRIC_CADENCE).nullish(), + monitorMemberId: z.string().nullish(), + analyzeMemberId: z.string().nullish(), + target: z.string().nullish(), + objectiveId: z.string().nullish(), + isActive: z.boolean().optional(), + position, + }), + metricUpdate: z.object({ + name: z.string().min(1).optional(), + whatIsMeasured: z.string().optional(), + method: z.string().optional(), + // Nullish: undefined = leave as-is, null = clear. + cadence: z.enum(METRIC_CADENCE).nullish(), + monitorMemberId: z.string().nullish(), + analyzeMemberId: z.string().nullish(), + target: z.string().nullish(), + objectiveId: z.string().nullish(), + isActive: z.boolean().optional(), + position, + }), + measurementCreate: z.object({ + metricId: z.string(), + periodStart: z.string(), // 'YYYY-MM-DD'; cadence alignment checked by the service + value: z.string().trim().min(1), + note: z.string().nullish(), + }), + // recordedAt / enteredById / source are server-set and immutable by design. + measurementUpdate: z.object({ + periodStart: z.string().optional(), + value: z.string().trim().min(1).optional(), + note: z.string().nullish(), + }), } as const; +/** One-save payload for the "Metrics due" / backfill views. */ +export const measurementBulkCreateSchema = z.object({ + measurements: z + .array(schemas.measurementCreate) + .min(1) + .max(200), // a backfill save is bounded by the missing-period cap per metric +}); + // Inferred input types — the single source of truth for register row shapes. // Service method signatures use these directly; the per-register DTO classes were // removed because they only duplicated these schemas. @@ -159,6 +209,13 @@ export type CreateRoleAssignmentInput = z.infer< export type UpdateRoleAssignmentInput = z.infer< typeof schemas.roleAssignmentUpdate >; +export type CreateMetricInput = z.infer; +export type UpdateMetricInput = z.infer; +export type CreateMeasurementInput = z.infer; +export type UpdateMeasurementInput = z.infer; +export type BulkCreateMeasurementInput = z.infer< + typeof measurementBulkCreateSchema +>; export const ISMS_REGISTER_KEYS = [ 'context-issues', @@ -167,6 +224,8 @@ export const ISMS_REGISTER_KEYS = [ 'objectives', 'roles', 'role-assignments', + 'metrics', + 'measurements', ] as const; export type IsmsRegisterKey = (typeof ISMS_REGISTER_KEYS)[number]; @@ -176,6 +235,8 @@ export interface RegisterHandler { documentId: string; organizationId: string; data: unknown; + /** The caller's member id (session auth only). Measurements record it as enteredById. */ + memberId?: string | null; }): Promise; update(args: { rowId: string; @@ -203,6 +264,8 @@ export interface RegisterServices { objectives: IsmsObjectiveService; roles: IsmsRoleService; roleAssignments: IsmsRoleAssignmentService; + metrics: IsmsMetricService; + measurements: IsmsMeasurementService; } /** Build the register → handler map from the injected per-register services. */ @@ -309,5 +372,48 @@ export function createRegisterRegistry( organizationId, }), }, + metrics: { + create: ({ documentId, organizationId, data }) => + services.metrics.create({ + documentId, + organizationId, + dto: parse(schemas.metricCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.metrics.update({ + metricId: rowId, + organizationId, + dto: parse(schemas.metricUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.metrics.remove({ metricId: rowId, organizationId }), + }, + measurements: { + create: ({ documentId, organizationId, data, memberId }) => + services.measurements.create({ + documentId, + organizationId, + memberId, + dto: parse(schemas.measurementCreate, data), + }), + update: ({ rowId, organizationId, data }) => + services.measurements.update({ + measurementId: rowId, + organizationId, + dto: parse(schemas.measurementUpdate, data), + }), + remove: ({ rowId, organizationId }) => + services.measurements.remove({ + measurementId: rowId, + organizationId, + }), + }, }; } + +/** Parse the bulk-measurements body (used by the dedicated bulk endpoint). */ +export function parseMeasurementBulkBody( + data: unknown, +): BulkCreateMeasurementInput { + return parse(measurementBulkCreateSchema, data); +} diff --git a/apps/api/src/isms/utils/document-types.spec.ts b/apps/api/src/isms/utils/document-types.spec.ts index 2e7aff6e9d..64d257afd1 100644 --- a/apps/api/src/isms/utils/document-types.spec.ts +++ b/apps/api/src/isms/utils/document-types.spec.ts @@ -1,8 +1,8 @@ import { ISMS_TYPE_DEFINITIONS, matchRequirementId } from './document-types'; describe('ISMS_TYPE_DEFINITIONS', () => { - it('defines all seven foundational document types with clauses', () => { - expect(ISMS_TYPE_DEFINITIONS).toHaveLength(7); + it('defines all eight foundational document types with clauses', () => { + expect(ISMS_TYPE_DEFINITIONS).toHaveLength(8); const types = ISMS_TYPE_DEFINITIONS.map((d) => d.type); expect(types).toEqual( expect.arrayContaining([ @@ -13,10 +13,18 @@ describe('ISMS_TYPE_DEFINITIONS', () => { 'leadership_commitment', 'roles_and_responsibilities', 'objectives_plan', + 'monitoring', ]), ); }); + it('maps monitoring to clause 9.1', () => { + const monitoring = ISMS_TYPE_DEFINITIONS.find( + (d) => d.type === 'monitoring', + ); + expect(monitoring?.clause).toBe('9.1'); + }); + it('maps 4.2 to both interested-parties documents', () => { const clause42 = ISMS_TYPE_DEFINITIONS.filter((d) => d.clause === '4.2'); expect(clause42.map((d) => d.type)).toEqual([ diff --git a/apps/api/src/isms/utils/document-types.ts b/apps/api/src/isms/utils/document-types.ts index 7fe6c94643..55c69e94a3 100644 --- a/apps/api/src/isms/utils/document-types.ts +++ b/apps/api/src/isms/utils/document-types.ts @@ -66,6 +66,13 @@ export const ISMS_TYPE_DEFINITIONS: IsmsTypeDefinition[] = [ description: 'Measurable information security objectives and the plan to achieve them (ISO 27001 clause 6.2).', }, + { + type: 'monitoring', + clause: '9.1', + title: 'Monitoring, Measurement, Analysis and Evaluation', + description: + 'The metrics the organization monitors — what is measured, how, when, by whom, and who analyses the results (ISO 27001 clause 9.1).', + }, ]; /** diff --git a/apps/api/src/isms/utils/ensure-setup-plan.ts b/apps/api/src/isms/utils/ensure-setup-plan.ts index e79a7385b2..4dd8764042 100644 --- a/apps/api/src/isms/utils/ensure-setup-plan.ts +++ b/apps/api/src/isms/utils/ensure-setup-plan.ts @@ -14,8 +14,10 @@ export interface IsmsDocumentPlan { * Build one create-plan per ISMS document type. Template-driven when the * FrameworkEditorIsmsDocumentTemplate rows are seeded; the requirement comes * from the framework-scoped link if present, otherwise from clause matching. - * Falls back to ISMS_TYPE_DEFINITIONS (no templates) so unseeded DBs still - * work — those plans carry a null templateId and no control links. + * Types with no template row fall back to ISMS_TYPE_DEFINITIONS — so unseeded + * DBs still work, and a newly-shipped type (e.g. monitoring, CS-723) provisions + * even before the template seed has been re-run. Fallback plans carry a null + * templateId and no control links. */ export async function resolveDocumentPlans({ frameworkId, @@ -39,17 +41,7 @@ export async function resolveDocumentPlans({ }, }); - if (templates.length === 0) { - return ISMS_TYPE_DEFINITIONS.map((def) => ({ - type: def.type, - title: def.title, - templateId: null, - controlTemplateIds: [], - requirementId: matchRequirementId({ clause: def.clause, requirements }), - })); - } - - return templates.map((template) => ({ + const templatePlans: IsmsDocumentPlan[] = templates.map((template) => ({ type: template.documentType, title: template.name, templateId: template.id, @@ -60,6 +52,19 @@ export async function resolveDocumentPlans({ template.requirementLinks[0]?.requirementId ?? matchRequirementId({ clause: template.clause, requirements }), })); + + const templatedTypes = new Set(templatePlans.map((plan) => plan.type)); + const fallbackPlans: IsmsDocumentPlan[] = ISMS_TYPE_DEFINITIONS.filter( + (def) => !templatedTypes.has(def.type), + ).map((def) => ({ + type: def.type, + title: def.title, + templateId: null, + controlTemplateIds: [], + requirementId: matchRequirementId({ clause: def.clause, requirements }), + })); + + return [...templatePlans, ...fallbackPlans]; } /** diff --git a/apps/api/src/isms/utils/export-payload.ts b/apps/api/src/isms/utils/export-payload.ts index e534ec8a5a..2e64b032a3 100644 --- a/apps/api/src/isms/utils/export-payload.ts +++ b/apps/api/src/isms/utils/export-payload.ts @@ -7,6 +7,11 @@ import { loadRolesExtras, type RolesExtras, } from '../documents/roles-export-data'; +import { + loadMonitoringExtras, + mapMetrics, + type MonitoringExtras, +} from '../documents/monitoring-export-data'; import type { DocumentExportInput, IsmsOrgProfile, @@ -46,6 +51,18 @@ export const EXPORT_DOCUMENT_INCLUDE = { orderBy: { position: 'asc' }, include: { assignments: { orderBy: { position: 'asc' } } }, }, + metrics: { + orderBy: { position: 'asc' }, + include: { + objective: { select: { objective: true, target: true } }, + // Only the most recent measurement: the document renders the current + // value, never the history (which stays in the platform / CSV export). + measurements: { + orderBy: [{ periodStart: 'desc' }, { recordedAt: 'desc' }], + take: 1, + }, + }, + }, } satisfies Prisma.IsmsDocumentInclude; export type LoadedExportDocument = Prisma.IsmsDocumentGetPayload<{ @@ -85,6 +102,18 @@ export async function resolveRolesExtras( return loadRolesExtras({ organizationId: document.organizationId, client }); } +/** The Monitoring document (9.1) resolves people + the SPO fallback; other types don't. */ +export async function resolveMonitoringExtras( + document: LoadedExportDocument, + client?: Prisma.TransactionClient, +): Promise { + if (document.type !== 'monitoring') return undefined; + return loadMonitoringExtras({ + organizationId: document.organizationId, + client, + }); +} + function formatDateYmd(date: Date | null): string | null { return date ? date.toISOString().slice(0, 10) : null; } @@ -121,10 +150,12 @@ export function buildExportInput({ document, orgProfile, rolesExtras, + monitoringExtras, }: { document: LoadedExportDocument; orgProfile?: IsmsOrgProfile; rolesExtras?: RolesExtras; + monitoringExtras?: MonitoringExtras; }): DocumentExportInput { return { contextIssues: document.contextIssues.map((issue) => ({ @@ -156,6 +187,9 @@ export function buildExportInput({ roles: rolesExtras ? mapRoles(document, rolesExtras) : undefined, operationalOwnership: rolesExtras?.operationalOwnership, band: rolesExtras?.band, + metrics: monitoringExtras + ? mapMetrics(document.metrics, monitoringExtras) + : undefined, }; } @@ -176,7 +210,13 @@ export async function buildDraftSnapshot( ): Promise { const orgProfile = await resolveOrgProfile(document); const rolesExtras = await resolveRolesExtras(document); - const input = buildExportInput({ document, orgProfile, rolesExtras }); + const monitoringExtras = await resolveMonitoringExtras(document); + const input = buildExportInput({ + document, + orgProfile, + rolesExtras, + monitoringExtras, + }); const metadata = buildExportMetadata({ type: document.type, title: document.title, diff --git a/apps/api/src/isms/utils/metric-periods.spec.ts b/apps/api/src/isms/utils/metric-periods.spec.ts new file mode 100644 index 0000000000..b79395f876 --- /dev/null +++ b/apps/api/src/isms/utils/metric-periods.spec.ts @@ -0,0 +1,205 @@ +import { + addPeriods, + anchorPeriod, + isAlignedPeriodStart, + isMetricOverdue, + listMissingPeriods, + periodLabel, + periodStartFor, + toPeriodKey, +} from './metric-periods'; + +describe('toPeriodKey', () => { + it('normalizes Dates and ISO strings to YYYY-MM-DD', () => { + expect(toPeriodKey(new Date('2026-07-15T10:30:00Z'))).toBe('2026-07-15'); + expect(toPeriodKey('2026-07-01')).toBe('2026-07-01'); + expect(toPeriodKey('2026-07-01T00:00:00.000Z')).toBe('2026-07-01'); + }); + + it('rejects garbage and impossible dates', () => { + expect(toPeriodKey('not-a-date')).toBeNull(); + expect(toPeriodKey('2026-02-30')).toBeNull(); + expect(toPeriodKey('2026-13-01')).toBeNull(); + }); +}); + +describe('periodStartFor', () => { + it('returns the first of the month for monthly cadence', () => { + expect(periodStartFor('monthly', new Date('2026-07-20T12:00:00Z'))).toBe( + '2026-07-01', + ); + }); + + it('returns the first of the quarter for quarterly cadence', () => { + expect(periodStartFor('quarterly', new Date('2026-08-20T12:00:00Z'))).toBe( + '2026-07-01', + ); + expect(periodStartFor('quarterly', new Date('2026-03-01T00:00:00Z'))).toBe( + '2026-01-01', + ); + }); +}); + +describe('isAlignedPeriodStart', () => { + it('accepts first-of-month for monthly', () => { + expect(isAlignedPeriodStart('monthly', '2026-07-01')).toBe(true); + expect(isAlignedPeriodStart('monthly', '2026-07-02')).toBe(false); + }); + + it('accepts only quarter starts for quarterly', () => { + expect(isAlignedPeriodStart('quarterly', '2026-07-01')).toBe(true); + expect(isAlignedPeriodStart('quarterly', '2026-08-01')).toBe(false); + }); +}); + +describe('addPeriods and periodLabel', () => { + it('steps by month and quarter, across year boundaries', () => { + expect(addPeriods('monthly', '2026-01-01', -1)).toBe('2025-12-01'); + expect(addPeriods('quarterly', '2026-01-01', -1)).toBe('2025-10-01'); + expect(addPeriods('quarterly', '2026-10-01', 1)).toBe('2027-01-01'); + }); + + it('labels periods for humans', () => { + expect(periodLabel('monthly', '2026-07-01')).toBe('July 2026'); + expect(periodLabel('quarterly', '2026-07-01')).toBe('Q3 2026'); + }); +}); + +describe('listMissingPeriods', () => { + const now = new Date('2026-07-20T12:00:00Z'); + + it('lists every unmeasured period from anchor through the current period, newest first', () => { + expect( + listMissingPeriods({ + cadence: 'monthly', + anchor: '2026-04-01', + measured: new Set(['2026-05-01']), + now, + }), + ).toEqual(['2026-07-01', '2026-06-01', '2026-04-01']); + }); + + it('returns only the current period for a brand-new metric', () => { + expect( + listMissingPeriods({ + cadence: 'monthly', + anchor: '2026-07-01', + measured: new Set(), + now, + }), + ).toEqual(['2026-07-01']); + }); + + it('caps the walk so a years-idle metric stays bounded', () => { + const missing = listMissingPeriods({ + cadence: 'monthly', + anchor: '2010-01-01', + measured: new Set(), + now, + cap: 12, + }); + expect(missing).toHaveLength(12); + expect(missing[0]).toBe('2026-07-01'); // newest kept, oldest dropped + }); +}); + +describe('anchorPeriod', () => { + it('uses the creation period when there are no measurements', () => { + expect( + anchorPeriod({ + cadence: 'monthly', + createdAt: new Date('2026-07-10T00:00:00Z'), + measuredKeys: [], + }), + ).toBe('2026-07-01'); + }); + + it('extends back to the earliest measurement when older than creation', () => { + expect( + anchorPeriod({ + cadence: 'monthly', + createdAt: new Date('2026-07-10T00:00:00Z'), + measuredKeys: ['2026-02-01', '2026-06-01'], + }), + ).toBe('2026-02-01'); + }); +}); + +describe('isMetricOverdue (CS-723 overdue signal)', () => { + const now = new Date('2026-07-20T12:00:00Z'); + + it('is NOT overdue when the previous period is recorded (within cadence)', () => { + expect( + isMetricOverdue({ + cadence: 'monthly', + latestMeasured: '2026-06-01', + anchor: '2026-01-01', + now, + }), + ).toBe(false); + }); + + it('is overdue when the latest measurement is older than the previous period', () => { + expect( + isMetricOverdue({ + cadence: 'monthly', + latestMeasured: '2026-05-01', + anchor: '2026-01-01', + now, + }), + ).toBe(true); + }); + + it('clears when the CURRENT period is entered, even with older gaps (per ticket)', () => { + expect( + isMetricOverdue({ + cadence: 'monthly', + latestMeasured: '2026-07-01', + anchor: '2026-01-01', + now, + }), + ).toBe(false); + }); + + it('is not overdue for a metric created this period with no measurements', () => { + expect( + isMetricOverdue({ + cadence: 'monthly', + latestMeasured: null, + anchor: '2026-07-01', + now, + }), + ).toBe(false); + }); + + it('becomes overdue once a full period passes with no measurement at all', () => { + expect( + isMetricOverdue({ + cadence: 'monthly', + latestMeasured: null, + anchor: '2026-06-01', + now, + }), + ).toBe(true); + }); + + it('respects quarterly cadence', () => { + // Q2 recorded, now in Q3 → fine; Q1 latest → overdue. + expect( + isMetricOverdue({ + cadence: 'quarterly', + latestMeasured: '2026-04-01', + anchor: '2026-01-01', + now, + }), + ).toBe(false); + expect( + isMetricOverdue({ + cadence: 'quarterly', + latestMeasured: '2026-01-01', + anchor: '2025-10-01', + now, + }), + ).toBe(true); + }); +}); diff --git a/apps/api/src/isms/utils/metric-periods.ts b/apps/api/src/isms/utils/metric-periods.ts new file mode 100644 index 0000000000..7477dfd27c --- /dev/null +++ b/apps/api/src/isms/utils/metric-periods.ts @@ -0,0 +1,173 @@ +/** + * Pure period math for the ISMS Monitoring register (clause 9.1, CS-723). + * + * A "period" is the calendar month (monthly cadence) or calendar quarter + * (quarterly cadence) a measurement covers, identified by the period key — + * the 'YYYY-MM-DD' of its first day in UTC. Keys sort chronologically as + * plain strings. + * + * Kept dependency-free and MIRRORED in the app + * (apps/app/.../documents/isms/components/monitoring-periods.ts) so the + * "Metrics due" / backfill views and the server agree on due and overdue — + * same precedent as roleValidationMessages (CS-698). Keep both copies in sync. + */ + +export type MetricCadenceValue = 'monthly' | 'quarterly'; + +const PERIOD_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/; + +/** Months covered by one period of the cadence. */ +function monthsPerPeriod(cadence: MetricCadenceValue): number { + return cadence === 'monthly' ? 1 : 3; +} + +function toUtcParts(key: string): { year: number; month: number } { + return { + year: Number(key.slice(0, 4)), + month: Number(key.slice(5, 7)) - 1, // 0-based + }; +} + +function keyFrom(year: number, month: number): string { + const date = new Date(Date.UTC(year, month, 1)); + return date.toISOString().slice(0, 10); +} + +/** Normalize a Date or ISO string to a 'YYYY-MM-DD' key (UTC). Null if invalid. */ +export function toPeriodKey(value: Date | string): string | null { + if (value instanceof Date) { + if (Number.isNaN(value.getTime())) return null; + return value.toISOString().slice(0, 10); + } + const head = value.slice(0, 10); + if (!PERIOD_KEY_PATTERN.test(head)) return null; + // Round-trip through Date so impossible dates (2026-02-30) are rejected. + const date = new Date(`${head}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) return null; + return date.toISOString().slice(0, 10) === head ? head : null; +} + +/** The first day of the period containing `date`, for the given cadence. */ +export function periodStartFor( + cadence: MetricCadenceValue, + date: Date, +): string { + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const step = monthsPerPeriod(cadence); + return keyFrom(year, month - (month % step)); +} + +/** Whether `value` is a valid, cadence-aligned period key (1st of month/quarter). */ +export function isAlignedPeriodStart( + cadence: MetricCadenceValue, + value: string, +): boolean { + const key = toPeriodKey(value); + if (!key || key.slice(8, 10) !== '01') return false; + const { month } = toUtcParts(key); + return month % monthsPerPeriod(cadence) === 0; +} + +/** Shift a period key by `count` periods (negative = earlier). */ +export function addPeriods( + cadence: MetricCadenceValue, + periodKey: string, + count: number, +): string { + const { year, month } = toUtcParts(periodKey); + return keyFrom(year, month + count * monthsPerPeriod(cadence)); +} + +/** Human label for a period key: "July 2026" (monthly) or "Q3 2026" (quarterly). */ +export function periodLabel( + cadence: MetricCadenceValue, + periodKey: string, +): string { + const { year, month } = toUtcParts(periodKey); + if (cadence === 'quarterly') { + return `Q${Math.floor(month / 3) + 1} ${year}`; + } + const name = new Date(Date.UTC(year, month, 1)).toLocaleString('en-US', { + month: 'long', + timeZone: 'UTC', + }); + return `${name} ${year}`; +} + +/** + * Every unmeasured period from the metric's anchor (the period it was created + * in, or its earliest measurement if that is older) up to and INCLUDING the + * current period. The current period counts as "due"; anything earlier is an + * overdue gap. Newest first, capped at `cap` (oldest dropped) so a years-idle + * metric cannot render an unbounded backfill table. + */ +export function listMissingPeriods({ + cadence, + anchor, + measured, + now, + cap = 60, +}: { + cadence: MetricCadenceValue; + /** Period key the metric's history starts at (see anchorPeriod). */ + anchor: string; + /** Period keys that already have at least one measurement. */ + measured: ReadonlySet; + now: Date; + cap?: number; +}): string[] { + const current = periodStartFor(cadence, now); + const missing: string[] = []; + for (let key = current; key >= anchor; key = addPeriods(cadence, key, -1)) { + if (!measured.has(key)) missing.push(key); + if (missing.length >= cap) break; + } + return missing; +} + +/** The period a metric's history starts at: creation, or its oldest measurement. */ +export function anchorPeriod({ + cadence, + createdAt, + measuredKeys, +}: { + cadence: MetricCadenceValue; + createdAt: Date | string; + measuredKeys: Iterable; +}): string { + const created = periodStartFor( + cadence, + createdAt instanceof Date ? createdAt : new Date(createdAt), + ); + let earliest = created; + for (const key of measuredKeys) { + if (key < earliest) earliest = key; + } + return earliest; +} + +/** + * The overdue signal (visual only, per CS-723): a metric is overdue when its + * MOST RECENT measurement's period is older than the cadence allows — i.e. + * strictly older than the period before the current one. Recording the current + * (or previous) period clears the state; earlier gaps stay visible in history + * but do not keep the metric flagged. + */ +export function isMetricOverdue({ + cadence, + latestMeasured, + anchor, + now, +}: { + cadence: MetricCadenceValue; + /** Period key of the most recent measurement, or null when none exist. */ + latestMeasured: string | null; + /** anchorPeriod(...) — used when the metric has no measurements at all. */ + anchor: string; + now: Date; +}): boolean { + const current = periodStartFor(cadence, now); + if (!latestMeasured) return anchor < current; + return latestMeasured < addPeriods(cadence, current, -1); +} diff --git a/apps/api/src/isms/wizard/isms-profile.service.ts b/apps/api/src/isms/wizard/isms-profile.service.ts index c50d1ffb3c..37ff28db88 100644 --- a/apps/api/src/isms/wizard/isms-profile.service.ts +++ b/apps/api/src/isms/wizard/isms-profile.service.ts @@ -33,6 +33,7 @@ const GENERATION_ORDER: Record = { leadership_commitment: 4, roles_and_responsibilities: 5, objectives_plan: 6, + monitoring: 7, }; const GENERATION_ORDER_DEFAULT = Object.keys(GENERATION_ORDER).length; diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx index a1a226bec0..28a4415e9f 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.test.tsx @@ -111,21 +111,19 @@ vi.mock('next/link', () => ({ })); import { IsmsOverview } from './IsmsOverview'; +import { ISMS_TYPE_META } from '../../isms/isms-types'; describe('IsmsOverview', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('renders all 6 foundational document cards', () => { + it('renders a card for every foundational document type', () => { render(); - expect(screen.getByText(/Context of the Organization/)).toBeInTheDocument(); - expect(screen.getByText(/Interested Parties Register/)).toBeInTheDocument(); - expect(screen.getByText(/Interested Parties Requirements/)).toBeInTheDocument(); - expect(screen.getByText(/ISMS Scope/)).toBeInTheDocument(); - expect(screen.getByText(/Leadership and Commitment/)).toBeInTheDocument(); - expect(screen.getByText(/Information Security Objectives and Plan/)).toBeInTheDocument(); + for (const meta of ISMS_TYPE_META) { + expect(screen.getByText(meta.title)).toBeInTheDocument(); + } }); it('renders the Foundational Documents section heading', () => { @@ -147,13 +145,27 @@ describe('IsmsOverview', () => { expect(contextLink).toBeDefined(); }); - it('links all six foundational documents to their detail pages', () => { + it('links every foundational document to its detail page', () => { render(); - // All six foundational documents are now implemented — none are "Coming soon". + // Every foundational document is implemented — none are "Coming soon". + // Derived from ISMS_TYPE_META so adding a type can't silently break this. expect(screen.queryByText('Coming soon')).not.toBeInTheDocument(); const ismsDetailLinks = screen .getAllByRole('link') .filter((link) => link.getAttribute('href')?.includes('/documents/isms/')); - expect(ismsDetailLinks).toHaveLength(6); + expect(ismsDetailLinks).toHaveLength(ISMS_TYPE_META.length); + }); + + it('links the Monitoring card to its detail page (CS-723)', () => { + render(); + const monitoringLink = screen + .getAllByRole('link') + .find((link) => link.getAttribute('href')?.includes('/documents/isms/monitoring')); + expect(monitoringLink).toBeDefined(); + }); + + it('renders the Metrics overdue tile', () => { + render(); + expect(screen.getByText('Metrics overdue')).toBeInTheDocument(); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx index 18b0c6ec91..c206829ac5 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/components/isms/IsmsOverview.tsx @@ -16,6 +16,7 @@ import { Incomplete, MagicWand, Renew, + Time, WarningAlt, WarningAltFilled, } from '@trycompai/design-system/icons'; @@ -88,6 +89,10 @@ export function IsmsOverview({ organizationId }: { organizationId: string }) { const approved = documents.filter((doc) => doc.status === 'approved').length; const outstanding = total - approved; const needsReview = isContextStale ? 1 : 0; + // Metrics whose latest measurement is older than their cadence allows + // (CS-723) — computed server-side on the monitoring document row. + const metricsOverdue = + documents.find((doc) => doc.type === 'monitoring')?.overdueMetricCount ?? 0; return [ { label: 'Documents', value: total, icon: DocumentMultiple_01 }, { label: 'Approved', value: approved, icon: CheckmarkFilled, tone: 'success' }, @@ -98,6 +103,12 @@ export function IsmsOverview({ organizationId }: { organizationId: string }) { icon: WarningAltFilled, tone: needsReview > 0 ? 'warning' : 'default', }, + { + label: 'Metrics overdue', + value: metricsOverdue, + icon: Time, + tone: metricsOverdue > 0 ? 'warning' : 'default', + }, ]; }, [documents, isContextStale]); @@ -163,7 +174,7 @@ export function IsmsOverview({ organizationId }: { organizationId: string }) {
diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx index 4c11a6be30..e60edfb7c2 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/[type]/page.tsx @@ -10,6 +10,7 @@ import { ContextOfOrganizationClient } from '../components/ContextOfOrganization import { InterestedPartiesClient } from '../components/InterestedPartiesClient'; import type { ApproverOption } from '../components/IsmsApprovalSection'; import { LeadershipClient } from '../components/LeadershipClient'; +import { MonitoringClient } from '../components/MonitoringClient'; import { ObjectivesClient } from '../components/ObjectivesClient'; import { RequirementsClient } from '../components/RequirementsClient'; import { RolesClient } from '../components/RolesClient'; @@ -43,6 +44,7 @@ const ISMS_DETAIL_CLIENTS: Record< interested_parties_requirements: RequirementsClient, objectives_plan: ObjectivesClient, roles_and_responsibilities: RolesClient, + monitoring: MonitoringClient, isms_scope: ScopeClient, leadership_commitment: LeadershipClient, }; diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx index 83402dd4d9..a23ec5d881 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/ContextOfOrganizationClient.test.tsx @@ -110,6 +110,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedPartyRequirements: [], objectives: [], roles: [], + metrics: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx index 245a2d6738..42cb621f49 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/InterestedPartiesClient.test.tsx @@ -133,6 +133,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedPartyRequirements: [], objectives: [], roles: [], + metrics: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx index addc53c6ad..1c215583f5 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsApprovalSection.test.tsx @@ -105,6 +105,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedPartyRequirements: [], objectives: [], roles: [], + metrics: [], controlLinks: [], draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx index c4483f018f..43cfa18d84 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/IsmsControlMappings.test.tsx @@ -192,6 +192,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedPartyRequirements: [], objectives: [], roles: [], + metrics: [], controlLinks: LINKS, draftNarrative: null, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx index e55d19fba5..d550f098e9 100644 --- a/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/LeadershipClient.test.tsx @@ -173,6 +173,7 @@ function makeDocument(overrides: Partial = {}): IsmsDocument { interestedPartyRequirements: [], objectives: [], roles: [], + metrics: [], controlLinks: [], draftNarrative: NARRATIVE, currentVersionId: null, diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/MeasurementHistory.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MeasurementHistory.tsx new file mode 100644 index 0000000000..d4b0478b4d --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MeasurementHistory.tsx @@ -0,0 +1,193 @@ +'use client'; + +import { + Button, + HStack, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Download, TrashCan } from '@trycompai/design-system/icons'; +import { useState } from 'react'; +import type { IsmsMeasurement, IsmsMetric } from '../isms-types'; +import { + buildMeasurementsCsv, + downloadCsv, + measurementsCsvFilename, +} from './monitoring-csv'; +import { addPeriods, periodLabel, toPeriodKey } from './monitoring-periods'; +import { RecordMeasurementForm, type RecordMeasurementValues } from './RecordMeasurementForm'; + +interface MeasurementHistoryProps { + metric: IsmsMetric; + canEdit: boolean; + /** memberId → display name, for the "Entered by" column. */ + memberNames: Record; + onRecord: (values: RecordMeasurementValues) => Promise; + onDeleteMeasurement: (measurementId: string) => Promise; +} + +type HistoryRow = + | { kind: 'measurement'; measurement: IsmsMeasurement; periodText: string } + | { kind: 'gap'; key: string; periodText: string }; + +/** + * Interleave gap markers between measured periods so historical gaps stay + * visible in the history even after the overdue state clears (per CS-723). + * Measurements arrive newest first; gaps are only marked BETWEEN measured + * periods (periods ahead/behind the record range belong to the due view). + */ +function buildHistoryRows(metric: IsmsMetric): HistoryRow[] { + const rows: HistoryRow[] = []; + const cadence = metric.cadence; + let previousKey: string | null = null; + + for (const measurement of metric.measurements) { + const key = toPeriodKey(measurement.periodStart); + if (key && cadence && previousKey) { + // Walk down from the period below the previous row to this row's period. + for ( + let gap = addPeriods(cadence, previousKey, -1); + gap > key; + gap = addPeriods(cadence, gap, -1) + ) { + rows.push({ + kind: 'gap', + key: `gap:${gap}`, + periodText: periodLabel(cadence, gap), + }); + } + } + rows.push({ + kind: 'measurement', + measurement, + periodText: + key && cadence ? periodLabel(cadence, key) : (key ?? measurement.periodStart), + }); + if (key && key !== previousKey) previousKey = key; + } + return rows; +} + +function formatDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toISOString().slice(0, 10); +} + +export function MeasurementHistory({ + metric, + canEdit, + memberNames, + onRecord, + onDeleteMeasurement, +}: MeasurementHistoryProps) { + const [deletingId, setDeletingId] = useState(null); + const rows = buildHistoryRows(metric); + + const handleDelete = async (measurementId: string) => { + setDeletingId(measurementId); + try { + await onDeleteMeasurement(measurementId); + } catch { + // Error already toasted by the caller. + } finally { + setDeletingId(null); + } + }; + + return ( + + + + Every measurement is a timestamped record — the recorded-on date is + set by the platform and cannot be edited. + + + + + {canEdit && metric.isActive && metric.cadence ? ( + + ) : null} + + {rows.length === 0 ? ( + + No measurements recorded yet. + + ) : ( +
+ + + + Period + Value + Recorded on + Entered by + Note + {canEdit ? : null} + + + + {rows.map((row) => + row.kind === 'gap' ? ( + + {row.periodText} + + + No measurement recorded for this period + + + + ) : ( + + {row.periodText} + {row.measurement.value} + {formatDate(row.measurement.recordedAt)} + + {row.measurement.enteredById + ? (memberNames[row.measurement.enteredById] ?? 'Former member') + : '—'} + + {row.measurement.note ?? '—'} + {canEdit ? ( + +
+
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/documents/isms/components/MetricFields.tsx b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MetricFields.tsx new file mode 100644 index 0000000000..0c63c449f2 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/documents/isms/components/MetricFields.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { + Field, + FieldError, + Grid, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Stack, + Textarea, +} from '@trycompai/design-system'; +import { Controller, type Control } from 'react-hook-form'; +import type { ApproverOption } from './IsmsApprovalSection'; +import type { MetricFormValues } from './metric-schema'; +import { + METRIC_CADENCES, + METRIC_CADENCE_LABELS, +} from './monitoring-constants'; +import { IsmsFieldLabel } from './shared'; + +interface MetricFieldsProps { + control: Control; + memberOptions: ApproverOption[]; + /** Custom metrics may rename themselves; seeded metric names stay fixed. */ + showName: boolean; +} + +/** "Who monitors" / "Who analyses" picker; empty = defaults to the SPO. */ +function MemberSelect({ + value, + onChange, + label, + memberOptions, +}: { + value: string; + onChange: (value: string) => void; + label: string; + memberOptions: ApproverOption[]; +}) { + return ( + + ); +} + +/** + * Inline edit fields for a monitoring metric (clause 9.1). Shared by the add + * form (MonitoringForm) and the row editor (MonitoringRow) so both edit the + * same field set with one schema. + */ +export function MetricFields({ control, memberOptions, showName }: MetricFieldsProps) { + return ( + + {showName ? ( + + + ( + <> + + {fieldState.error?.message} + + )} + /> + + + ) : null} + + ( +