From 964cb1bf300a68b9d71a18ab15c134974475f589 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Fri, 17 Apr 2026 16:16:22 -0400 Subject: [PATCH 1/9] feat(integration-platform): expand validation library detection for sanitized inputs check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand the GitHub Sanitized Inputs check to cover more ecosystems and libraries, and tighten the Python matcher so false positives on substrings (e.g. "schema" inside "jsonschema") and comments no longer pass. - JS/TS: zod (existing) + yup, joi, @effect/schema, effect, valibot, ajv, class-validator, io-ts, superstruct, runtypes - Python: pydantic (existing) + marshmallow, cerberus, voluptuous, jsonschema, schematics, typeguard - PHP: new — laravel/framework, respect/validation, symfony/validator, vlucas/valitron (detected via composer.json) - Python matcher rewritten to parse lines, strip comments, and match package names as standalone tokens - Failure message updated to list all supported libraries so customers know what we check for Closes customer complaints surfaced in Slack (Yup, Joi, Marshmallow, Effect Schema, Laravel). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github/checks/sanitized-inputs.ts | 108 +++++++++++++++--- 1 file changed, 94 insertions(+), 14 deletions(-) diff --git a/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts b/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts index 2acdde137..24ae0fb20 100644 --- a/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts +++ b/packages/integration-platform/src/manifests/github/checks/sanitized-inputs.ts @@ -1,9 +1,11 @@ /** * Sanitized Inputs Check * - * Ensures repositories use a modern validation/sanitization library - * (Zod or Pydantic) and have automated static analysis (CodeQL) enabled. - * Supports monorepos by scanning all package.json/requirements.txt files. + * Ensures repositories use a modern validation/sanitization library and have + * automated static analysis (CodeQL) enabled. Supports monorepos by scanning + * all package.json / requirements.txt / pyproject.toml / composer.json files. + * + * Validation-library detection covers JS/TS, Python, and PHP ecosystems. * * Code scanning detection supports: * - GitHub CodeQL default setup @@ -21,10 +23,43 @@ import type { } from '../types'; import { parseRepoBranch, targetReposVariable } from '../variables'; -const JS_VALIDATION_PACKAGES = ['zod']; -const PY_VALIDATION_PACKAGES = ['pydantic']; +const JS_VALIDATION_PACKAGES = [ + 'zod', + 'yup', + 'joi', + '@effect/schema', + 'effect', + 'valibot', + 'ajv', + 'class-validator', + 'io-ts', + 'superstruct', + 'runtypes', +]; + +const PY_VALIDATION_PACKAGES = [ + 'pydantic', + 'marshmallow', + 'cerberus', + 'voluptuous', + 'jsonschema', + 'schematics', + 'typeguard', +]; -const TARGET_FILES = ['package.json', 'requirements.txt', 'pyproject.toml']; +const PHP_VALIDATION_PACKAGES = [ + 'laravel/framework', + 'respect/validation', + 'symfony/validator', + 'vlucas/valitron', +]; + +const TARGET_FILES = [ + 'package.json', + 'requirements.txt', + 'pyproject.toml', + 'composer.json', +]; // Patterns that indicate code scanning is configured in a workflow const CODE_SCANNING_PATTERNS = [ @@ -76,7 +111,7 @@ export const sanitizedInputsCheck: IntegrationCheck = { id: 'sanitized_inputs', name: 'Sanitized Inputs & Code Scanning', description: - 'Verifies repositories use Zod/Pydantic for input validation and have GitHub CodeQL scanning enabled. Scans entire repository including monorepo subdirectories.', + 'Verifies repositories use a supported input-validation library (JS/TS, Python, or PHP) and have GitHub CodeQL scanning enabled. Scans entire repository including monorepo subdirectories.', service: 'code-security', taskMapping: TASK_TEMPLATES.sanitizedInputs, defaultSeverity: 'medium', @@ -151,12 +186,49 @@ export const sanitizedInputsCheck: IntegrationCheck = { return null; }; + const escapeRegex = (s: string): string => + s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const checkPythonFile = (content: string, filePath: string): ValidationMatch | null => { - const lower = content.toLowerCase(); - for (const candidate of PY_VALIDATION_PACKAGES) { - if (lower.includes(candidate)) { - return { library: candidate, file: filePath }; + // Parse line-by-line so we can strip comments and match package names as + // standalone tokens (e.g. don't match "schema" inside "jsonschema" or inside + // a prose comment). + const lines = content.split('\n'); + for (const rawLine of lines) { + const line = rawLine.split('#')[0].toLowerCase(); + if (!line.trim()) continue; + for (const candidate of PY_VALIDATION_PACKAGES) { + const escaped = escapeRegex(candidate.toLowerCase()); + // Match the package name as a standalone token. Leading context: start + // of line or a separator commonly preceding a package name (whitespace, + // quote, bracket, comma, semicolon). Trailing context: end of line or a + // separator that can follow a package name (whitespace, version + // operator, bracket, quote, comma, semicolon). + const pattern = new RegExp( + `(?:^|[\\s"'\\[,;])${escaped}(?:$|[\\s=<>!~\\[\\]"',;])`, + ); + if (pattern.test(line)) { + return { library: candidate, file: filePath }; + } + } + } + return null; + }; + + const checkComposerJson = (content: string, filePath: string): ValidationMatch | null => { + try { + const pkg = JSON.parse(content); + const deps = { + ...(pkg.require || {}), + ...(pkg['require-dev'] || {}), + }; + for (const candidate of PHP_VALIDATION_PACKAGES) { + if (deps[candidate]) { + return { library: candidate, file: filePath }; + } } + } catch { + // Invalid JSON, skip } return null; }; @@ -184,6 +256,9 @@ export const sanitizedInputsCheck: IntegrationCheck = { } else if (fileName === 'requirements.txt' || fileName === 'pyproject.toml') { const match = checkPythonFile(content, entry.path); if (match) matches.push(match); + } else if (fileName === 'composer.json') { + const match = checkComposerJson(content, entry.path); + if (match) matches.push(match); } } @@ -335,15 +410,20 @@ export const sanitizedInputsCheck: IntegrationCheck = { .filter((e) => e.type === 'blob' && TARGET_FILES.includes(getFileName(e.path))) .map((e) => e.path); + const supportedLibraries = [ + ...JS_VALIDATION_PACKAGES, + ...PY_VALIDATION_PACKAGES, + ...PHP_VALIDATION_PACKAGES, + ].join(', '); + ctx.fail({ title: `No input validation library found in ${repo.name}`, - description: - 'Could not detect Zod or Pydantic in any package.json, requirements.txt, or pyproject.toml. Implement input validation and sanitization using one of these libraries.', + description: `Could not detect a supported validation library in any package.json, requirements.txt, pyproject.toml, or composer.json. Checked for: ${supportedLibraries}. If you use a different library, you can mark this task manually with evidence.`, resourceType: 'repository', resourceId: repo.full_name, severity: 'medium', remediation: - 'Add Zod (JavaScript/TypeScript) or Pydantic (Python) to enforce schema validation on inbound data.', + 'Add a supported input-validation library (e.g. Zod/Yup/Joi/Valibot for JS/TS, Pydantic/Marshmallow for Python, Laravel/Respect-Validation for PHP) to enforce schema validation on inbound data.', evidence: { [repo.full_name]: { validation: { From 0d59e8f4f748aaf5fc17e7c9b3a955424a120d5c Mon Sep 17 00:00:00 2001 From: Mariano Fuentes Date: Mon, 20 Apr 2026 16:16:58 -0400 Subject: [PATCH 2/9] fix(devices): flag stale device agents as non-compliant + CSV export (#2612) * feat(utils): add device staleness helpers * feat(api): derive device complianceStatus on read * feat(api): surface stale status in /v1/devices * feat(trigger): daily cron to flag stale devices non-compliant * fix(trigger): surface error detail on flag-stale-devices failure * feat(devices): add client-side CSV export helper * fix(devices): harden CSV export (CRLF, BOM, filename sanitization) Co-Authored-By: Claude Opus 4.7 (1M context) * feat(devices): render stale state and add CSV export button * refactor(devices): hoist orgId to parent, avoid per-row useParams * chore(utils): apply prettier formatting to devices.test.ts Co-Authored-By: Claude Opus 4.7 (1M context) * fix(devices): chart treats stale devices as non-compliant * chore(devices): use DS Button iconLeft prop for CSV export * fix(devices): address cubic P1/P2 review feedback - packages/utils: guard daysSinceCheckIn against NaN so corrupt lastCheckIn strings are treated as stale (P1). - devices-csv: neutralize CSV formula injection (leading =, +, -, @, tab, CR) by prefixing with apostrophe per OWASP (P2). - flag-stale-devices: maxDuration is in seconds, not ms (P2). --------- Co-authored-by: Claude Opus 4.7 (1M context) --- apps/api/package.json | 4 +- apps/api/src/devices/devices.service.ts | 10 +- .../[orgId]/people/[employeeId]/page.tsx | 11 + .../DeviceAgentDevicesList.test.tsx | 126 ++++++++ .../components/DeviceAgentDevicesList.tsx | 117 ++++++-- .../components/DeviceComplianceChart.test.tsx | 20 +- .../components/DeviceComplianceChart.tsx | 5 +- .../people/devices/lib/devices-csv.test.ts | 284 ++++++++++++++++++ .../[orgId]/people/devices/lib/devices-csv.ts | 91 ++++++ .../[orgId]/people/devices/types/index.ts | 6 + .../api/people/agent-devices/route.test.ts | 119 ++++++++ .../src/app/api/people/agent-devices/route.ts | 60 ++-- .../tasks/device/flag-stale-devices.test.ts | 101 +++++++ .../tasks/device/flag-stale-devices.ts | 51 ++++ bun.lock | 1 + packages/utils/src/devices.test.ts | 120 ++++++++ packages/utils/src/devices.ts | 47 +++ 17 files changed, 1111 insertions(+), 62 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts create mode 100644 apps/app/src/app/api/people/agent-devices/route.test.ts create mode 100644 apps/app/src/trigger/tasks/device/flag-stale-devices.test.ts create mode 100644 apps/app/src/trigger/tasks/device/flag-stale-devices.ts create mode 100644 packages/utils/src/devices.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index c55d68cb3..d9548588b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -81,6 +81,7 @@ "@trycompai/db": "workspace:*", "@trycompai/email": "workspace:*", "@trycompai/integration-platform": "workspace:*", + "@trycompai/utils": "workspace:*", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.34.2", "@upstash/vector": "^1.2.2", @@ -167,7 +168,8 @@ "^@trycompai/company$": "/../../../packages/company/src/index.ts", "^@trycompai/db$": "@prisma/client", "^@trycompai/email$": "/../../../packages/email/index.ts", - "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts" + "^@trycompai/integration-platform$": "/../../../packages/integration-platform/src/index.ts", + "^@trycompai/utils/(.*)$": "/../../../packages/utils/src/$1.ts" } }, "license": "UNLICENSED", diff --git a/apps/api/src/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index fcfd5e4ee..cc43686d8 100644 --- a/apps/api/src/devices/devices.service.ts +++ b/apps/api/src/devices/devices.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException, Logger } from '@nestjs/common'; import { db } from '@db'; +import { getDeviceComplianceStatus } from '@trycompai/utils/devices'; import { FleetService } from '../lib/fleet.service'; import { DeviceResponseDto } from './dto/device-responses.dto'; import type { MemberResponseDto } from './dto/member-responses.dto'; @@ -331,7 +332,14 @@ export class DevicesService { dto.updated_at = device.updatedAt.toISOString(); dto.display_name = device.name; dto.display_text = device.name; - dto.status = device.isCompliant ? 'compliant' : 'non-compliant'; + const complianceStatus = getDeviceComplianceStatus({ + isCompliant: device.isCompliant, + lastCheckIn: device.lastCheckIn, + }); + // Keep the existing string shape ('compliant' | 'non-compliant' | 'stale') so + // downstream API consumers see a predictable value. + dto.status = + complianceStatus === 'non_compliant' ? 'non-compliant' : complianceStatus; dto.disk_encryption_enabled = device.diskEncryptionEnabled; dto.source = 'device_agent'; // Default empty values for FleetDM-specific fields diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index 216117633..c011bb9f0 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -11,6 +11,10 @@ import { db } from '@db/server'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; +import { + daysSinceCheckIn, + getDeviceComplianceStatus, +} from '@trycompai/utils/devices'; import type { CheckDetails, DeviceWithChecks } from '../devices/types'; import { Employee } from './components/Employee'; @@ -287,6 +291,11 @@ const getMemberDevice = async ( return null; } + const complianceStatus = getDeviceComplianceStatus({ + isCompliant: device.isCompliant, + lastCheckIn: device.lastCheckIn, + }); + return { id: device.id, name: device.name, @@ -309,5 +318,7 @@ const getMemberDevice = async ( email: device.member.user.email, }, source: 'device_agent' as const, + complianceStatus, + daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), }; }; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx new file mode 100644 index 000000000..dd948905c --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.test.tsx @@ -0,0 +1,126 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import type { DeviceWithChecks } from '../types'; + +vi.mock('next/navigation', () => ({ + useParams: () => ({ orgId: 'org_1' }), +})); + +vi.mock('../lib/devices-csv', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + downloadDevicesCsv: vi.fn(), + }; +}); + +import { DeviceAgentDevicesList } from './DeviceAgentDevicesList'; +import { downloadDevicesCsv } from '../lib/devices-csv'; + +const mockDownload = vi.mocked(downloadDevicesCsv); + +function makeDevice(overrides: Partial = {}): DeviceWithChecks { + return { + id: 'dev_1', + name: 'Mac', + hostname: 'mac', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN1', + hardwareModel: 'MBP', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: new Date().toISOString(), + agentVersion: '1.0.0', + installedAt: new Date().toISOString(), + memberId: 'mem_1', + user: { name: 'Jane', email: 'jane@example.com' }, + source: 'device_agent', + complianceStatus: 'compliant', + daysSinceLastCheckIn: 0, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('DeviceAgentDevicesList', () => { + it('renders "Yes" for a compliant device', () => { + render(); + expect(screen.getByText('Yes')).toBeInTheDocument(); + }); + + it('renders "No" for a non-compliant fresh device', () => { + render( + , + ); + expect(screen.getByText('No')).toBeInTheDocument(); + }); + + it('renders "Stale (Nd)" for a stale device and shows em-dash check badges', () => { + render( + , + ); + expect(screen.getByText('Stale (34d)')).toBeInTheDocument(); + // All four check badges should show em-dash when stale. + const dashBadges = screen.getAllByText('—'); + expect(dashBadges.length).toBe(4); + }); + + it('renders "Stale" with no day count when lastCheckIn is null', () => { + render( + , + ); + expect(screen.getByText('Stale')).toBeInTheDocument(); + }); + + it('shows the Export CSV button when devices are present', () => { + render(); + expect(screen.getByRole('button', { name: /export csv/i })).toBeInTheDocument(); + }); + + it('calls the CSV download with the built rows when clicked', () => { + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /export csv/i })); + expect(mockDownload).toHaveBeenCalledTimes(1); + const [filename, contents] = mockDownload.mock.calls[0]; + expect(filename).toMatch(/^devices-org_1-\d{4}-\d{2}-\d{2}\.csv$/); + expect(contents).toContain('Alpha'); + expect(contents).toContain('Beta'); + expect(contents).toContain('stale'); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx index d78d9f841..0b9c9861b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceAgentDevicesList.tsx @@ -2,6 +2,7 @@ import { Badge, + Button, Empty, EmptyDescription, EmptyHeader, @@ -18,11 +19,16 @@ import { TableRow, Text, } from '@trycompai/design-system'; -import { Search } from '@trycompai/design-system/icons'; +import { Download, Search } from '@trycompai/design-system/icons'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useMemo, useState } from 'react'; import type { DeviceWithChecks } from '../types'; +import { + buildDevicesCsv, + devicesCsvFilename, + downloadDevicesCsv, +} from '../lib/devices-csv'; import { DeviceDetails } from './DeviceDetails'; export interface DeviceAgentDevicesListProps { @@ -62,9 +68,11 @@ function isDeviceOnline(lastCheckIn: string | null): boolean { return diffMs < 2 * 60 * 60 * 1000; } -function UserNameCell({ device }: { device: DeviceWithChecks }) { - const params = useParams(); - const orgId = params?.orgId as string; +function staleLabel(daysSinceLastCheckIn: number | null): string { + return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`; +} + +function UserNameCell({ device, orgId }: { device: DeviceWithChecks; orgId: string }) { const memberId = device.memberId; if (!memberId) { @@ -90,7 +98,52 @@ function UserNameCell({ device }: { device: DeviceWithChecks }) { ); } +function CompliantBadge({ device }: { device: DeviceWithChecks }) { + if (device.complianceStatus === 'stale') { + return ( + + {staleLabel(device.daysSinceLastCheckIn)} + + ); + } + if (device.complianceStatus === 'compliant') { + return Yes; + } + return No; +} + +function CheckBadges({ device }: { device: DeviceWithChecks }) { + if (device.complianceStatus === 'stale') { + return ( +
+ {CHECK_FIELDS.map(({ key, label }) => ( + + — + + ))} +
+ ); + } + return ( +
+ {CHECK_FIELDS.map(({ key, label }) => ( + + {label} + + ))} +
+ ); +} + export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) => { + const { orgId } = useParams<{ orgId: string }>(); const [selectedDevice, setSelectedDevice] = useState(null); const [searchQuery, setSearchQuery] = useState(''); const [page, setPage] = useState(1); @@ -114,6 +167,12 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) return filteredDevices.slice(start, start + perPage); }, [filteredDevices, page, perPage]); + function handleExport() { + const contents = buildDevicesCsv(devices); + const filename = devicesCsvFilename({ orgId }); + downloadDevicesCsv(filename, contents); + } + if (selectedDevice) { return setSelectedDevice(null)} />; } @@ -124,20 +183,25 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) return ( -
- - - - - { - setSearchQuery(e.target.value); - setPage(1); - }} - /> - +
+
+ + + + + { + setSearchQuery(e.target.value); + setPage(1); + }} + /> + +
+
{filteredDevices.length === 0 ? ( @@ -195,7 +259,7 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps)
- +
@@ -209,21 +273,10 @@ export const DeviceAgentDevicesList = ({ devices }: DeviceAgentDevicesListProps) {formatTimeAgo(device.lastCheckIn)} -
- {CHECK_FIELDS.map(({ key, label }) => ( - - {label} - - ))} -
+
- - {device.isCompliant ? 'Yes' : 'No'} - + ))} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx index 9d7ab0851..dd8dd0792 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.test.tsx @@ -48,6 +48,8 @@ function makeAgentDevice(overrides: Partial = {}): DeviceWithC installedAt: new Date().toISOString(), user: { name: 'Test User', email: 'test@example.com' }, source: 'device_agent', + complianceStatus: 'compliant', + daysSinceLastCheckIn: 0, ...overrides, }; } @@ -140,7 +142,7 @@ describe('DeviceComplianceChart', () => { it('counts agent devices correctly — mixed compliance', () => { const devices = [ makeAgentDevice({ isCompliant: true }), - makeAgentDevice({ isCompliant: false }), + makeAgentDevice({ isCompliant: false, complianceStatus: 'non_compliant' }), ]; render(); @@ -148,6 +150,20 @@ describe('DeviceComplianceChart', () => { expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); }); + it('counts stale agent devices as Non-Compliant (chart stays binary)', () => { + const devices = [ + makeAgentDevice({ + isCompliant: true, + complianceStatus: 'stale', + daysSinceLastCheckIn: 30, + }), + ]; + + render(); + expect(screen.getByText('Compliant (0)')).toBeInTheDocument(); + expect(screen.getByText('Non-Compliant (1)')).toBeInTheDocument(); + }); + it('counts fleet devices correctly — all policies pass', () => { const devices = [ makeFleetDevice({ @@ -181,7 +197,7 @@ describe('DeviceComplianceChart', () => { it('combines agent and fleet devices in total count', () => { const agentDevices = [ makeAgentDevice({ isCompliant: true }), - makeAgentDevice({ isCompliant: false }), + makeAgentDevice({ isCompliant: false, complianceStatus: 'non_compliant' }), ]; const fleetDevices = [ makeFleetDevice({ diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx index c9eeb4d0d..e02a3b0d8 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/components/DeviceComplianceChart.tsx @@ -31,9 +31,10 @@ export function DeviceComplianceChart({ fleetDevices, agentDevices }: DeviceComp let compliantCount = 0; let nonCompliantCount = 0; - // Count device-agent devices + // Count device-agent devices. Stale devices (no check-in for >= 7 days) + // count as non-compliant to match the table's three-state rendering. for (const device of agentDevices ?? []) { - if (device.isCompliant) { + if (device.complianceStatus === 'compliant') { compliantCount++; } else { nonCompliantCount++; diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts new file mode 100644 index 000000000..9e3946fb9 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.test.ts @@ -0,0 +1,284 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DeviceWithChecks } from '../types'; +import { + buildDevicesCsv, + DEVICES_CSV_HEADER, + devicesCsvFilename, + downloadDevicesCsv, +} from './devices-csv'; + +function makeDevice(overrides: Partial = {}): DeviceWithChecks { + return { + id: 'dev_1', + name: 'MacBook', + hostname: 'macbook', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN1', + hardwareModel: 'MBP', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: '2026-04-17T12:00:00.000Z', + agentVersion: '1.2.0', + installedAt: '2026-01-01T00:00:00.000Z', + memberId: 'mem_1', + user: { name: 'Jane Doe', email: 'jane@example.com' }, + source: 'device_agent', + complianceStatus: 'compliant', + daysSinceLastCheckIn: 0, + ...overrides, + }; +} + +function stripBom(csv: string): string { + return csv.replace(/^\uFEFF/, ''); +} + +describe('buildDevicesCsv', () => { + it('prepends a UTF-8 BOM so Excel renders non-ASCII correctly', () => { + const csv = buildDevicesCsv([]); + expect(csv.charCodeAt(0)).toBe(0xfeff); + }); + + it('emits the fixed header row with CRLF terminator', () => { + const csv = buildDevicesCsv([]); + expect(stripBom(csv)).toBe(DEVICES_CSV_HEADER + '\r\n'); + }); + + it('renders a compliant device in column order using CRLF line endings', () => { + const csv = buildDevicesCsv([makeDevice()]); + const body = stripBom(csv); + // Trim only the trailing terminator to keep any embedded CRLFs intact. + expect(body.endsWith('\r\n')).toBe(true); + const [header, row] = body.slice(0, -2).split('\r\n'); + expect(header).toBe(DEVICES_CSV_HEADER); + expect(row).toBe( + [ + 'MacBook', + 'Jane Doe', + 'jane@example.com', + 'macos', + '14.0', + '1.2.0', + '2026-04-17T12:00:00.000Z', + '0', + 'compliant', + 'yes', + 'yes', + 'yes', + 'yes', + ].join(','), + ); + }); + + it('uses "no" for failing checks and stale status when applicable', () => { + const csv = buildDevicesCsv([ + makeDevice({ + complianceStatus: 'stale', + daysSinceLastCheckIn: 51, + diskEncryptionEnabled: false, + antivirusEnabled: false, + }), + ]); + const body = stripBom(csv); + const row = body.slice(0, -2).split('\r\n')[1]; + const cells = row.split(','); + expect(cells).toContain('stale'); + expect(cells[7]).toBe('51'); // days since sync + expect(cells.slice(-4)).toEqual(['no', 'no', 'yes', 'yes']); + }); + + it('represents never-synced devices with empty last-check-in and empty days', () => { + const csv = buildDevicesCsv([ + makeDevice({ + lastCheckIn: null, + daysSinceLastCheckIn: null, + complianceStatus: 'stale', + }), + ]); + const body = stripBom(csv); + const cells = body.slice(0, -2).split('\r\n')[1].split(','); + expect(cells[6]).toBe(''); // last check-in + expect(cells[7]).toBe(''); // days since sync + expect(cells[8]).toBe('stale'); + }); + + it('escapes commas, quotes, and newlines per RFC 4180', () => { + const csv = buildDevicesCsv([ + makeDevice({ + name: 'Device, "quoted"', + user: { name: 'Line1\nLine2', email: 'x@y.com' }, + }), + ]); + const body = stripBom(csv); + // The embedded newline is inside a quoted cell, so split on CRLF (record sep) + // and take everything after the header row. + const rowsPart = body.slice(0, -2).split('\r\n').slice(1).join('\r\n'); + expect(rowsPart.startsWith('"Device, ""quoted"""')).toBe(true); + expect(rowsPart).toContain('"Line1\nLine2"'); + }); + + it('handles multiple rows separated by CRLF', () => { + const csv = buildDevicesCsv([ + makeDevice({ id: 'a', name: 'Alpha' }), + makeDevice({ id: 'b', name: 'Beta', complianceStatus: 'non_compliant' }), + ]); + const body = stripBom(csv); + const lines = body.slice(0, -2).split('\r\n'); + expect(lines).toHaveLength(3); + expect(lines[1].startsWith('Alpha,')).toBe(true); + expect(lines[2].startsWith('Beta,')).toBe(true); + }); + + describe('formula injection neutralization', () => { + function firstCell(csv: string): string { + const body = stripBom(csv); + const row = body.slice(0, -2).split('\r\n')[1]; + return row.split(',')[0]; + } + + it('prefixes cells starting with "=" with an apostrophe', () => { + const csv = buildDevicesCsv([makeDevice({ name: '=CMD(A1)' })]); + expect(firstCell(csv)).toBe("'=CMD(A1)"); + }); + + it('prefixes cells starting with "+" with an apostrophe', () => { + const csv = buildDevicesCsv([makeDevice({ name: '+1234' })]); + expect(firstCell(csv)).toBe("'+1234"); + }); + + it('prefixes cells starting with "-" with an apostrophe', () => { + const csv = buildDevicesCsv([makeDevice({ name: '-5' })]); + expect(firstCell(csv)).toBe("'-5"); + }); + + it('prefixes cells starting with "@" with an apostrophe', () => { + const csv = buildDevicesCsv([makeDevice({ name: '@somewhere' })]); + expect(firstCell(csv)).toBe("'@somewhere"); + }); + + it('prefixes and double-quotes cells with a leading trigger AND an embedded comma', () => { + const csv = buildDevicesCsv([makeDevice({ name: '=EVIL,' })]); + const body = stripBom(csv); + const rowsPart = body.slice(0, -2).split('\r\n').slice(1).join('\r\n'); + expect(rowsPart.startsWith('"\'=EVIL,"')).toBe(true); + }); + }); +}); + +describe('devicesCsvFilename', () => { + const now = new Date('2026-04-20T10:00:00Z'); + + it('uses orgSlug when present', () => { + expect(devicesCsvFilename({ orgSlug: 'acme', orgId: 'org_123', now })).toBe( + 'devices-acme-2026-04-20.csv', + ); + }); + + it('falls back to orgId when slug is empty', () => { + expect(devicesCsvFilename({ orgSlug: '', orgId: 'org_123', now })).toBe( + 'devices-org_123-2026-04-20.csv', + ); + }); + + it('sanitizes special characters from slug', () => { + expect(devicesCsvFilename({ orgSlug: 'acme/test', orgId: 'org_123', now })).toBe( + 'devices-acme-test-2026-04-20.csv', + ); + }); + + it('sanitizes orgId with unsafe characters', () => { + expect(devicesCsvFilename({ orgSlug: '', orgId: 'org/123 evil\\name', now })).toBe( + 'devices-org-123-evil-name-2026-04-20.csv', + ); + }); +}); + +describe('downloadDevicesCsv', () => { + const createdUrl = 'blob:mock-url'; + const createObjectURL = vi.fn<(blob: Blob) => string>(); + const revokeObjectURL = vi.fn<(url: string) => void>(); + const clickSpy = vi.fn<() => void>(); + let appendedNodes: Node[] = []; + let removedNodes: Node[] = []; + + beforeEach(() => { + vi.useFakeTimers(); + createObjectURL.mockReset().mockReturnValue(createdUrl); + revokeObjectURL.mockReset(); + clickSpy.mockReset(); + appendedNodes = []; + removedNodes = []; + + vi.stubGlobal('URL', { + ...URL, + createObjectURL, + revokeObjectURL, + }); + + vi.spyOn(document.body, 'appendChild').mockImplementation((node: T): T => { + appendedNodes.push(node); + return node; + }); + vi.spyOn(document.body, 'removeChild').mockImplementation((node: T): T => { + removedNodes.push(node); + return node; + }); + + // Intercept anchor elements so we can assert on props and mock click(). + const originalCreateElement = document.createElement.bind(document); + vi.spyOn(document, 'createElement').mockImplementation((( + tag: string, + options?: ElementCreationOptions, + ) => { + const el = originalCreateElement(tag, options); + if (tag === 'a') { + (el as HTMLAnchorElement).click = clickSpy; + } + return el; + }) as typeof document.createElement); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('creates an anchor with filename and blob URL, then clicks and cleans up', () => { + downloadDevicesCsv('devices-acme-2026-04-20.csv', 'header\r\nrow\r\n'); + + expect(createObjectURL).toHaveBeenCalledTimes(1); + expect(appendedNodes).toHaveLength(1); + const appendedNode = appendedNodes[0] as HTMLAnchorElement; + expect(appendedNode.tagName).toBe('A'); + expect(appendedNode.download).toBe('devices-acme-2026-04-20.csv'); + expect(appendedNode.href).toContain(createdUrl); + + expect(clickSpy).toHaveBeenCalledTimes(1); + expect(removedNodes).toHaveLength(1); + expect(removedNodes[0]).toBe(appendedNode); + + // revoke should be deferred via setTimeout + expect(revokeObjectURL).not.toHaveBeenCalled(); + vi.runAllTimers(); + expect(revokeObjectURL).toHaveBeenCalledWith(createdUrl); + }); + + it('no-ops when document is undefined (SSR)', () => { + // Simulate SSR by temporarily removing the document global. + const globalWithDoc = globalThis as { document?: Document }; + const originalDocument = globalWithDoc.document; + delete globalWithDoc.document; + try { + expect(() => downloadDevicesCsv('x.csv', 'data')).not.toThrow(); + expect(createObjectURL).not.toHaveBeenCalled(); + } finally { + globalWithDoc.document = originalDocument; + } + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts new file mode 100644 index 000000000..3aa656bf6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/devices/lib/devices-csv.ts @@ -0,0 +1,91 @@ +import type { DeviceWithChecks } from '../types'; + +export const DEVICES_CSV_HEADER = [ + 'Device Name', + 'User Name', + 'Email', + 'Platform', + 'OS Version', + 'Agent Version', + 'Last Check-in (ISO)', + 'Days Since Sync', + 'Status', + 'Disk Encryption', + 'Antivirus', + 'Password Policy', + 'Screen Lock', +].join(','); + +const FORMULA_TRIGGER = /^[=+\-@\t\r]/; + +function escapeCell(value: string | number | null | undefined): string { + if (value === null || value === undefined) return ''; + let str = String(value); + if (FORMULA_TRIGGER.test(str)) { + str = `'${str}`; + } + if (/[",\r\n]/.test(str)) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; +} + +function yesNo(value: boolean): 'yes' | 'no' { + return value ? 'yes' : 'no'; +} + +export function buildDevicesCsv(devices: DeviceWithChecks[]): string { + const rows = devices.map((d) => + [ + escapeCell(d.name), + escapeCell(d.user.name), + escapeCell(d.user.email), + escapeCell(d.platform), + escapeCell(d.osVersion), + escapeCell(d.agentVersion ?? ''), + escapeCell(d.lastCheckIn ?? ''), + escapeCell(d.daysSinceLastCheckIn ?? ''), + escapeCell(d.complianceStatus), + escapeCell(yesNo(d.diskEncryptionEnabled)), + escapeCell(yesNo(d.antivirusEnabled)), + escapeCell(yesNo(d.passwordPolicySet)), + escapeCell(yesNo(d.screenLockEnabled)), + ].join(','), + ); + // RFC 4180: records separated by CRLF; trailing CRLF after the final record. + // Prepend a UTF-8 BOM so Excel correctly detects UTF-8 encoding for non-ASCII data. + return '\uFEFF' + [DEVICES_CSV_HEADER, ...rows].join('\r\n') + '\r\n'; +} + +export function downloadDevicesCsv(filename: string, contents: string): void { + if (typeof document === 'undefined') return; + const blob = new Blob([contents], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + // Defer revoke so the browser can finish dispatching the download first. + setTimeout(() => URL.revokeObjectURL(url), 0); +} + +function sanitizeFilenamePart(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]+/g, '-'); +} + +export function devicesCsvFilename({ + orgSlug, + orgId, + now = new Date(), +}: { + orgSlug?: string; + orgId: string; + now?: Date; +}): string { + const date = now.toISOString().slice(0, 10); + const raw = orgSlug && orgSlug.length > 0 ? orgSlug : orgId; + const safe = sanitizeFilenamePart(raw); + return `devices-${safe}-${date}.csv`; +} diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts index 82ab00976..919b40854 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts +++ b/apps/app/src/app/(app)/[orgId]/people/devices/types/index.ts @@ -1,3 +1,5 @@ +import type { DeviceComplianceStatus } from '@trycompai/utils/devices'; + export type CheckDetailEntry = { method?: string; raw?: string; @@ -33,6 +35,10 @@ export interface DeviceWithChecks { }; /** Indicates which system reported this device */ source: 'device_agent' | 'fleet'; + /** Derived on the server; 'stale' = no check-in for >= 7 days. */ + complianceStatus: DeviceComplianceStatus; + /** Whole days since last check-in, or null when never synced. */ + daysSinceLastCheckIn: number | null; } export interface FleetPolicy { diff --git a/apps/app/src/app/api/people/agent-devices/route.test.ts b/apps/app/src/app/api/people/agent-devices/route.test.ts new file mode 100644 index 000000000..adc808f79 --- /dev/null +++ b/apps/app/src/app/api/people/agent-devices/route.test.ts @@ -0,0 +1,119 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/auth', () => ({ + auth: { + api: { + getSession: vi.fn(), + }, + }, +})); + +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})); + +vi.mock('@db/server', () => ({ + db: { + device: { + findMany: vi.fn(), + }, + }, +})); + +import { auth } from '@/utils/auth'; +import { db } from '@db/server'; +import { GET } from './route'; + +const mockedGetSession = vi.mocked(auth.api.getSession); +const mockedFindMany = vi.mocked( + (db as unknown as { device: { findMany: ReturnType } }).device.findMany, +); + +// Freeze "now" so day math is deterministic. +const FIXED_NOW = new Date('2026-04-17T12:00:00.000Z'); + +beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockedGetSession.mockResolvedValue({ + session: { activeOrganizationId: 'org_1' }, + } as unknown as Awaited>); +}); + +function deviceRow(overrides: Partial> = {}) { + return { + id: 'dev_1', + name: 'Mac', + hostname: 'mac', + platform: 'macos', + osVersion: '14.0', + serialNumber: 'SN1', + hardwareModel: 'MBP', + isCompliant: true, + diskEncryptionEnabled: true, + antivirusEnabled: true, + passwordPolicySet: true, + screenLockEnabled: true, + checkDetails: null, + lastCheckIn: new Date(FIXED_NOW), + agentVersion: '1.0.0', + installedAt: new Date('2026-01-01T00:00:00.000Z'), + memberId: 'mem_1', + member: { user: { name: 'A', email: 'a@example.com' } }, + ...overrides, + }; +} + +describe('GET /api/people/agent-devices', () => { + it('returns 401 when no organization is active', async () => { + mockedGetSession.mockResolvedValue({ session: {} } as never); + const res = await GET(); + expect(res.status).toBe(401); + }); + + it('marks a fresh + isCompliant device as compliant', async () => { + mockedFindMany.mockResolvedValue([deviceRow({ lastCheckIn: new Date(FIXED_NOW) })]); + const res = await GET(); + const body = await res.json(); + expect(body.data[0].complianceStatus).toBe('compliant'); + expect(body.data[0].daysSinceLastCheckIn).toBe(0); + }); + + it('marks a fresh + !isCompliant device as non_compliant', async () => { + mockedFindMany.mockResolvedValue([ + deviceRow({ + isCompliant: false, + diskEncryptionEnabled: false, + lastCheckIn: new Date(FIXED_NOW), + }), + ]); + const res = await GET(); + const body = await res.json(); + expect(body.data[0].complianceStatus).toBe('non_compliant'); + }); + + it('marks a device with lastCheckIn >= 7 days ago as stale', async () => { + const eightDaysAgo = new Date(FIXED_NOW.getTime() - 8 * 24 * 60 * 60 * 1000); + mockedFindMany.mockResolvedValue([deviceRow({ lastCheckIn: eightDaysAgo })]); + const res = await GET(); + const body = await res.json(); + expect(body.data[0].complianceStatus).toBe('stale'); + expect(body.data[0].daysSinceLastCheckIn).toBe(8); + }); + + it('marks a device with null lastCheckIn as stale', async () => { + mockedFindMany.mockResolvedValue([deviceRow({ lastCheckIn: null })]); + const res = await GET(); + const body = await res.json(); + expect(body.data[0].complianceStatus).toBe('stale'); + expect(body.data[0].daysSinceLastCheckIn).toBeNull(); + }); +}); diff --git a/apps/app/src/app/api/people/agent-devices/route.ts b/apps/app/src/app/api/people/agent-devices/route.ts index afb734157..682840fd6 100644 --- a/apps/app/src/app/api/people/agent-devices/route.ts +++ b/apps/app/src/app/api/people/agent-devices/route.ts @@ -2,6 +2,10 @@ import { auth } from '@/utils/auth'; import { db } from '@db/server'; import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; +import { + daysSinceCheckIn, + getDeviceComplianceStatus, +} from '@trycompai/utils/devices'; import type { CheckDetails, DeviceWithChecks } from '@/app/(app)/[orgId]/people/devices/types'; export async function GET() { @@ -30,30 +34,38 @@ export async function GET() { orderBy: { installedAt: 'desc' }, }); - const data: DeviceWithChecks[] = devices.map((device) => ({ - id: device.id, - name: device.name, - hostname: device.hostname, - platform: device.platform as 'macos' | 'windows' | 'linux', - osVersion: device.osVersion, - serialNumber: device.serialNumber, - hardwareModel: device.hardwareModel, - isCompliant: device.isCompliant, - diskEncryptionEnabled: device.diskEncryptionEnabled, - antivirusEnabled: device.antivirusEnabled, - passwordPolicySet: device.passwordPolicySet, - screenLockEnabled: device.screenLockEnabled, - checkDetails: (device.checkDetails as CheckDetails) ?? null, - lastCheckIn: device.lastCheckIn?.toISOString() ?? null, - agentVersion: device.agentVersion, - installedAt: device.installedAt.toISOString(), - memberId: device.memberId, - user: { - name: device.member.user.name, - email: device.member.user.email, - }, - source: 'device_agent' as const, - })); + const data: DeviceWithChecks[] = devices.map((device) => { + const complianceStatus = getDeviceComplianceStatus({ + isCompliant: device.isCompliant, + lastCheckIn: device.lastCheckIn, + }); + return { + id: device.id, + name: device.name, + hostname: device.hostname, + platform: device.platform as 'macos' | 'windows' | 'linux', + osVersion: device.osVersion, + serialNumber: device.serialNumber, + hardwareModel: device.hardwareModel, + isCompliant: device.isCompliant, + diskEncryptionEnabled: device.diskEncryptionEnabled, + antivirusEnabled: device.antivirusEnabled, + passwordPolicySet: device.passwordPolicySet, + screenLockEnabled: device.screenLockEnabled, + checkDetails: (device.checkDetails as CheckDetails) ?? null, + lastCheckIn: device.lastCheckIn?.toISOString() ?? null, + agentVersion: device.agentVersion, + installedAt: device.installedAt.toISOString(), + memberId: device.memberId, + user: { + name: device.member.user.name, + email: device.member.user.email, + }, + source: 'device_agent' as const, + complianceStatus, + daysSinceLastCheckIn: daysSinceCheckIn(device.lastCheckIn), + }; + }); return NextResponse.json({ data }); } diff --git a/apps/app/src/trigger/tasks/device/flag-stale-devices.test.ts b/apps/app/src/trigger/tasks/device/flag-stale-devices.test.ts new file mode 100644 index 000000000..da68c3f66 --- /dev/null +++ b/apps/app/src/trigger/tasks/device/flag-stale-devices.test.ts @@ -0,0 +1,101 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@db/server', () => ({ + db: { + device: { + updateMany: vi.fn(), + }, + }, +})); + +vi.mock('@trigger.dev/sdk', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + schedules: { + task: (config: { run: (payload: unknown) => Promise }) => config, + }, +})); + +import { db } from '@db/server'; +import { flagStaleDevices } from './flag-stale-devices'; + +const FIXED_NOW = new Date('2026-04-17T12:00:00.000Z'); + +const task = flagStaleDevices as unknown as { + run: (payload: unknown) => Promise<{ + success: boolean; + flaggedCount: number; + threshold: Date; + error?: string; + }>; +}; + +const mockUpdateMany = vi.mocked( + (db as unknown as { device: { updateMany: ReturnType } }).device.updateMany, +); + +beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(FIXED_NOW); +}); + +afterAll(() => { + vi.useRealTimers(); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('flagStaleDevices', () => { + it('flips compliant devices older than 7 days to isCompliant = false', async () => { + mockUpdateMany.mockResolvedValue({ count: 3 }); + + const result = await task.run({}); + + expect(mockUpdateMany).toHaveBeenCalledTimes(1); + const call = mockUpdateMany.mock.calls[0][0] as { + where: { + isCompliant: boolean; + OR: Array<{ lastCheckIn: null | { lt: Date } }>; + }; + data: { isCompliant: boolean }; + }; + + expect(call.where.isCompliant).toBe(true); + expect(call.data).toEqual({ isCompliant: false }); + + // Threshold is 7 days before FIXED_NOW. + const expectedThreshold = new Date(FIXED_NOW.getTime() - 7 * 24 * 60 * 60 * 1000); + const ltClause = call.where.OR.find((c) => c.lastCheckIn && 'lt' in c.lastCheckIn); + expect(ltClause).toBeDefined(); + expect((ltClause!.lastCheckIn as { lt: Date }).lt.toISOString()).toBe( + expectedThreshold.toISOString(), + ); + + // Null-lastCheckIn clause is also present. + const nullClause = call.where.OR.find((c) => c.lastCheckIn === null); + expect(nullClause).toBeDefined(); + + expect(result.success).toBe(true); + expect(result.flaggedCount).toBe(3); + }); + + it('reports zero when nothing matches', async () => { + mockUpdateMany.mockResolvedValue({ count: 0 }); + + const result = await task.run({}); + + expect(result.success).toBe(true); + expect(result.flaggedCount).toBe(0); + }); + + it('returns success: false when the update throws', async () => { + mockUpdateMany.mockRejectedValue(new Error('db down')); + + const result = await task.run({}); + + expect(result.success).toBe(false); + expect(result.flaggedCount).toBe(0); + expect(result.error).toBe('db down'); + }); +}); diff --git a/apps/app/src/trigger/tasks/device/flag-stale-devices.ts b/apps/app/src/trigger/tasks/device/flag-stale-devices.ts new file mode 100644 index 000000000..a37b062b3 --- /dev/null +++ b/apps/app/src/trigger/tasks/device/flag-stale-devices.ts @@ -0,0 +1,51 @@ +import { db } from '@db/server'; +import { logger, schedules } from '@trigger.dev/sdk'; +import { STALE_DEVICE_THRESHOLD_DAYS } from '@trycompai/utils/devices'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +export const flagStaleDevices = schedules.task({ + id: 'flag-stale-devices', + cron: '0 6 * * *', // Daily at 06:00 UTC (~01:00 US/Eastern) + maxDuration: 60 * 5, // 5 minutes (trigger.dev expects seconds) + run: async (): Promise<{ + success: boolean; + flaggedCount: number; + threshold: Date; + error?: string; + }> => { + const threshold = new Date( + Date.now() - STALE_DEVICE_THRESHOLD_DAYS * MS_PER_DAY, + ); + + try { + const result = await db.device.updateMany({ + where: { + isCompliant: true, + OR: [{ lastCheckIn: null }, { lastCheckIn: { lt: threshold } }], + }, + data: { isCompliant: false }, + }); + + logger.info( + `Flagged ${result.count} stale device(s) as non-compliant (threshold ${threshold.toISOString()})`, + ); + + return { + success: true, + flaggedCount: result.count, + threshold, + }; + } catch (error) { + logger.error('flag-stale-devices failed', { + error: error instanceof Error ? error.message : String(error), + }); + return { + success: false, + flaggedCount: 0, + threshold, + error: error instanceof Error ? error.message : String(error), + }; + } + }, +}); diff --git a/bun.lock b/bun.lock index f70f726ea..fe358cd8d 100644 --- a/bun.lock +++ b/bun.lock @@ -148,6 +148,7 @@ "@trycompai/db": "workspace:*", "@trycompai/email": "workspace:*", "@trycompai/integration-platform": "workspace:*", + "@trycompai/utils": "workspace:*", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.34.2", "@upstash/vector": "^1.2.2", diff --git a/packages/utils/src/devices.test.ts b/packages/utils/src/devices.test.ts new file mode 100644 index 000000000..3f1316ee8 --- /dev/null +++ b/packages/utils/src/devices.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import { + STALE_DEVICE_THRESHOLD_DAYS, + daysSinceCheckIn, + getDeviceComplianceStatus, + isDeviceStale, +} from './devices'; + +// Fri 2026-04-17 12:00:00 UTC — a fixed "now" for deterministic math. +const FIXED_NOW = new Date('2026-04-17T12:00:00.000Z'); + +const originalDateNow = Date.now; + +beforeEach(() => { + Date.now = () => FIXED_NOW.getTime(); +}); + +afterEach(() => { + Date.now = originalDateNow; +}); + +function daysAgo(days: number): Date { + return new Date(FIXED_NOW.getTime() - days * 24 * 60 * 60 * 1000); +} + +describe('STALE_DEVICE_THRESHOLD_DAYS', () => { + it('is 7 days', () => { + expect(STALE_DEVICE_THRESHOLD_DAYS).toBe(7); + }); +}); + +describe('daysSinceCheckIn', () => { + it('returns null when lastCheckIn is null', () => { + expect(daysSinceCheckIn(null)).toBeNull(); + }); + + it('returns 0 for just now', () => { + expect(daysSinceCheckIn(FIXED_NOW)).toBe(0); + }); + + it('returns whole days (floored) for past timestamps', () => { + expect(daysSinceCheckIn(daysAgo(1))).toBe(1); + expect(daysSinceCheckIn(daysAgo(6))).toBe(6); + expect(daysSinceCheckIn(daysAgo(7))).toBe(7); + expect(daysSinceCheckIn(daysAgo(51))).toBe(51); + }); + + it('accepts ISO strings', () => { + expect(daysSinceCheckIn(daysAgo(3).toISOString())).toBe(3); + }); + + it('returns null for invalid date strings', () => { + expect(daysSinceCheckIn('not-a-date')).toBeNull(); + expect(daysSinceCheckIn('')).toBeNull(); + }); +}); + +describe('isDeviceStale', () => { + it('treats never-synced devices as stale', () => { + expect(isDeviceStale(null)).toBe(true); + }); + + it('is false for a check-in 6 days ago', () => { + expect(isDeviceStale(daysAgo(6))).toBe(false); + }); + + it('is true exactly at the 7-day boundary', () => { + expect(isDeviceStale(daysAgo(7))).toBe(true); + }); + + it('is true for a check-in 51 days ago', () => { + expect(isDeviceStale(daysAgo(51))).toBe(true); + }); + + it('treats invalid date strings as stale', () => { + expect(isDeviceStale('not-a-date')).toBe(true); + }); +}); + +describe('getDeviceComplianceStatus', () => { + it('returns "stale" regardless of isCompliant when never synced', () => { + expect(getDeviceComplianceStatus({ isCompliant: true, lastCheckIn: null })).toBe('stale'); + expect(getDeviceComplianceStatus({ isCompliant: false, lastCheckIn: null })).toBe('stale'); + }); + + it('returns "stale" regardless of isCompliant when beyond threshold', () => { + expect(getDeviceComplianceStatus({ isCompliant: true, lastCheckIn: daysAgo(8) })).toBe('stale'); + expect(getDeviceComplianceStatus({ isCompliant: false, lastCheckIn: daysAgo(51) })).toBe( + 'stale', + ); + }); + + it('returns "compliant" for fresh + isCompliant true', () => { + expect(getDeviceComplianceStatus({ isCompliant: true, lastCheckIn: daysAgo(1) })).toBe( + 'compliant', + ); + }); + + it('returns "non_compliant" for fresh + isCompliant false', () => { + expect(getDeviceComplianceStatus({ isCompliant: false, lastCheckIn: daysAgo(1) })).toBe( + 'non_compliant', + ); + }); + + it('honors the 7-day boundary: day 6 fresh, day 7 stale', () => { + expect(getDeviceComplianceStatus({ isCompliant: true, lastCheckIn: daysAgo(6) })).toBe( + 'compliant', + ); + expect(getDeviceComplianceStatus({ isCompliant: true, lastCheckIn: daysAgo(7) })).toBe('stale'); + }); + + it('returns "stale" for invalid date strings regardless of isCompliant', () => { + expect(getDeviceComplianceStatus({ isCompliant: true, lastCheckIn: 'not-a-date' })).toBe( + 'stale', + ); + expect(getDeviceComplianceStatus({ isCompliant: false, lastCheckIn: 'not-a-date' })).toBe( + 'stale', + ); + }); +}); diff --git a/packages/utils/src/devices.ts b/packages/utils/src/devices.ts index 79a9fd9e7..6d411cec9 100644 --- a/packages/utils/src/devices.ts +++ b/packages/utils/src/devices.ts @@ -33,3 +33,50 @@ export function mergeDeviceLists( return [...priorityDevices, ...uniqueSecondaryDevices]; } + +/** How long without a check-in before a device is considered stale. */ +export const STALE_DEVICE_THRESHOLD_DAYS = 7; + +export type DeviceComplianceStatus = 'compliant' | 'non_compliant' | 'stale'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; + +function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +/** + * Whole days since `lastCheckIn`. Returns null when the device has never + * checked in. Uses `Date.now()` so tests can freeze time. + */ +export function daysSinceCheckIn(lastCheckIn: Date | string | null): number | null { + if (lastCheckIn === null) return null; + const then = toDate(lastCheckIn).getTime(); + if (Number.isNaN(then)) return null; + return Math.floor((Date.now() - then) / MS_PER_DAY); +} + +/** + * True when a device hasn't checked in within STALE_DEVICE_THRESHOLD_DAYS. + * A device that has never synced is always stale. + */ +export function isDeviceStale(lastCheckIn: Date | string | null): boolean { + const days = daysSinceCheckIn(lastCheckIn); + if (days === null) return true; + return days >= STALE_DEVICE_THRESHOLD_DAYS; +} + +/** + * Three-state compliance status. Stale wins over the stored `isCompliant` + * because old check-in data cannot be trusted. + */ +export function getDeviceComplianceStatus({ + isCompliant, + lastCheckIn, +}: { + isCompliant: boolean; + lastCheckIn: Date | string | null; +}): DeviceComplianceStatus { + if (isDeviceStale(lastCheckIn)) return 'stale'; + return isCompliant ? 'compliant' : 'non_compliant'; +} From 26c04d8a8ec92596a583aaca2dc2f97258244603 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 16:18:08 -0400 Subject: [PATCH 3/9] feat: add compliance timeline to overview (feature flagged) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(db): add timeline models and enums for compliance timeline feature Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): add default timeline template definitions as code constants Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): add timeline date recalculation helper with tests Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): add timelines service with CRUD, activation, pause/resume, phase completion Co-Authored-By: Claude Opus 4.6 (1M context) * refactor(api): split timelines service into focused modules under 300 lines each Extract lifecycle, phase editing, template management, and template resolution into separate files to comply with the max 300 lines per file rule. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): add timeline DTOs, controllers, module registration, and Swagger cleanup - DTOs: activate-timeline, update-phase, create-template, create-phase-template, update-template, update-phase-template with class-validator decorators - Customer controller: GET /timelines, GET /timelines/:id, POST /timelines/:id/phases/:phaseId/ready with Slack webhook notification - Admin template controller: full CRUD for timeline templates and their phases - Admin org timelines controller: activate, pause, resume, phase CRUD, complete - TimelinesModule registered in AppModule with all services and controllers - Added @ApiExcludeController() to all 8 existing admin controllers Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): auto-create timeline on framework add and auto-complete phases on 100% tasks Co-Authored-By: Claude Opus 4.6 (1M context) * feat(app): add SWR hooks for timelines and admin timeline management Co-Authored-By: Claude Opus 4.6 (1M context) * feat(app): add reusable TimelinePhaseBar component Co-Authored-By: Claude Opus 4.6 (1M context) * fix: add generated prisma to gitignore and add completedBy include to findOne * feat(app): add TimelineOverview component to Overview page Add a compliance timeline section above the existing dashboard grid, showing stacked timeline cards with phase bars, status badges, and date summaries. Also updates the Timeline hook types to match the actual API response (DRAFT/ACTIVE/PAUSED/COMPLETED statuses). Co-Authored-By: Claude Opus 4.6 (1M context) * feat(app): add expanded timeline view to framework detail page Co-Authored-By: Claude Opus 4.6 (1M context) * feat(app): add admin timeline template management page Add a new admin page for managing timeline templates with CRUD operations. Includes a template list with phase bar previews and a sheet editor for creating/editing templates and their phases. Added sidebar navigation link. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(app): add Timeline tab to admin org detail page Add a Timeline tab to the admin organization detail view showing all timelines for an org. Includes status badges, phase tables, and action buttons for activating (with date picker), pausing, resuming, and editing individual phases via a sheet editor. Co-Authored-By: Claude Opus 4.6 (1M context) * fix(api): auto-create timelines for existing framework instances on first load * fix(api): fix type error in date helper tests * feat(api): smart timeline backfill using trust status, scores, and task completion data Co-Authored-By: Claude Opus 4.6 (1M context) * fix(app): use design system tokens, remove outer card, add next cycle date Co-Authored-By: Claude Opus 4.6 (1M context) * fix(app): improve phase bar with progress indicator and add timeline summary * fix(api): use 'Attestation' instead of 'Certification' for HIPAA and GDPR * feat: separate SOC 2 Type 1 and Type 2 timelines, add date markers to phase bar * fix: replace em dashes with hyphens in template names * fix(api): wrap admin timeline endpoints in { data, count } response format * fix(app): use correct timeline status values (DRAFT/ACTIVE/PAUSED/COMPLETED) in admin components * fix(app): fix missing closing brace in TimelineCard * fix(app): replace table layout with stacked phase cards in admin timeline view * feat(app): add tabs to Overview page with Timeline tab and compact teaser Co-Authored-By: Claude Opus 4.6 (1M context) * fix(app): simplify timeline teaser to clean single-line banner without phase bar * fix(app): remove timeline teaser from Overview tab * fix(app): remove redundant header and summary from Timeline tab * fix(app): use defaultDurationWeeks for template phase duration (fixes NaN) * fix: recalculate end date and downstream phases when duration or start date changes * feat(api): auto-complete timeline phases immediately when tasks are marked done * feat: add phase grouping with groupLabel for SOC 2 Type 2 sub-phases * feat(admin): add delete, reset, and recreate timeline actions with confirmation dialogs * fix(app): close confirmation dialogs immediately on confirm * fix(api): recreate also deletes DB templates so code defaults are re-seeded * fix(api): force-refresh templates from code defaults during recreate * fix(app): add bracket lines connecting group label to phase edges * fix(app): render grouped phases as cohesive blocks with no internal gaps * fix(app): show one start/end date per group instead of per sub-phase * feat: add AUTO_POLICIES and AUTO_PEOPLE completion types for independent sub-phase tracking * fix(api): use getOverviewScores for AUTO_POLICIES and AUTO_PEOPLE checks * fix(api): use correct property names from scores (total/published) * fix(app): wrap phase groups to new rows on small screens * fix(app): vertical phase stack on mobile, horizontal bar on lg+ screens * fix(app): keep grouped phases in one row on mobile, only ungrouped phases stack * fix(app): treat ungrouped phases as individual groups so each gets its own row on mobile * fix(app): show group labels and dates per row on mobile phase bar * fix(app): replace mobile phase bars with clean vertical step list * fix(app): compact horizontal bars on mobile without per-phase dates * fix(app): remove checkmarks from completed phase bars * fix(app): add diagonal stripe pattern to unfilled portion of active phase * fix(app): use primary color for stripe pattern on active phase * fix(app): use oklch color-mix for stripe pattern instead of hsl wrapper * fix(app): reduce stripe opacity to 10% for subtler effect * fix(app): reduce gap between phases from 3px to 1px * fix(api): rename SOC 2 Type 2 - Year 1 to SOC 2 Type 2 * fix(app): remove redundant Started/Est. completion text from timeline cards * feat: live completion percentages for AUTO_* phases, revert if metric drops below 100% * fix(api): rename SOC 2 sub-phases to Policies, Evidence, People * feat(app): show live completion % and fill for all AUTO_* sub-phases independently * feat(app): show avg completion % on group label, single cohesive fill for grouped phases * fix(app): show individual sub-phase percentages inside grouped bar * fix(app): add pulsing progress marker to grouped phase bar * feat(app): add dedicated full-page timeline template editor Replace the drawer-based template editor with a dedicated page at [orgId]/admin/timeline-templates/[templateId]. The new editor includes metadata form, live phase bar preview, individual phase cards with save/delete/reorder, and group label support with color indicators. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(app): framework dropdown in template creation and editing * feat(api): centralized Slack notifications for phase completion, timeline completion, and ready-for-review * fix(api): assign transaction result to txResult variable * fix(api): use template name in Slack notifications to distinguish SOC 2 Type 1 vs Type 2 * fix(api): use Slack Block Kit for cleaner notification formatting * feat(api): add clickable org link to Slack notifications with admin timeline URL * fix(api): show org ID in Slack notification messages * fix(app): only show stripes on IN_PROGRESS phases, plain muted for PENDING * fix(api): only advance next phase to IN_PROGRESS when all prior phases are completed * fix(app): show framework name instead of ID in template editor dropdown * feat(app): group sub-phases under parent card in template editor Phases that share a groupLabel are now nested inside a parent group card with an editable label, total duration display, and "Add Sub-phase" button. Sub-phases render as compact inline rows instead of full cards. Ungrouped phases retain the existing full PhaseCard rendering. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): add Auditor Review + Draft Report phases to SOC 2 templates * fix(app): phase numbering counts groups as 1, label below Phase N, add wk suffix to duration * fix(app): use DS Text component for weeks label instead of custom span * fix(app): add column labels (Name, Duration, Completion) above sub-phase rows * feat(app): add confirmation dialogs to all delete actions in template editor Co-Authored-By: Claude Opus 4.6 (1M context) * fix(api): add groupLabel to phase template DTOs and service * fix(api): remove AdminAuditLogInterceptor from template controller (no org context) * fix(api): pass groupLabel from DTO to service in addPhase and updatePhase * feat(api): add timeline auto-completion hooks to policies, members, tasks, and evidence services Wire up checkAutoCompletePhases fire-and-forget calls in all services that can affect compliance progress metrics, enabling automatic phase completion/reversion when tasks, policies, people, or evidence change. Co-Authored-By: Claude Opus 4.6 (1M context) * feat(api): add Preparing for Audit group (Policies, Evidence, People) to SOC 2 Type 1 * fix(api): auto-complete phases at 100% on page load, not just on events * fix(app): group phase cards visually in admin timeline, show template name as title * fix(api): use actual completion date as anchor for downstream phase recalculation * chore(db): add migration for AUTO_POLICIES and AUTO_PEOPLE completion types * feat: add Start Next Cycle action for completed timelines (admin only) * fix(app): use render prop on AlertDialogTrigger to avoid nested buttons * fix(app): destructure onStartNextCycle from TimelineActions props * feat(app): group timeline cycles by framework with stacked card effect for past cycles * fix(app): fix stacked card effect to show behind the main card * fix(app): stacked card effect as thin strips peeking below main card * fix(app): simple lip button below card for showing previous cycles * fix(app): remove bottom border radius from card when previous cycles lip is attached * fix(app): subtle muted background on previous cycles lip * fix(app): reduce lip padding and font size for sleeker look * refactor(app): migrate TimelineOverview to DS components (Card, Badge, Text, Stack) * fix(app): improve timeline card padding, title size, and spacing * fix(app): remove bg from previous cycles lip, only show on hover * fix(app): remove lip, show cycle badge (Year 2) when multiple cycles exist * fix(app): show Year badge on all timelines, computed from cycle count not internal ID * fix(api): always use now() for completedAt and endDate when auto-completing phases * fix(api): pin phase dates on early completion so recalculation doesn't overwrite them * fix(api): use min(endDate, now) for completedAt in backfill to avoid future dates * chore: update OpenAPI spec with timeline endpoints * feat(timelines): add track-based SOC2 timeline model * feat(findings): integrate timelines module and add AUTO_FINDINGS phase completion logic * chore: ignore .worktrees directory * feat(flags): add per-org PostHog feature flag admin toggle Gates features behind PostHog flags evaluated per organization via group targeting. Platform admins can list/toggle flags for a specific org from the admin panel; end users pick up changes on route navigation. - register org as PostHog group via OrganizationIdentifier in org layout - useFeatureFlag hook (posthog-js/react based, auto-reactive) - reload flags on route change so toggles propagate without full refresh - admin endpoints list + patch flags for a given org (groupIdentify) - Feature Flags tab in admin org panel with search + toggle - gate Timeline tab in Overview behind is-timeline-enabled Co-Authored-By: Claude Opus 4.7 (1M context) * chore: timeline cleanup — Card DS fix, dedup import, timeline migration - TimelineOverview uses DS Card title/headerAction props (no className) - remove duplicate ApiExcludeController import - add timeline prisma migration - regenerate openapi.json Co-Authored-By: Claude Opus 4.7 (1M context) * fix(timeline,flags): address AI review feedback API: - reset timeline status to ACTIVE when reopening regressed phases so a COMPLETED timeline never holds an IN_PROGRESS phase (fix data corruption) - fire-and-forget createTimelinesForFrameworks so timeline creation errors don't mask successful framework creation - log errors in checkAutoCompletePhases .catch() in tasks/people/policies services instead of swallowing silently - apply AdminAuditLogInterceptor to feature flag admin controller so PATCH toggles get audit-logged - drop unused AdminAuditLogInterceptor import from admin-timeline-templates controller (no orgId in path, interceptor would no-op) - cache PostHog "not configured" state so warning logs once, not per request - read Slack webhook URL + APP_URL at call time instead of module load time so env var loading order doesn't silently disable notifications App: - surface SWR error state in FeatureFlagsTab so failed fetches don't render as an empty "no flags found" panel - check .error on api.delete / api.patch / api.post in template-actions so partial saves throw instead of silently succeeding - non-mutating sort of template.phases in TemplateEditorPage to avoid corrupting SWR cache Co-Authored-By: Claude Opus 4.7 (1M context) * fix(timeline): address remaining AI review P2 feedback API: - extract COMPLETION_TYPES to shared timeline-constants, narrow DTO completionType from string to CompletionType union - trigger checkAutoCompletePhases on both done and not_relevant task statuses (AUTO_TASKS counts both as finished) - drop unnecessary checkAutoCompletePhases after task creation — a todo task can't advance any AUTO phase App: - extract COMPLETION_OPTIONS constant to timeline-templates/constants and share across PhaseRow, TemplateEditor, template-actions - remove orderIndex from form state (controlled/uncontrolled hybrid); compute from array position at submit time in template-actions - convert TemplateMetadataForm's framework fetch from raw useEffect to SWR with proper error handling - delete duplicate admin layout under timeline-templates (parent admin layout already guards the route) Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(timelines): make findAllForOrganization pure-read Previously GET /timelines mutated state (completed phases, reverted regressed phases, updated timeline status). That violates HTTP semantics and races under concurrent reads. - extract the sync loop into TimelinesService.reconcileAutoPhasesForOrganization (handles both advancement and regression, replaces the inline logic) - findAllForOrganization is now a pure read: ensureTimelinesExist (idempotent backfill) + fetch + in-memory enrichment with live completion percentages - call reconcileAutoPhasesForOrganization from checkAutoCompletePhases so every existing event hook (task/policy/people/findings mutations) drives both advancement and regression — no stale state left for reads to repair - recreateAllForOrganization explicitly reconciles before returning so freshly-recreated timelines are synced against current metrics Co-Authored-By: Claude Opus 4.7 (1M context) * fix(timelines): always reconcile after checkAutoCompletePhases The reconcile call sat after an early return for phases.length === 0, which skipped it in the exact case it mattered: when all AUTO phases are already COMPLETED and a metric drops (regression). Wrap the advancement in a try/finally so reconciliation fires unconditionally on every event hook — extract advancement into runPhaseAdvancement for readability. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(db): reorder timeline migrations to apply after main's latest Three of the branch's timeline migrations were dated 2026-04-07/08, which is before main's 20260410120000_add_finding_scope. On merge, Prisma would apply them out-of-order relative to what staging/prod already have. Rename them to 20260410130000-20260410130002 so they sit right after main's latest migration while preserving internal dependency order (timeline models → phase group label → auto policies/people completion types → rest of timeline migrations). Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address cubic P1/P2 review feedback batch API: - remove unused UseInterceptors import from admin-timeline-templates - add @Transform trim to UnlockTimelineDto so whitespace-only reason fails @IsNotEmpty validation - log errors (this.logger.warn) on checkAutoCompletePhases .catch() in people-invite and evidence-forms services (matches pattern elsewhere) - replace unreachable ECONNREFUSED check in dynamic-manifest-loader with the correct Prisma error code P1001 - forbidNonWhitelisted on FF admin controller's ValidationPipe so bogus body keys return 400 instead of silently stripping - @IsInt (not @IsNumber) on cycleNumber so 1.5 etc. is rejected - wrap posthog groupIdentify + flush in try/catch in FF service — network failures now surface as meaningful BadRequestException + log instead of raw 500 - check response.ok in Slack helper so non-200s are logged - return sendSlack promise from notify* helpers instead of floating it - drop getScores' checkAutoCompletePhases call — mutation event hooks already drive both advancement and regression; the dashboard read shouldn't trigger heavy writes on every load App: - TemplateMetadataForm: reset(values) after save so isDirty flips back, wrap setSaving in try/finally - TemplateEditorPage: render distinct error state when the SWR fetch fails instead of falling through to "Template not found" - TimelineActivateForm: validate date before ISO conversion and wrap body in try/finally so a bad date can't leave the button stuck in loading Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address new cubic P1/P2 review batch P1: - template-actions: upsert phases first, then delete removed ones. Reverse order meant a mid-save failure could drop phases before their replacements were successfully written. P2: - tasks.service: trigger checkAutoCompletePhases on ANY status change (bulk and single update). Limiting it to done/not_relevant missed regressions when a finished task is reopened. - findings.service: same — run reconciliation on every finding status transition, not only when moving to closed. - admin-feature-flags.service: paginate the PostHog feature_flags REST response (follow `next` cursor) so orgs with >200 flags don't silently drop the tail. - admin-feature-flags.service: treat any non-empty string variant returned by getAllFlags as enabled (multivariate flags report variant name, not boolean true), not just strict `=== true`. - DTOs: use @IsInt instead of @IsNumber for cycleNumber, orderIndex, durationWeeks, defaultDurationWeeks — rejects fractional values at the boundary instead of failing later at the DB Int column. - timelines-phases.service: wire data.endDate and data.datesPinned through the update. Previously the DTO advertised them but the service silently ignored both. - admin-org-timelines.controller: pass datesPinned from DTO to service. - TimelineActivateForm: parse date input as local midnight (match YYYY-MM-DD, construct Date with local components) so a negative-UTC-offset user's chosen day isn't shifted by ISO serialization. - FeatureFlagsTab: disable all flag switches while any PATCH is in flight so a late rollback from a failed request can't overwrite a newer successful toggle. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address new cubic P1/P2 batch after main merge P1: - timelines-phases.service: if caller sets an explicit start/end date, force datesPinned=true regardless of what the DTO says — storing a specific date with datesPinned=false would let the downstream date recalculator silently overwrite it - template-actions.createNewTemplate: wrap the phase-creation loop in try/catch and delete the orphan template on failure, so a failed phase POST can't leave a template with zero phases in the framework's list - timelines.service.ensureTimelinesExist: always call backfillTimeline (which is idempotent per-track) instead of short-circuiting when any timeline already exists. Repairs partial state from a half-succeeded createTimelinesForFrameworks call (e.g. SOC 2 with only Type 1 created) P2: - markReadyForReview: return idempotently (alreadyReady=true) when the phase is already marked, and only fire the Slack ping on the first transition. Double-clicks / retries no longer spam the CX channel. - timelines-slack.helper: use the same base-URL fallback chain the other notifiers use (NEXT_PUBLIC_APP_URL → BETTER_AUTH_URL → APP_URL), and escape user-supplied text before interpolating into Slack mrkdwn so org names with `<`, `>`, `&`, `|` can't break link syntax. - posthog.service: prefer POSTHOG_API_KEY over NEXT_PUBLIC_POSTHOG_KEY so backend config can't be shadowed by frontend env values. - tasks.service.createTask: re-run checkAutoCompletePhases after create — a new todo task reduces AUTO_TASKS completion % and can regress a previously COMPLETED phase. - policies.service.spec: register a TimelinesService stub so the test module compiles after the service gained that dependency. Co-Authored-By: Claude Opus 4.7 (1M context) * fix: address new cubic P1/P2 batch P1: - admin-feature-flags.service: validate the PostHog pagination \`next\` URL against the configured host before following it. A compromised or malicious PostHog response could otherwise redirect pagination to an attacker-controlled host and leak the Authorization header (personal API key) via SSRF. - findings.service: wire checkAutoCompletePhases into create() and delete() too. Previously only update() triggered reconciliation, so creating a new open finding or deleting the last open one could leave AUTO_FINDINGS phases out of sync with the actual ratio. P2: - frameworks-timeline.helper.createTimelinesForFrameworks: try/catch per track instead of wrapping the whole loop. A soc2_type1 failure no longer silently skips soc2_type2 — each track is attempted independently and logged on its own. - timelines-date.helper: defensively copy Date objects on assignment so the caller's timelineStartDate (and the pinned start/end dates of input phases) can't be mutated by a caller writing back into the returned rows. - admin-org-timelines.controller.addPhase: cast to PhaseCompletionType (already imported) instead of a hardcoded string union — stays in sync with the Prisma enum automatically when a new type is added. - timeline.prisma: document the NULL-distinct behavior of the (frameworkId, templateKey) unique constraint so it's not misread as a bug. Rows without explicit keys are already covered by the (frameworkId, trackKey, cycleNumber) constraint above. Co-Authored-By: Claude Opus 4.7 (1M context) * test(flags): add FF tests + guard invalid POSTHOG_HOST After auditing all cubic review threads, only two were actually still open against the current code: - admin-feature-flags.service: new URL(apiHost) at line 55 could throw if the configured host lacks a protocol (e.g. 'posthog.internal'). Wrap it in try/catch and return [] with a logged error instead of crashing the whole list endpoint. - admin-feature-flags.controller/service: no tests existed. Add a controller spec covering the NotFoundException path and happy-path delegation, plus a service spec covering: missing REST config, the SSRF guard refusing foreign-origin pagination, multivariate variant strings recognized as enabled, and setFlagForOrganization calling groupIdentify + flush. Co-Authored-By: Claude Opus 4.7 (1M context) * test(flags): use strict host match in fetch mock to silence CodeQL The URL filter `u.includes('us.posthog.com')` could match `evil.com/us.posthog.com/`. Harmless in a unit test but CodeQL flags the pattern. Switch to `new URL(u).host === 'us.posthog.com'` so it's an exact-host check. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(app): stop tracking generated Prisma client apps/app/.gitignore had src/generated/ but not prisma/src/generated/, which is where the schema's output setting actually writes. A previous merge-conflict sweep committed ~96 generated files. Extend the gitignore and untrack them — postinstall runs 'prisma generate' so the client is recreated on install / build. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(db): squash 9 timeline migrations into one via prisma migrate diff Generated a single migration using 'prisma migrate diff' with --from-migrations pointing at a copy of packages/db/prisma/migrations that excludes the 9 timeline ones, and --to-schema pointing at the current schema. The resulting SQL is byte-for-byte what main + the squashed migration would produce (verified: migrate diff from the final migrations dir against the schema reports 'No difference detected'). Dropped: - 20260410130000_add_timeline_models - 20260410130001_add_phase_group_label - 20260410130002_add_auto_policies_people_completion_types - 20260413182638_add_timeline_lock_flags - 20260413200000_add_timeline_lock_state_and_regression - 20260413212000_add_timeline_template_progression_keys - 20260413231500_add_timeline_track_keys - 20260414113000_add_auto_findings_completion_type - 20260417155556_timeline Replaced with: - 20260420000000_timeline_feature Local devs who already applied any of the dropped migrations will need to 'prisma migrate reset' — the old names still sit in their _prisma_migrations table. Staging/prod haven't applied yet (branch isn't merged) so they're unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(app): remove the duplicated prisma/ setup apps/app/prisma/ was a full duplicate of packages/db: - client.ts was byte-identical to packages/db/src/client.ts - prisma/schema/ duplicated packages/db/prisma/schema/ and was already drifting (a recent timeline.prisma comment existed in packages/db but not here) - prisma.config.ts + postinstall + db:generate + db:getschema all existed to sync and generate from the local duplicated schema The app never imports @trycompai/db directly — it uses the @db alias (351 call sites) which maps to ./prisma/{index,server}.ts. Those files now just re-export from @prisma/client (populated by packages/db's build) so the local schema and generation are dead code. Dropped: - apps/app/prisma.config.ts - apps/app/prisma/schema/ (45 files) - apps/app/prisma/src/ (previously-generated tree) - package.json: build:docker prisma-generate prefix, db:generate, db:getschema, prebuild, postinstall - .gitignore: stale prisma/generated + prisma/schema/*.prisma rules Kept: apps/app/prisma/{client,index,server}.ts — these wire up the app's PrismaClient instance (with the PG adapter) and expose it via the @db alias. packages/db doesn't export a ready-made db instance, so these three thin files stay for now. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(db,docker): restore Prisma client generation after app prisma cleanup When apps/app lost its own postinstall + build:docker prisma-generate step, nothing was left to populate @prisma/client. Cubic was right that 'next build' would fail — the deps stage already uses --ignore-scripts and PRISMA_SKIP_POSTINSTALL_GENERATE=true, so neither the app nor packages/db generated anything. Fix in two places: - packages/db/package.json: add a postinstall that runs generate-prisma-client-js. Covers local dev — 'bun install' now populates node_modules/@prisma/client. '|| true' mirrors the app's old pattern so installs in envs without DATABASE_URL don't fail. - Dockerfile app-builder stage: after combine-schemas, explicitly run generate-prisma-client-js so the Docker build gets the client too (the deps stage skipped it intentionally). Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Mariano Fuentes Co-authored-by: Claude Opus 4.6 (1M context) --- .gitignore | 2 + Dockerfile | 10 +- apps/api/package.json | 45 +- .../admin-feature-flags.controller.spec.ts | 132 +++ .../admin-feature-flags.controller.ts | 69 ++ .../admin-feature-flags.module.ts | 11 + .../admin-feature-flags.service.spec.ts | 146 ++++ .../admin-feature-flags.service.ts | 197 +++++ .../dto/update-feature-flag.dto.ts | 10 + .../admin-feature-flags/posthog.service.ts | 42 + apps/api/src/app.module.ts | 4 + .../evidence-forms/evidence-forms.module.ts | 3 +- .../evidence-forms.service.spec.ts | 4 +- .../evidence-forms/evidence-forms.service.ts | 42 +- apps/api/src/findings/findings.module.ts | 3 +- apps/api/src/findings/findings.service.ts | 42 + .../frameworks-scores.helper.spec.ts | 277 +++++++ .../frameworks/frameworks-scores.helper.ts | 60 +- .../frameworks-timeline.helper.spec.ts | 196 +++++ .../frameworks/frameworks-timeline.helper.ts | 362 +++++++++ apps/api/src/frameworks/frameworks.module.ts | 3 +- apps/api/src/frameworks/frameworks.service.ts | 29 +- .../admin-integrations.controller.ts | 2 +- .../dynamic-manifest-loader.service.ts | 14 +- .../src/people/people-invite.service.spec.ts | 8 +- apps/api/src/people/people-invite.service.ts | 15 + apps/api/src/people/people.module.ts | 3 +- apps/api/src/people/people.service.spec.ts | 4 + apps/api/src/people/people.service.ts | 46 +- apps/api/src/policies/policies.module.ts | 3 +- .../api/src/policies/policies.service.spec.ts | 4 + apps/api/src/policies/policies.service.ts | 66 +- apps/api/src/tasks/tasks.module.ts | 3 +- apps/api/src/tasks/tasks.service.ts | 64 +- .../admin-org-timelines.controller.ts | 181 +++++ .../admin-timeline-templates.controller.ts | 112 +++ .../src/timelines/default-templates.spec.ts | 98 +++ apps/api/src/timelines/default-templates.ts | 487 +++++++++++ .../timelines/dto/activate-timeline.dto.ts | 11 + .../dto/create-phase-template.dto.ts | 99 +++ .../src/timelines/dto/create-template.dto.ts | 26 + .../src/timelines/dto/unlock-timeline.dto.ts | 16 + .../dto/update-phase-template.dto.ts | 61 ++ .../api/src/timelines/dto/update-phase.dto.ts | 67 ++ .../src/timelines/dto/update-template.dto.ts | 18 + apps/api/src/timelines/timeline-constants.ts | 10 + .../timelines/timelines-backfill.helper.ts | 360 +++++++++ .../timelines/timelines-date.helper.spec.ts | 131 +++ .../src/timelines/timelines-date.helper.ts | 40 + .../timelines-lifecycle.service.spec.ts | 422 ++++++++++ .../timelines/timelines-lifecycle.service.ts | 331 ++++++++ .../timelines-phases.service.spec.ts | 238 ++++++ .../src/timelines/timelines-phases.service.ts | 202 +++++ .../src/timelines/timelines-slack.helper.ts | 153 ++++ .../timelines-template-resolver.spec.ts | 157 ++++ .../timelines/timelines-template-resolver.ts | 202 +++++ .../timelines-templates.service.spec.ts | 207 +++++ .../timelines/timelines-templates.service.ts | 290 +++++++ .../api/src/timelines/timelines.controller.ts | 64 ++ apps/api/src/timelines/timelines.module.ts | 26 + .../src/timelines/timelines.service.spec.ts | 753 ++++++++++++++++++ apps/api/src/timelines/timelines.service.ts | 609 ++++++++++++++ apps/app/.gitignore | 6 - apps/app/package.json | 8 +- apps/app/prisma.config.ts | 9 - apps/app/prisma/client.ts | 2 +- apps/app/prisma/index.ts | 2 +- apps/app/prisma/schema/schema.prisma | 10 - apps/app/prisma/server.ts | 2 +- .../[orgId]/admin/components/AdminSidebar.tsx | 1 + .../[adminOrgId]/components/AdminOrgTabs.tsx | 10 + .../components/FeatureFlagsTab.tsx | 167 ++++ .../components/TimelineActivateForm.tsx | 78 ++ .../[adminOrgId]/components/TimelineCard.tsx | 528 ++++++++++++ .../components/TimelinePhaseEditor.tsx | 277 +++++++ .../[adminOrgId]/components/TimelineTab.tsx | 107 +++ .../[templateId]/components/PhaseCard.tsx | 314 ++++++++ .../components/PhaseGroupCard.tsx | 199 +++++ .../[templateId]/components/PhaseList.tsx | 229 ++++++ .../[templateId]/components/SubPhaseRow.tsx | 238 ++++++ .../components/TemplateEditorPage.tsx | 136 ++++ .../components/TemplateMetadataForm.tsx | 167 ++++ .../timeline-templates/[templateId]/page.tsx | 11 + .../components/NewTemplateDialog.tsx | 177 ++++ .../components/PhaseRow.tsx | 114 +++ .../components/TemplateEditor.tsx | 243 ++++++ .../components/TemplateList.tsx | 298 +++++++ .../components/constants.ts | 10 + .../components/template-actions.ts | 141 ++++ .../[orgId]/admin/timeline-templates/page.tsx | 5 + .../components/FrameworkTimeline.tsx | 265 ++++++ .../frameworks/[frameworkInstanceId]/page.tsx | 2 + apps/app/src/app/(app)/[orgId]/layout.tsx | 6 +- .../overview/components/OverviewTabs.tsx | 22 +- .../overview/components/TimelineOverview.tsx | 187 +++++ .../overview/components/TimelinePhaseBar.tsx | 368 +++++++++ .../(app)/[orgId]/overview/timeline/page.tsx | 22 + apps/app/src/app/posthog.ts | 9 +- apps/app/src/hooks/use-admin-feature-flags.ts | 60 ++ apps/app/src/hooks/use-admin-timelines.ts | 191 +++++ apps/app/src/hooks/use-timelines.ts | 134 ++++ bun.lock | 9 + .../components/organization-identifier.tsx | 35 + .../analytics/src/hooks/use-feature-flag.ts | 14 + packages/analytics/src/index.ts | 2 + packages/db/package.json | 1 + .../migration.sql | 133 ++++ packages/db/prisma/schema/auth.prisma | 23 +- .../db/prisma/schema/framework-editor.prisma | 1 + packages/db/prisma/schema/framework.prisma | 1 + packages/db/prisma/schema/organization.prisma | 3 + packages/db/prisma/schema/timeline.prisma | 121 +++ packages/docs/openapi.json | 86 ++ 113 files changed, 12381 insertions(+), 95 deletions(-) create mode 100644 apps/api/src/admin-feature-flags/admin-feature-flags.controller.spec.ts create mode 100644 apps/api/src/admin-feature-flags/admin-feature-flags.controller.ts create mode 100644 apps/api/src/admin-feature-flags/admin-feature-flags.module.ts create mode 100644 apps/api/src/admin-feature-flags/admin-feature-flags.service.spec.ts create mode 100644 apps/api/src/admin-feature-flags/admin-feature-flags.service.ts create mode 100644 apps/api/src/admin-feature-flags/dto/update-feature-flag.dto.ts create mode 100644 apps/api/src/admin-feature-flags/posthog.service.ts create mode 100644 apps/api/src/frameworks/frameworks-scores.helper.spec.ts create mode 100644 apps/api/src/frameworks/frameworks-timeline.helper.spec.ts create mode 100644 apps/api/src/frameworks/frameworks-timeline.helper.ts create mode 100644 apps/api/src/timelines/admin-org-timelines.controller.ts create mode 100644 apps/api/src/timelines/admin-timeline-templates.controller.ts create mode 100644 apps/api/src/timelines/default-templates.spec.ts create mode 100644 apps/api/src/timelines/default-templates.ts create mode 100644 apps/api/src/timelines/dto/activate-timeline.dto.ts create mode 100644 apps/api/src/timelines/dto/create-phase-template.dto.ts create mode 100644 apps/api/src/timelines/dto/create-template.dto.ts create mode 100644 apps/api/src/timelines/dto/unlock-timeline.dto.ts create mode 100644 apps/api/src/timelines/dto/update-phase-template.dto.ts create mode 100644 apps/api/src/timelines/dto/update-phase.dto.ts create mode 100644 apps/api/src/timelines/dto/update-template.dto.ts create mode 100644 apps/api/src/timelines/timeline-constants.ts create mode 100644 apps/api/src/timelines/timelines-backfill.helper.ts create mode 100644 apps/api/src/timelines/timelines-date.helper.spec.ts create mode 100644 apps/api/src/timelines/timelines-date.helper.ts create mode 100644 apps/api/src/timelines/timelines-lifecycle.service.spec.ts create mode 100644 apps/api/src/timelines/timelines-lifecycle.service.ts create mode 100644 apps/api/src/timelines/timelines-phases.service.spec.ts create mode 100644 apps/api/src/timelines/timelines-phases.service.ts create mode 100644 apps/api/src/timelines/timelines-slack.helper.ts create mode 100644 apps/api/src/timelines/timelines-template-resolver.spec.ts create mode 100644 apps/api/src/timelines/timelines-template-resolver.ts create mode 100644 apps/api/src/timelines/timelines-templates.service.spec.ts create mode 100644 apps/api/src/timelines/timelines-templates.service.ts create mode 100644 apps/api/src/timelines/timelines.controller.ts create mode 100644 apps/api/src/timelines/timelines.module.ts create mode 100644 apps/api/src/timelines/timelines.service.spec.ts create mode 100644 apps/api/src/timelines/timelines.service.ts delete mode 100644 apps/app/prisma.config.ts delete mode 100644 apps/app/prisma/schema/schema.prisma create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/FeatureFlagsTab.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineActivateForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelinePhaseEditor.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineTab.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/components/PhaseCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/components/PhaseGroupCard.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/components/PhaseList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/components/SubPhaseRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/components/TemplateEditorPage.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/components/TemplateMetadataForm.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/[templateId]/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/components/NewTemplateDialog.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/components/PhaseRow.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/components/TemplateEditor.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/components/TemplateList.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/components/constants.ts create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/components/template-actions.ts create mode 100644 apps/app/src/app/(app)/[orgId]/admin/timeline-templates/page.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkTimeline.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/components/TimelineOverview.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/components/TimelinePhaseBar.tsx create mode 100644 apps/app/src/app/(app)/[orgId]/overview/timeline/page.tsx create mode 100644 apps/app/src/hooks/use-admin-feature-flags.ts create mode 100644 apps/app/src/hooks/use-admin-timelines.ts create mode 100644 apps/app/src/hooks/use-timelines.ts create mode 100644 packages/analytics/src/components/organization-identifier.tsx create mode 100644 packages/analytics/src/hooks/use-feature-flag.ts create mode 100644 packages/db/prisma/migrations/20260420000000_timeline_feature/migration.sql create mode 100644 packages/db/prisma/schema/timeline.prisma diff --git a/.gitignore b/.gitignore index 9cd387c21..58946b17f 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ packages/*/dist # Generated Prisma Client **/src/db/generated/ +packages/db/prisma/src/generated/ # Release script scripts/sync-release-branch.sh @@ -97,3 +98,4 @@ scripts/sync-release-branch.sh .superpowers/* .claude/worktrees/ +.worktrees/ diff --git a/Dockerfile b/Dockerfile index 8177432ca..a9fdfa186 100644 --- a/Dockerfile +++ b/Dockerfile @@ -63,9 +63,13 @@ COPY apps/app ./apps/app # Bring in node_modules for build and prisma prebuild COPY --from=deps /app/node_modules ./node_modules -# Pre-combine schemas for app build -RUN cd packages/db && node scripts/combine-schemas.js -RUN cp packages/db/dist/schema.prisma apps/app/prisma/schema.prisma +# Pre-combine schemas and generate the Prisma client into +# node_modules/@prisma/client. The deps stage ran `bun install` with +# `--ignore-scripts` so packages/db's postinstall was skipped; we run +# it explicitly here so `next build` can resolve the generated runtime +# + types when it imports @prisma/client. +RUN cd packages/db && node scripts/combine-schemas.js \ + && node scripts/generate-prisma-client-js.js # Ensure Next build has required public env at build-time ARG NEXT_PUBLIC_BETTER_AUTH_URL diff --git a/apps/api/package.json b/apps/api/package.json index d9548588b..4bf6aa80c 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,57 +7,57 @@ "@ai-sdk/anthropic": "^2.0.53", "@ai-sdk/groq": "^2.0.32", "@ai-sdk/openai": "^2.0.65", - "@aws-sdk/client-ec2": "^3.911.0", - "@aws-sdk/client-s3": "3.1013.0", "@aws-sdk/client-acm": "^3.948.0", + "@aws-sdk/client-api-gateway": "^3.948.0", + "@aws-sdk/client-apigatewayv2": "^3.948.0", + "@aws-sdk/client-appflow": "^3.948.0", + "@aws-sdk/client-athena": "^3.948.0", "@aws-sdk/client-backup": "^3.948.0", + "@aws-sdk/client-cloudfront": "^3.948.0", "@aws-sdk/client-cloudtrail": "^3.948.0", "@aws-sdk/client-cloudwatch": "^3.948.0", - "@aws-sdk/client-cost-explorer": "^3.948.0", "@aws-sdk/client-cloudwatch-logs": "^3.948.0", + "@aws-sdk/client-codebuild": "^3.948.0", + "@aws-sdk/client-cognito-identity-provider": "^3.948.0", "@aws-sdk/client-config-service": "^3.948.0", + "@aws-sdk/client-cost-explorer": "^3.948.0", "@aws-sdk/client-dynamodb": "^3.948.0", + "@aws-sdk/client-ec2": "^3.911.0", "@aws-sdk/client-ecr": "^3.948.0", "@aws-sdk/client-ecs": "^3.948.0", "@aws-sdk/client-efs": "^3.948.0", "@aws-sdk/client-eks": "^3.948.0", + "@aws-sdk/client-elastic-beanstalk": "^3.948.0", "@aws-sdk/client-elastic-load-balancing-v2": "^3.948.0", + "@aws-sdk/client-elasticache": "^3.948.0", + "@aws-sdk/client-emr": "^3.948.0", + "@aws-sdk/client-eventbridge": "^3.948.0", + "@aws-sdk/client-glue": "^3.948.0", "@aws-sdk/client-guardduty": "^3.948.0", "@aws-sdk/client-iam": "^3.948.0", "@aws-sdk/client-inspector2": "^3.948.0", + "@aws-sdk/client-kafka": "^3.948.0", + "@aws-sdk/client-kinesis": "^3.948.0", "@aws-sdk/client-kms": "^3.948.0", "@aws-sdk/client-lambda": "^3.948.0", "@aws-sdk/client-macie2": "^3.948.0", + "@aws-sdk/client-network-firewall": "^3.948.0", "@aws-sdk/client-opensearch": "^3.948.0", "@aws-sdk/client-rds": "^3.948.0", "@aws-sdk/client-redshift": "^3.948.0", "@aws-sdk/client-route-53": "^3.948.0", + "@aws-sdk/client-s3": "3.1013.0", + "@aws-sdk/client-sagemaker": "^3.948.0", "@aws-sdk/client-secrets-manager": "^3.948.0", "@aws-sdk/client-securityhub": "^3.948.0", - "@aws-sdk/client-sns": "^3.948.0", - "@aws-sdk/client-sqs": "^3.948.0", - "@aws-sdk/client-wafv2": "^3.948.0", - "@aws-sdk/client-api-gateway": "^3.948.0", - "@aws-sdk/client-apigatewayv2": "^3.948.0", - "@aws-sdk/client-appflow": "^3.948.0", - "@aws-sdk/client-athena": "^3.948.0", - "@aws-sdk/client-cloudfront": "^3.948.0", - "@aws-sdk/client-codebuild": "^3.948.0", - "@aws-sdk/client-cognito-identity-provider": "^3.948.0", - "@aws-sdk/client-elastic-beanstalk": "^3.948.0", - "@aws-sdk/client-elasticache": "^3.948.0", - "@aws-sdk/client-emr": "^3.948.0", - "@aws-sdk/client-eventbridge": "^3.948.0", - "@aws-sdk/client-glue": "^3.948.0", - "@aws-sdk/client-kafka": "^3.948.0", - "@aws-sdk/client-kinesis": "^3.948.0", - "@aws-sdk/client-network-firewall": "^3.948.0", - "@aws-sdk/client-sagemaker": "^3.948.0", "@aws-sdk/client-sfn": "^3.948.0", "@aws-sdk/client-shield": "^3.948.0", + "@aws-sdk/client-sns": "^3.948.0", + "@aws-sdk/client-sqs": "^3.948.0", "@aws-sdk/client-ssm": "^3.948.0", "@aws-sdk/client-sts": "^3.948.0", "@aws-sdk/client-transfer": "^3.948.0", + "@aws-sdk/client-wafv2": "^3.948.0", "@aws-sdk/s3-request-presigner": "3.1013.0", "@browserbasehq/sdk": "2.6.0", "@browserbasehq/stagehand": "^3.2.1", @@ -103,6 +103,7 @@ "nanoid": "^5.1.6", "pdf-lib": "^1.17.1", "playwright-core": "^1.57.0", + "posthog-node": "^5.29.2", "prisma": "7.6.0", "react": "^19.1.1", "react-dom": "^19.1.0", diff --git a/apps/api/src/admin-feature-flags/admin-feature-flags.controller.spec.ts b/apps/api/src/admin-feature-flags/admin-feature-flags.controller.spec.ts new file mode 100644 index 000000000..65d0c8a72 --- /dev/null +++ b/apps/api/src/admin-feature-flags/admin-feature-flags.controller.spec.ts @@ -0,0 +1,132 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; + +jest.mock('@db', () => ({ + db: { + organization: { + findUnique: jest.fn(), + }, + }, +})); + +jest.mock('../auth/platform-admin.guard', () => ({ + PlatformAdminGuard: class MockGuard { + canActivate() { + return true; + } + }, +})); + +jest.mock('../admin-organizations/admin-audit-log.interceptor', () => ({ + AdminAuditLogInterceptor: class MockInterceptor { + intercept(_ctx: unknown, next: { handle: () => unknown }) { + return next.handle(); + } + }, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import { AdminFeatureFlagsController } from './admin-feature-flags.controller'; +import { AdminFeatureFlagsService } from './admin-feature-flags.service'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { AdminAuditLogInterceptor } from '../admin-organizations/admin-audit-log.interceptor'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires +const mockDb = require('@db').db as { + organization: { findUnique: jest.Mock }; +}; + +describe('AdminFeatureFlagsController', () => { + let controller: AdminFeatureFlagsController; + let service: { + listForOrganization: jest.Mock; + setFlagForOrganization: jest.Mock; + }; + + beforeEach(async () => { + jest.clearAllMocks(); + service = { + listForOrganization: jest.fn(), + setFlagForOrganization: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AdminFeatureFlagsController], + providers: [{ provide: AdminFeatureFlagsService, useValue: service }], + }) + .overrideGuard(PlatformAdminGuard) + .useValue({ canActivate: () => true }) + .overrideInterceptor(AdminAuditLogInterceptor) + .useValue({ intercept: (_ctx: unknown, next: { handle: () => unknown }) => next.handle() }) + .compile(); + + controller = module.get(AdminFeatureFlagsController); + }); + + describe('list', () => { + it('throws NotFoundException when the org does not exist', async () => { + mockDb.organization.findUnique.mockResolvedValue(null); + await expect(controller.list('org_missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + expect(service.listForOrganization).not.toHaveBeenCalled(); + }); + + it('returns flags wrapped in { data } when the org exists', async () => { + mockDb.organization.findUnique.mockResolvedValue({ id: 'org_1' }); + service.listForOrganization.mockResolvedValue([ + { + key: 'is-timeline-enabled', + name: 'is-timeline-enabled', + description: '', + active: true, + enabled: true, + createdAt: null, + }, + ]); + + const result = await controller.list('org_1'); + + expect(service.listForOrganization).toHaveBeenCalledWith('org_1'); + expect(result.data).toHaveLength(1); + expect(result.data[0].key).toBe('is-timeline-enabled'); + }); + }); + + describe('update', () => { + it('throws NotFoundException when the org does not exist', async () => { + mockDb.organization.findUnique.mockResolvedValue(null); + await expect( + controller.update('org_missing', { + flagKey: 'is-timeline-enabled', + enabled: true, + }), + ).rejects.toBeInstanceOf(NotFoundException); + expect(service.setFlagForOrganization).not.toHaveBeenCalled(); + }); + + it('delegates to the service with orgId, orgName, flagKey, and enabled', async () => { + mockDb.organization.findUnique.mockResolvedValue({ + id: 'org_1', + name: 'Acme', + }); + service.setFlagForOrganization.mockResolvedValue({ + key: 'is-timeline-enabled', + enabled: false, + }); + + const result = await controller.update('org_1', { + flagKey: 'is-timeline-enabled', + enabled: false, + }); + + expect(service.setFlagForOrganization).toHaveBeenCalledWith({ + orgId: 'org_1', + orgName: 'Acme', + flagKey: 'is-timeline-enabled', + enabled: false, + }); + expect(result.data.enabled).toBe(false); + }); + }); +}); diff --git a/apps/api/src/admin-feature-flags/admin-feature-flags.controller.ts b/apps/api/src/admin-feature-flags/admin-feature-flags.controller.ts new file mode 100644 index 000000000..ce2b1b0e9 --- /dev/null +++ b/apps/api/src/admin-feature-flags/admin-feature-flags.controller.ts @@ -0,0 +1,69 @@ +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Patch, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiExcludeController, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { db } from '@db'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { AdminAuditLogInterceptor } from '../admin-organizations/admin-audit-log.interceptor'; +import { AdminFeatureFlagsService } from './admin-feature-flags.service'; +import { UpdateFeatureFlagDto } from './dto/update-feature-flag.dto'; + +@ApiExcludeController() +@ApiTags('Admin - Feature Flags') +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 60 } }) +export class AdminFeatureFlagsController { + constructor(private readonly service: AdminFeatureFlagsService) {} + + @Get(':orgId/feature-flags') + @ApiOperation({ + summary: + 'List all admin-managed feature flags with their current state for an organization', + }) + async list(@Param('orgId') orgId: string) { + const org = await db.organization.findUnique({ where: { id: orgId } }); + if (!org) throw new NotFoundException('Organization not found'); + + const flags = await this.service.listForOrganization(orgId); + return { data: flags }; + } + + @Patch(':orgId/feature-flags') + @ApiOperation({ + summary: 'Enable or disable a feature flag for an organization', + }) + @UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ) + async update( + @Param('orgId') orgId: string, + @Body() dto: UpdateFeatureFlagDto, + ) { + const org = await db.organization.findUnique({ where: { id: orgId } }); + if (!org) throw new NotFoundException('Organization not found'); + + const result = await this.service.setFlagForOrganization({ + orgId, + orgName: org.name, + flagKey: dto.flagKey, + enabled: dto.enabled, + }); + return { data: result }; + } +} diff --git a/apps/api/src/admin-feature-flags/admin-feature-flags.module.ts b/apps/api/src/admin-feature-flags/admin-feature-flags.module.ts new file mode 100644 index 000000000..c2d54c4a5 --- /dev/null +++ b/apps/api/src/admin-feature-flags/admin-feature-flags.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AdminFeatureFlagsController } from './admin-feature-flags.controller'; +import { AdminFeatureFlagsService } from './admin-feature-flags.service'; +import { PostHogService } from './posthog.service'; + +@Module({ + controllers: [AdminFeatureFlagsController], + providers: [AdminFeatureFlagsService, PostHogService], + exports: [AdminFeatureFlagsService, PostHogService], +}) +export class AdminFeatureFlagsModule {} diff --git a/apps/api/src/admin-feature-flags/admin-feature-flags.service.spec.ts b/apps/api/src/admin-feature-flags/admin-feature-flags.service.spec.ts new file mode 100644 index 000000000..588eea17f --- /dev/null +++ b/apps/api/src/admin-feature-flags/admin-feature-flags.service.spec.ts @@ -0,0 +1,146 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { AdminFeatureFlagsService } from './admin-feature-flags.service'; +import { PostHogService } from './posthog.service'; + +const originalEnv = { ...process.env }; + +describe('AdminFeatureFlagsService', () => { + let service: AdminFeatureFlagsService; + let posthog: { getClient: jest.Mock }; + let fetchSpy: jest.SpiedFunction; + + const mockClient = () => ({ + getAllFlags: jest.fn(), + groupIdentify: jest.fn(), + flush: jest.fn().mockResolvedValue(undefined), + }); + + beforeEach(async () => { + jest.clearAllMocks(); + process.env.POSTHOG_PERSONAL_API_KEY = 'phx_test'; + process.env.POSTHOG_PROJECT_ID = '123'; + process.env.POSTHOG_HOST = 'https://us.posthog.com'; + + posthog = { getClient: jest.fn() }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AdminFeatureFlagsService, + { provide: PostHogService, useValue: posthog }, + ], + }).compile(); + + service = module.get(AdminFeatureFlagsService); + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('{}', { status: 200 })) as any; + }); + + afterEach(() => { + fetchSpy.mockRestore(); + process.env = { ...originalEnv }; + }); + + describe('listForOrganization', () => { + it('returns [] when PostHog REST config is missing', async () => { + delete process.env.POSTHOG_PERSONAL_API_KEY; + const result = await service.listForOrganization('org_1'); + expect(result).toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('refuses to follow a pagination `next` URL pointing to a foreign origin', async () => { + const client = mockClient(); + client.getAllFlags.mockResolvedValue({}); + posthog.getClient.mockReturnValue(client); + + fetchSpy.mockImplementation((url) => { + const host = new URL(String(url)).host; + if (host === 'us.posthog.com') { + return Promise.resolve( + new Response( + JSON.stringify({ + results: [ + { id: 1, key: 'flag_a', name: '', active: true }, + ], + next: 'https://evil.example.com/api/feature_flags/?cursor=abc', + }), + { status: 200 }, + ), + ); + } + throw new Error(`fetch should not be called with ${url}`); + }); + + const result = await service.listForOrganization('org_1'); + + expect(result).toEqual([ + expect.objectContaining({ key: 'flag_a', enabled: false }), + ]); + // Only the first (trusted) page was fetched; evil origin was refused. + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + it('treats a multivariate variant string as enabled', async () => { + const client = mockClient(); + client.getAllFlags.mockResolvedValue({ + 'exp-flag': 'variant-a', + 'off-flag': false, + }); + posthog.getClient.mockReturnValue(client); + + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + results: [ + { id: 1, key: 'exp-flag', name: '', active: true }, + { id: 2, key: 'off-flag', name: '', active: true }, + ], + next: null, + }), + { status: 200 }, + ), + ); + + const result = await service.listForOrganization('org_1'); + + const exp = result.find((f) => f.key === 'exp-flag'); + const off = result.find((f) => f.key === 'off-flag'); + expect(exp?.enabled).toBe(true); + expect(off?.enabled).toBe(false); + }); + }); + + describe('setFlagForOrganization', () => { + it('throws BadRequest when PostHog is not configured', async () => { + posthog.getClient.mockReturnValue(null); + await expect( + service.setFlagForOrganization({ + orgId: 'org_1', + flagKey: 'f', + enabled: true, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('calls groupIdentify + flush with the flag key as a group property', async () => { + const client = mockClient(); + posthog.getClient.mockReturnValue(client); + + const result = await service.setFlagForOrganization({ + orgId: 'org_1', + orgName: 'Acme', + flagKey: 'is-timeline-enabled', + enabled: true, + }); + + expect(client.groupIdentify).toHaveBeenCalledWith({ + groupType: 'organization', + groupKey: 'org_1', + properties: { name: 'Acme', 'is-timeline-enabled': true }, + }); + expect(client.flush).toHaveBeenCalled(); + expect(result).toEqual({ key: 'is-timeline-enabled', enabled: true }); + }); + }); +}); diff --git a/apps/api/src/admin-feature-flags/admin-feature-flags.service.ts b/apps/api/src/admin-feature-flags/admin-feature-flags.service.ts new file mode 100644 index 000000000..192c5c669 --- /dev/null +++ b/apps/api/src/admin-feature-flags/admin-feature-flags.service.ts @@ -0,0 +1,197 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { PostHogService } from './posthog.service'; + +export interface FlagState { + key: string; + name: string; + description: string; + active: boolean; + enabled: boolean; + createdAt: string | null; +} + +interface PostHogFlagListItem { + id: number; + key: string; + name: string; + active: boolean; + deleted?: boolean; + created_at?: string; +} + +@Injectable() +export class AdminFeatureFlagsService { + private readonly logger = new Logger(AdminFeatureFlagsService.name); + + constructor(private readonly posthog: PostHogService) {} + + private getPostHogRestConfig(): { apiHost: string; apiKey: string; projectId: string } | null { + const apiKey = process.env.POSTHOG_PERSONAL_API_KEY; + const projectId = process.env.POSTHOG_PROJECT_ID; + const apiHost = + process.env.POSTHOG_HOST || + process.env.NEXT_PUBLIC_POSTHOG_HOST || + 'https://us.posthog.com'; + + if (!apiKey || !projectId) return null; + return { apiHost, apiKey, projectId }; + } + + private async fetchFlagsFromPostHog(): Promise { + const config = this.getPostHogRestConfig(); + if (!config) return []; + + const results: PostHogFlagListItem[] = []; + const apiHost = config.apiHost.replace(/\/$/, ''); + const baseUrl = `${apiHost}/api/projects/${config.projectId}/feature_flags/`; + let nextUrl: string | null = `${baseUrl}?limit=200`; + // Hard cap so a misbehaving cursor can't loop forever. + const maxPages = 25; + + // Only follow `next` links that point back to the configured PostHog + // host. Without this check, a malicious PostHog response could redirect + // pagination to an attacker-controlled host and leak the Authorization + // header (which carries the personal API key) via SSRF. + let expectedOrigin: string; + try { + expectedOrigin = new URL(apiHost).origin; + } catch { + this.logger.error( + `POSTHOG_HOST is not a valid URL: "${apiHost}". Skipping flag fetch.`, + ); + return []; + } + + try { + for (let page = 0; page < maxPages && nextUrl; page++) { + let parsedNext: URL; + try { + parsedNext = new URL(nextUrl); + } catch { + this.logger.error(`Invalid PostHog pagination URL: ${nextUrl}`); + break; + } + if (parsedNext.origin !== expectedOrigin) { + this.logger.error( + `Refusing to follow PostHog pagination to foreign origin: ${parsedNext.origin}`, + ); + break; + } + + const response: Response = await fetch(parsedNext.toString(), { + headers: { + Authorization: `Bearer ${config.apiKey}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + this.logger.error( + `PostHog flag list failed: ${response.status} ${await response.text()}`, + ); + break; + } + + const data = (await response.json()) as { + results?: PostHogFlagListItem[]; + next?: string | null; + }; + for (const f of data.results ?? []) { + if (!f.deleted) results.push(f); + } + nextUrl = data.next ?? null; + } + } catch (err) { + this.logger.error('Failed to fetch flags from PostHog REST API', err); + } + + return results; + } + + async listForOrganization(orgId: string): Promise { + const flags = await this.fetchFlagsFromPostHog(); + const client = this.posthog.getClient(); + + if (flags.length === 0 || !client) { + return []; + } + + // Evaluate all flags in one network call instead of one per flag. + const distinctId = `admin-check:${orgId}`; + let evaluated: Record = {}; + try { + evaluated = await client.getAllFlags(distinctId, { + groups: { organization: orgId }, + disableGeoip: true, + }); + } catch (err) { + this.logger.error(`Failed to evaluate flags for org ${orgId}`, err); + } + + // A flag is "on" for this org when the evaluator returns true (boolean + // flags) OR any string variant key (multivariate flags — PostHog returns + // the variant name for enabled variants, `false` for disabled). + const isEnabled = (value: string | boolean | undefined): boolean => + value === true || (typeof value === 'string' && value.length > 0); + + return flags + .map((flag) => ({ + key: flag.key, + name: flag.key, + description: flag.name ?? '', + active: flag.active, + enabled: isEnabled(evaluated[flag.key]), + createdAt: flag.created_at ?? null, + })) + .sort((a, b) => { + // Newest first; fall back to key for ties / missing dates. + const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; + if (aTime !== bTime) return bTime - aTime; + return a.key.localeCompare(b.key); + }); + } + + async setFlagForOrganization({ + orgId, + orgName, + flagKey, + enabled, + }: { + orgId: string; + orgName?: string; + flagKey: string; + enabled: boolean; + }): Promise<{ key: string; enabled: boolean }> { + if (!flagKey) { + throw new BadRequestException('flagKey is required'); + } + + const client = this.posthog.getClient(); + if (!client) { + throw new BadRequestException('PostHog is not configured on this environment'); + } + + try { + client.groupIdentify({ + groupType: 'organization', + groupKey: orgId, + properties: { + ...(orgName ? { name: orgName } : {}), + [flagKey]: enabled, + }, + }); + await client.flush(); + } catch (err) { + this.logger.error( + `Failed to set flag ${flagKey}=${enabled} for org ${orgId}`, + err, + ); + throw new BadRequestException( + 'Unable to update feature flag via PostHog. Please try again.', + ); + } + + return { key: flagKey, enabled }; + } +} diff --git a/apps/api/src/admin-feature-flags/dto/update-feature-flag.dto.ts b/apps/api/src/admin-feature-flags/dto/update-feature-flag.dto.ts new file mode 100644 index 000000000..c2520805e --- /dev/null +++ b/apps/api/src/admin-feature-flags/dto/update-feature-flag.dto.ts @@ -0,0 +1,10 @@ +import { IsBoolean, IsString, IsNotEmpty } from 'class-validator'; + +export class UpdateFeatureFlagDto { + @IsString() + @IsNotEmpty() + flagKey!: string; + + @IsBoolean() + enabled!: boolean; +} diff --git a/apps/api/src/admin-feature-flags/posthog.service.ts b/apps/api/src/admin-feature-flags/posthog.service.ts new file mode 100644 index 000000000..942e155fe --- /dev/null +++ b/apps/api/src/admin-feature-flags/posthog.service.ts @@ -0,0 +1,42 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { PostHog } from 'posthog-node'; + +@Injectable() +export class PostHogService implements OnModuleDestroy { + private readonly logger = new Logger(PostHogService.name); + private client: PostHog | null = null; + private initialized = false; + + getClient(): PostHog | null { + if (this.initialized) return this.client; + + // Prefer POSTHOG_API_KEY (explicit backend config) over the + // NEXT_PUBLIC_* fallback so frontend env wiring can't accidentally + // override the server key if both happen to be present. + const apiKey = process.env.POSTHOG_API_KEY || process.env.NEXT_PUBLIC_POSTHOG_KEY; + const apiHost = + process.env.POSTHOG_HOST || + process.env.NEXT_PUBLIC_POSTHOG_HOST || + 'https://us.i.posthog.com'; + + this.initialized = true; + + if (!apiKey) { + this.logger.warn('PostHog API key not configured; feature flag operations will be no-ops'); + return null; + } + + this.client = new PostHog(apiKey, { + host: apiHost, + flushAt: 1, + flushInterval: 0, + }); + return this.client; + } + + async onModuleDestroy() { + if (this.client) { + await this.client.shutdown(); + } + } +} diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3bc246e5a..b6ada5eb9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -49,6 +49,8 @@ import { SecretsModule } from './secrets/secrets.module'; import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module'; import { StripeModule } from './stripe/stripe.module'; import { AdminOrganizationsModule } from './admin-organizations/admin-organizations.module'; +import { AdminFeatureFlagsModule } from './admin-feature-flags/admin-feature-flags.module'; +import { TimelinesModule } from './timelines/timelines.module'; @Module({ imports: [ @@ -110,6 +112,8 @@ import { AdminOrganizationsModule } from './admin-organizations/admin-organizati SecurityPenetrationTestsModule, StripeModule, AdminOrganizationsModule, + AdminFeatureFlagsModule, + TimelinesModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/evidence-forms/evidence-forms.module.ts b/apps/api/src/evidence-forms/evidence-forms.module.ts index 333ffaa5d..a2d69ad34 100644 --- a/apps/api/src/evidence-forms/evidence-forms.module.ts +++ b/apps/api/src/evidence-forms/evidence-forms.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; import { AttachmentsModule } from '@/attachments/attachments.module'; import { AuthModule } from '@/auth/auth.module'; +import { TimelinesModule } from '../timelines/timelines.module'; import { EvidenceFormsController } from './evidence-forms.controller'; import { EvidenceFormsService } from './evidence-forms.service'; @Module({ - imports: [AuthModule, AttachmentsModule], + imports: [AuthModule, AttachmentsModule, TimelinesModule], controllers: [EvidenceFormsController], providers: [EvidenceFormsService], exports: [EvidenceFormsService], diff --git a/apps/api/src/evidence-forms/evidence-forms.service.spec.ts b/apps/api/src/evidence-forms/evidence-forms.service.spec.ts index 670ee8d6a..26b7752d9 100644 --- a/apps/api/src/evidence-forms/evidence-forms.service.spec.ts +++ b/apps/api/src/evidence-forms/evidence-forms.service.spec.ts @@ -59,7 +59,9 @@ describe('EvidenceFormsService', () => { getPresignedDownloadUrl: jest.fn(), } as unknown as AttachmentsService; - const service = new EvidenceFormsService(attachmentsServiceMock); + const timelinesServiceMock = {} as unknown as import('../timelines/timelines.service').TimelinesService; + + const service = new EvidenceFormsService(attachmentsServiceMock, timelinesServiceMock); const mockedDb = db as unknown as MockDb; beforeEach(() => { diff --git a/apps/api/src/evidence-forms/evidence-forms.service.ts b/apps/api/src/evidence-forms/evidence-forms.service.ts index 53e6c6eda..b1f88934c 100644 --- a/apps/api/src/evidence-forms/evidence-forms.service.ts +++ b/apps/api/src/evidence-forms/evidence-forms.service.ts @@ -8,6 +8,7 @@ import { import { BadRequestException, Injectable, + Logger, NotFoundException, UnauthorizedException, } from '@nestjs/common'; @@ -20,6 +21,8 @@ import { type EvidenceFormFieldDefinition, type EvidenceFormType, } from './evidence-forms.definitions'; +import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; +import { TimelinesService } from '../timelines/timelines.service'; const listQuerySchema = z.object({ search: z.string().trim().optional(), @@ -128,7 +131,12 @@ function normalizeSubmissionFormType< @Injectable() export class EvidenceFormsService { - constructor(private readonly attachmentsService: AttachmentsService) {} + private readonly logger = new Logger(EvidenceFormsService.name); + + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly timelinesService: TimelinesService, + ) {} private requireJwtUser(authContext: AuthContext): string { if (authContext.isApiKey || authContext.authType === 'api-key') { @@ -407,6 +415,14 @@ export class EvidenceFormsService { where: { id: params.submissionId }, }); + // Check timeline auto-completion after evidence deletion + checkAutoCompletePhases({ + organizationId: params.organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { success: true, id: params.submissionId }; } @@ -464,7 +480,7 @@ export class EvidenceFormsService { throw new BadRequestException(message); } - return await db.evidenceSubmission + const submission = await db.evidenceSubmission .create({ data: { organizationId: params.organizationId, @@ -483,6 +499,16 @@ export class EvidenceFormsService { }, }) .then(normalizeSubmissionFormType); + + // Check timeline auto-completion after evidence submission + checkAutoCompletePhases({ + organizationId: params.organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + + return submission; } async uploadFile(params: { @@ -576,7 +602,7 @@ export class EvidenceFormsService { const downloadUrl = await this.attachmentsService.getPresignedDownloadUrl(fileKey); - return await db.evidenceSubmission + const submission = await db.evidenceSubmission .create({ data: { organizationId: params.organizationId, @@ -602,6 +628,16 @@ export class EvidenceFormsService { }, }) .then(normalizeSubmissionFormType); + + // Check timeline auto-completion after evidence upload submission + checkAutoCompletePhases({ + organizationId: params.organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + + return submission; } async exportCsv(params: { diff --git a/apps/api/src/findings/findings.module.ts b/apps/api/src/findings/findings.module.ts index 975b20351..642f8a49b 100644 --- a/apps/api/src/findings/findings.module.ts +++ b/apps/api/src/findings/findings.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { TimelinesModule } from '../timelines/timelines.module'; import { NovuService } from '../notifications/novu.service'; import { FindingAuditService } from './finding-audit.service'; import { FindingNotifierService } from './finding-notifier.service'; @@ -7,7 +8,7 @@ import { FindingsController } from './findings.controller'; import { FindingsService } from './findings.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, TimelinesModule], controllers: [FindingsController], providers: [ FindingsService, diff --git a/apps/api/src/findings/findings.service.ts b/apps/api/src/findings/findings.service.ts index 6d70b5de6..e5f1ff961 100644 --- a/apps/api/src/findings/findings.service.ts +++ b/apps/api/src/findings/findings.service.ts @@ -21,6 +21,9 @@ import { CreateFindingDto } from './dto/create-finding.dto'; import { UpdateFindingDto } from './dto/update-finding.dto'; import { FindingAuditService } from './finding-audit.service'; import { FindingNotifierService } from './finding-notifier.service'; +import { type EvidenceFormType } from '@/evidence-forms/evidence-forms.definitions'; +import { TimelinesService } from '../timelines/timelines.service'; +import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; // Target keys on Finding. Exactly one of these (or `area`) must be set per finding. const TARGET_KEYS = [ @@ -72,6 +75,7 @@ export class FindingsService { constructor( private readonly findingAuditService: FindingAuditService, private readonly findingNotifierService: FindingNotifierService, + private readonly timelinesService: TimelinesService, ) {} private normalizeFindingFormTypes< @@ -337,6 +341,18 @@ export class FindingsService { actorName, }); + // A new open finding lowers the AUTO_FINDINGS completion ratio, which + // can regress a previously COMPLETED phase back to IN_PROGRESS. + void checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((error) => { + this.logger.warn( + `Failed to reconcile AUTO_FINDINGS phases after finding ${finding.id} create`, + error instanceof Error ? error.message : String(error), + ); + }); + this.logger.log(`Created finding ${finding.id} for ${target.kind}`); return this.normalizeFindingFormTypes(finding); } @@ -444,6 +460,20 @@ export class FindingsService { actorName, newStatus: updateDto.status, }); + + // Any status transition can move AUTO_FINDINGS metric in either + // direction — advance when everything is closed, regress when a + // closed finding is reopened. checkAutoCompletePhases also triggers + // the regression reconciliation pass. + void checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((error) => { + this.logger.warn( + `Failed to reconcile AUTO_FINDINGS phases after finding ${findingId} status change`, + error instanceof Error ? error.message : String(error), + ); + }); } this.logger.log( @@ -466,6 +496,18 @@ export class FindingsService { // `extractFindingDescription`. No explicit call here to avoid a // duplicate activity entry. + // Removing a finding shifts the AUTO_FINDINGS completion ratio and can + // advance a phase whose only remaining open finding was deleted. + void checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((error) => { + this.logger.warn( + `Failed to reconcile AUTO_FINDINGS phases after finding ${findingId} delete`, + error instanceof Error ? error.message : String(error), + ); + }); + this.logger.log(`Deleted finding ${findingId}`); return { message: 'Finding deleted successfully', diff --git a/apps/api/src/frameworks/frameworks-scores.helper.spec.ts b/apps/api/src/frameworks/frameworks-scores.helper.spec.ts new file mode 100644 index 000000000..7cb00fbaa --- /dev/null +++ b/apps/api/src/frameworks/frameworks-scores.helper.spec.ts @@ -0,0 +1,277 @@ +jest.mock('@db', () => ({ + db: { + policy: { findMany: jest.fn() }, + task: { findMany: jest.fn() }, + member: { findMany: jest.fn() }, + onboarding: { findUnique: jest.fn() }, + organization: { findUnique: jest.fn() }, + frameworkInstance: { findFirst: jest.fn() }, + employeeTrainingVideoCompletion: { findMany: jest.fn() }, + device: { findMany: jest.fn() }, + fleetPolicyResult: { findMany: jest.fn() }, + evidenceSubmission: { groupBy: jest.fn() }, + finding: { findMany: jest.fn() }, + }, +})); + +jest.mock('../utils/compliance-filters', () => ({ + filterComplianceMembers: jest.fn(), +})); + +import { db } from '@db'; +import { filterComplianceMembers } from '../utils/compliance-filters'; +import { getOverviewScores } from './frameworks-scores.helper'; + +const mockDb = db as jest.Mocked; +const mockFilterComplianceMembers = + filterComplianceMembers as jest.MockedFunction< + typeof filterComplianceMembers + >; + +describe('frameworks-scores.helper', () => { + beforeEach(() => { + jest.clearAllMocks(); + + (mockDb.policy.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.task.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.onboarding.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.frameworkInstance.findFirst as jest.Mock).mockResolvedValue(null); + ( + mockDb.employeeTrainingVideoCompletion.findMany as jest.Mock + ).mockResolvedValue([]); + (mockDb.fleetPolicyResult.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.evidenceSubmission.groupBy as jest.Mock).mockResolvedValue([]); + (mockDb.finding.findMany as jest.Mock).mockResolvedValue([]); + }); + + it('requires installed device for people completion when device agent step is enabled', async () => { + const members: Array<{ + id: string; + role: string; + deactivated: boolean; + user: { id: string; email: string; role: string }; + }> = [ + { + id: 'mem_1', + role: 'owner', + deactivated: false, + user: { id: 'usr_1', email: 'a@example.com', role: 'owner' }, + }, + { + id: 'mem_2', + role: 'owner', + deactivated: false, + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + + (mockDb.member.findMany as jest.Mock) + .mockResolvedValueOnce(members) + .mockResolvedValueOnce([]); + mockFilterComplianceMembers.mockResolvedValue(members); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: true, + }); + (mockDb.device.findMany as jest.Mock).mockResolvedValue([ + { memberId: 'mem_1' }, + ]); + + const scores = await getOverviewScores('org_1'); + + expect(scores.people.total).toBe(2); + expect(scores.people.completed).toBe(1); + const deviceFindManyCalls = (mockDb.device.findMany as jest.Mock).mock + .calls; + expect(deviceFindManyCalls).toContainEqual([ + { + where: { + organizationId: 'org_1', + memberId: { in: ['mem_1', 'mem_2'] }, + }, + select: { memberId: true }, + distinct: ['memberId'], + }, + ]); + }); + + it('skips installed device requirement when device agent step is disabled', async () => { + const members: Array<{ + id: string; + role: string; + deactivated: boolean; + user: { id: string; email: string; role: string }; + }> = [ + { + id: 'mem_1', + role: 'owner', + deactivated: false, + user: { id: 'usr_1', email: 'a@example.com', role: 'owner' }, + }, + { + id: 'mem_2', + role: 'owner', + deactivated: false, + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + + (mockDb.member.findMany as jest.Mock).mockResolvedValue(members); + mockFilterComplianceMembers.mockResolvedValue(members); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + }); + + const scores = await getOverviewScores('org_1'); + + expect(scores.people.total).toBe(2); + expect(scores.people.completed).toBe(2); + const deviceFindManyCalls = (mockDb.device.findMany as jest.Mock).mock + .calls; + expect(deviceFindManyCalls).toHaveLength(0); + }); + + it('counts Fleet-managed devices when device agent step is enabled', async () => { + const members: Array<{ + id: string; + userId: string; + role: string; + deactivated: boolean; + user: { id: string; email: string; role: string }; + }> = [ + { + id: 'mem_1', + userId: 'usr_1', + role: 'owner', + deactivated: false, + user: { id: 'usr_1', email: 'a@example.com', role: 'owner' }, + }, + { + id: 'mem_2', + userId: 'usr_2', + role: 'owner', + deactivated: false, + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + + (mockDb.member.findMany as jest.Mock) + .mockResolvedValueOnce(members) + .mockResolvedValueOnce([{ id: 'mem_2', userId: 'usr_2' }]); + mockFilterComplianceMembers.mockResolvedValue(members); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: true, + }); + (mockDb.device.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.fleetPolicyResult.findMany as jest.Mock).mockResolvedValue([ + { userId: 'usr_2' }, + ]); + + const scores = await getOverviewScores('org_1'); + + expect(scores.people.total).toBe(2); + expect(scores.people.completed).toBe(1); + const fleetPolicyCalls = (mockDb.fleetPolicyResult.findMany as jest.Mock) + .mock.calls; + expect(fleetPolicyCalls).toContainEqual([ + { + where: { + organizationId: 'org_1', + userId: { in: ['usr_1', 'usr_2'] }, + }, + select: { userId: true }, + distinct: ['userId'], + }, + ]); + }); + + it('requires all security training videos when security training step is enabled', async () => { + const members: Array<{ + id: string; + role: string; + deactivated: boolean; + user: { id: string; email: string; role: string }; + }> = [ + { + id: 'mem_1', + role: 'owner', + deactivated: false, + user: { id: 'usr_1', email: 'a@example.com', role: 'owner' }, + }, + { + id: 'mem_2', + role: 'owner', + deactivated: false, + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + + (mockDb.member.findMany as jest.Mock).mockResolvedValue(members); + mockFilterComplianceMembers.mockResolvedValue(members); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + securityTrainingStepEnabled: true, + deviceAgentStepEnabled: false, + }); + ( + mockDb.employeeTrainingVideoCompletion.findMany as jest.Mock + ).mockResolvedValue([ + { memberId: 'mem_1', videoId: 'sat-1', completedAt: new Date() }, + { memberId: 'mem_1', videoId: 'sat-2', completedAt: new Date() }, + { memberId: 'mem_1', videoId: 'sat-3', completedAt: new Date() }, + { memberId: 'mem_1', videoId: 'sat-4', completedAt: new Date() }, + { memberId: 'mem_1', videoId: 'sat-5', completedAt: new Date() }, + { memberId: 'mem_2', videoId: 'sat-1', completedAt: new Date() }, + { memberId: 'mem_2', videoId: 'sat-2', completedAt: new Date() }, + { memberId: 'mem_2', videoId: 'sat-3', completedAt: new Date() }, + { memberId: 'mem_2', videoId: 'sat-4', completedAt: new Date() }, + { memberId: 'mem_2', videoId: 'sat-5', completedAt: null }, + ]); + + const scores = await getOverviewScores('org_1'); + + expect(scores.people.total).toBe(2); + expect(scores.people.completed).toBe(1); + expect( + mockDb.employeeTrainingVideoCompletion.findMany, + ).toHaveBeenCalledWith({ + where: { memberId: { in: ['mem_1', 'mem_2'] } }, + }); + }); + + it('skips security training requirement when security training step is disabled', async () => { + const members: Array<{ + id: string; + role: string; + deactivated: boolean; + user: { id: string; email: string; role: string }; + }> = [ + { + id: 'mem_1', + role: 'owner', + deactivated: false, + user: { id: 'usr_1', email: 'a@example.com', role: 'owner' }, + }, + { + id: 'mem_2', + role: 'owner', + deactivated: false, + user: { id: 'usr_2', email: 'b@example.com', role: 'owner' }, + }, + ]; + + (mockDb.member.findMany as jest.Mock).mockResolvedValue(members); + mockFilterComplianceMembers.mockResolvedValue(members); + (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({ + securityTrainingStepEnabled: false, + deviceAgentStepEnabled: false, + }); + + const scores = await getOverviewScores('org_1'); + + expect(scores.people.total).toBe(2); + expect(scores.people.completed).toBe(2); + expect(mockDb.employeeTrainingVideoCompletion.findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts index 9c233b164..b62974278 100644 --- a/apps/api/src/frameworks/frameworks-scores.helper.ts +++ b/apps/api/src/frameworks/frameworks-scores.helper.ts @@ -27,7 +27,10 @@ export async function getOverviewScores(organizationId: string) { }), db.organization.findUnique({ where: { id: organizationId }, - select: { securityTrainingStepEnabled: true }, + select: { + securityTrainingStepEnabled: true, + deviceAgentStepEnabled: true, + }, }), db.frameworkInstance.findFirst({ where: { organizationId, framework: { name: 'HIPAA' } }, @@ -36,6 +39,7 @@ export async function getOverviewScores(organizationId: string) { ]); const securityTrainingStepEnabled = org?.securityTrainingStepEnabled === true; + const deviceAgentStepEnabled = org?.deviceAgentStepEnabled === true; const hasHipaaFramework = !!hipaaInstance; // Policy breakdown @@ -70,7 +74,55 @@ export async function getOverviewScores(organizationId: string) { ); const memberIds = activeEmployees.map((e) => e.id); + const memberUserIds = activeEmployees + .map((e) => e.userId) + .filter((id): id is string => !!id); const needsCompletions = securityTrainingStepEnabled || hasHipaaFramework; + let membersWithInstalledDevices = new Set(); + + if (deviceAgentStepEnabled) { + const [installedDevices, membersWithFleetLabels, fleetPolicyResults] = + await Promise.all([ + db.device.findMany({ + where: { + organizationId, + memberId: { in: memberIds }, + }, + select: { memberId: true }, + distinct: ['memberId'], + }), + db.member.findMany({ + where: { + organizationId, + id: { in: memberIds }, + NOT: { fleetDmLabelId: null }, + }, + select: { id: true, userId: true }, + }), + memberUserIds.length > 0 + ? db.fleetPolicyResult.findMany({ + where: { + organizationId, + userId: { in: memberUserIds }, + }, + select: { userId: true }, + distinct: ['userId'], + }) + : Promise.resolve([]), + ]); + + const fleetUserIdsWithData = new Set( + fleetPolicyResults.map((result) => result.userId), + ); + const memberIdsWithFleetData = membersWithFleetLabels + .filter((member) => fleetUserIdsWithData.has(member.userId)) + .map((member) => member.id); + + membersWithInstalledDevices = new Set([ + ...installedDevices.map((device) => device.memberId), + ...memberIdsWithFleetData, + ]); + } const trainingCompletions = needsCompletions ? await db.employeeTrainingVideoCompletion.findMany({ @@ -94,11 +146,15 @@ export async function getOverviewScores(organizationId: string) { const hasCompletedHipaa = hasHipaaFramework ? completedVideoIds.includes(HIPAA_TRAINING_ID) : true; + const hasInstalledDevice = deviceAgentStepEnabled + ? membersWithInstalledDevices.has(emp.id) + : true; if ( hasAcceptedAllPolicies && hasCompletedAllTraining && - hasCompletedHipaa + hasCompletedHipaa && + hasInstalledDevice ) { completedMembers++; } diff --git a/apps/api/src/frameworks/frameworks-timeline.helper.spec.ts b/apps/api/src/frameworks/frameworks-timeline.helper.spec.ts new file mode 100644 index 000000000..b26169104 --- /dev/null +++ b/apps/api/src/frameworks/frameworks-timeline.helper.spec.ts @@ -0,0 +1,196 @@ +import { checkAutoCompletePhases } from './frameworks-timeline.helper'; + +jest.mock('@db', () => ({ + db: { + timelinePhase: { + findMany: jest.fn(), + }, + frameworkInstance: { + findMany: jest.fn(), + }, + task: { + findMany: jest.fn(), + }, + finding: { + findMany: jest.fn(), + }, + }, + PhaseCompletionType: { + AUTO_TASKS: 'AUTO_TASKS', + AUTO_POLICIES: 'AUTO_POLICIES', + AUTO_PEOPLE: 'AUTO_PEOPLE', + AUTO_FINDINGS: 'AUTO_FINDINGS', + AUTO_UPLOAD: 'AUTO_UPLOAD', + MANUAL: 'MANUAL', + }, + TimelinePhaseStatus: { + PENDING: 'PENDING', + IN_PROGRESS: 'IN_PROGRESS', + COMPLETED: 'COMPLETED', + }, + TimelineStatus: { + DRAFT: 'DRAFT', + ACTIVE: 'ACTIVE', + PAUSED: 'PAUSED', + COMPLETED: 'COMPLETED', + }, + FindingStatus: { + open: 'open', + ready_for_review: 'ready_for_review', + needs_revision: 'needs_revision', + closed: 'closed', + }, + FindingType: { + soc2: 'soc2', + iso27001: 'iso27001', + }, +})); + +jest.mock('./frameworks-scores.helper', () => ({ + getOverviewScores: jest.fn().mockResolvedValue({ + policies: { total: 1, published: 0 }, + tasks: { total: 1, done: 0 }, + people: { total: 1, completed: 0 }, + }), +})); + +import { db } from '@db'; + +const mockDb = db as jest.Mocked; + +describe('frameworks-timeline.helper', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('auto-completes AUTO_FINDINGS phase only when all findings for that framework type are closed', async () => { + const timelinesService = { + completePhase: jest.fn().mockResolvedValue({ id: 'tli_soc2' }), + }; + + (mockDb.timelinePhase.findMany as jest.Mock).mockResolvedValue([ + { + id: 'phase_soc2', + completionType: 'AUTO_FINDINGS', + startDate: new Date('2026-04-10T00:00:00.000Z'), + instance: { + id: 'tli_soc2', + organizationId: 'org_1', + frameworkInstanceId: 'fi_soc2', + frameworkInstance: { framework: { name: 'SOC 2' } }, + }, + }, + { + id: 'phase_iso', + completionType: 'AUTO_FINDINGS', + startDate: new Date('2026-04-10T00:00:00.000Z'), + instance: { + id: 'tli_iso', + organizationId: 'org_1', + frameworkInstanceId: 'fi_iso', + frameworkInstance: { framework: { name: 'ISO 27001' } }, + }, + }, + ]); + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { id: 'fi_soc2', requirementsMapped: [] }, + { id: 'fi_iso', requirementsMapped: [] }, + ]); + (mockDb.finding.findMany as jest.Mock).mockResolvedValue([ + { + type: 'soc2', + status: 'closed', + createdAt: new Date('2026-04-11T00:00:00.000Z'), + }, + { + type: 'iso27001', + status: 'open', + createdAt: new Date('2026-04-11T00:00:00.000Z'), + }, + ]); + + await checkAutoCompletePhases({ + organizationId: 'org_1', + timelinesService: timelinesService as any, + }); + + expect(timelinesService.completePhase).toHaveBeenCalledTimes(1); + expect(timelinesService.completePhase).toHaveBeenCalledWith( + 'tli_soc2', + 'phase_soc2', + 'org_1', + ); + }); + + it('does not auto-complete AUTO_FINDINGS phase when no findings exist for that framework type', async () => { + const timelinesService = { + completePhase: jest.fn().mockResolvedValue({ id: 'tli_soc2' }), + }; + + (mockDb.timelinePhase.findMany as jest.Mock).mockResolvedValue([ + { + id: 'phase_soc2', + completionType: 'AUTO_FINDINGS', + startDate: new Date('2026-04-10T00:00:00.000Z'), + instance: { + id: 'tli_soc2', + organizationId: 'org_1', + frameworkInstanceId: 'fi_soc2', + frameworkInstance: { framework: { name: 'SOC 2' } }, + }, + }, + ]); + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { id: 'fi_soc2', requirementsMapped: [] }, + ]); + (mockDb.finding.findMany as jest.Mock).mockResolvedValue([]); + + await checkAutoCompletePhases({ + organizationId: 'org_1', + timelinesService: timelinesService as any, + }); + + expect(timelinesService.completePhase).not.toHaveBeenCalled(); + }); + + it('maps SOC 2 v.1 AUTO_FINDINGS phases to SOC 2 finding type', async () => { + const timelinesService = { + completePhase: jest.fn().mockResolvedValue({ id: 'tli_soc2_v1' }), + }; + + (mockDb.timelinePhase.findMany as jest.Mock).mockResolvedValue([ + { + id: 'phase_soc2_v1', + completionType: 'AUTO_FINDINGS', + startDate: new Date('2026-04-10T00:00:00.000Z'), + instance: { + id: 'tli_soc2_v1', + organizationId: 'org_1', + frameworkInstanceId: 'fi_soc2_v1', + frameworkInstance: { framework: { name: 'SOC 2 v.1' } }, + }, + }, + ]); + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { id: 'fi_soc2_v1', requirementsMapped: [] }, + ]); + (mockDb.finding.findMany as jest.Mock).mockResolvedValue([ + { + type: 'soc2', + status: 'closed', + createdAt: new Date('2026-04-11T00:00:00.000Z'), + }, + ]); + + await checkAutoCompletePhases({ + organizationId: 'org_1', + timelinesService: timelinesService as any, + }); + + expect(timelinesService.completePhase).toHaveBeenCalledWith( + 'tli_soc2_v1', + 'phase_soc2_v1', + 'org_1', + ); + }); +}); diff --git a/apps/api/src/frameworks/frameworks-timeline.helper.ts b/apps/api/src/frameworks/frameworks-timeline.helper.ts new file mode 100644 index 000000000..fc604bb29 --- /dev/null +++ b/apps/api/src/frameworks/frameworks-timeline.helper.ts @@ -0,0 +1,362 @@ +import { Logger } from '@nestjs/common'; +import { + db, + FindingStatus, + FindingType, + PhaseCompletionType, + TimelinePhaseStatus, + TimelineStatus, +} from '@db'; +import { TimelinesService } from '../timelines/timelines.service'; +import { getOverviewScores } from './frameworks-scores.helper'; + +const logger = new Logger('FrameworksTimelineHelper'); + +type TaskForAutoCompletion = { + id: string; + status: string; + controls: Array<{ id: string }>; +}; + +const FRAMEWORK_TO_FINDING_TYPE: Record = { + SOC2: FindingType.soc2, + SOC2V1: FindingType.soc2, + ISO27001: FindingType.iso27001, +}; + +function getFindingTypeForFrameworkName( + frameworkName: string | null | undefined, +): FindingType | undefined { + if (!frameworkName) return undefined; + const normalized = frameworkName + .toUpperCase() + .replace(/[^A-Z0-9]/g, ''); + return FRAMEWORK_TO_FINDING_TYPE[normalized]; +} + +/** + * For each framework editor ID, look up the corresponding FrameworkInstance + * and attempt to create a DRAFT timeline from the matching template. + * Failures are logged but never propagate -- the framework add must succeed. + */ +export async function createTimelinesForFrameworks({ + organizationId, + frameworkEditorIds, + timelinesService, +}: { + organizationId: string; + frameworkEditorIds: string[]; + timelinesService: TimelinesService; +}) { + const instances = await db.frameworkInstance.findMany({ + where: { + organizationId, + frameworkId: { in: frameworkEditorIds }, + }, + select: { + id: true, + framework: { select: { name: true } }, + }, + }); + + for (const instance of instances) { + // Custom frameworks don't have a platform Framework record; fall back to + // the single primary track. + const timelinesToCreate = + instance.framework?.name === 'SOC 2' + ? [ + { cycleNumber: 1, trackKey: 'soc2_type1' }, + { cycleNumber: 1, trackKey: 'soc2_type2' }, + ] + : [{ cycleNumber: 1, trackKey: 'primary' }]; + + // Try each track independently so a failure on one track (e.g. + // soc2_type1) doesn't silently skip the other (soc2_type2). Partial + // state is also repaired on the next /timelines read via backfill. + for (const timeline of timelinesToCreate) { + try { + await timelinesService.createFromTemplate({ + organizationId, + frameworkInstanceId: instance.id, + cycleNumber: timeline.cycleNumber, + trackKey: timeline.trackKey, + }); + } catch (err) { + logger.warn( + `Failed to create ${timeline.trackKey} timeline for framework instance ${instance.id}`, + err instanceof Error ? err.message : err, + ); + } + } + } +} + +/** + * Check all active timeline instances for this org. For any IN_PROGRESS + * AUTO_* phase, verify whether the linked auto-completion criteria + * are satisfied and auto-complete the phase if so. + */ +export async function checkAutoCompletePhases({ + organizationId, + timelinesService, +}: { + organizationId: string; + timelinesService: TimelinesService; +}) { + try { + await runPhaseAdvancement({ organizationId, timelinesService }); + } finally { + // Always reconcile — handles regressions on COMPLETED phases, which + // runPhaseAdvancement skips via its early return when no phase is + // IN_PROGRESS. Without this, a metric drop on an already-completed + // phase would be missed by every event hook. + await timelinesService + .reconcileAutoPhasesForOrganization(organizationId) + .catch((err) => + logger.warn( + 'reconcileAutoPhasesForOrganization failed', + err instanceof Error ? err.message : err, + ), + ); + } +} + +async function runPhaseAdvancement({ + organizationId, + timelinesService, +}: { + organizationId: string; + timelinesService: TimelinesService; +}) { + const autoTypes = [ + PhaseCompletionType.AUTO_TASKS, + PhaseCompletionType.AUTO_POLICIES, + PhaseCompletionType.AUTO_PEOPLE, + PhaseCompletionType.AUTO_FINDINGS, + ]; + + const phases = await db.timelinePhase.findMany({ + where: { + completionType: { in: autoTypes }, + status: TimelinePhaseStatus.IN_PROGRESS, + instance: { + organizationId, + status: TimelineStatus.ACTIVE, + }, + }, + include: { + instance: { + select: { + id: true, + organizationId: true, + frameworkInstanceId: true, + frameworkInstance: { + select: { + framework: { + select: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + if (phases.length === 0) return; + + // Collect unique framework instance IDs + const frameworkInstanceIds = [ + ...new Set(phases.map((p) => p.instance.frameworkInstanceId)), + ]; + + // Fetch framework instances with controls for scoring + const frameworkInstances = await db.frameworkInstance.findMany({ + where: { id: { in: frameworkInstanceIds }, organizationId }, + include: { + requirementsMapped: { + include: { + control: { + include: { + policies: { + select: { id: true, name: true, status: true }, + }, + }, + }, + }, + }, + }, + }); + + // Deduplicate controls per framework instance + const frameworkControlsMap = new Map(); + const frameworkControlIdsMap = new Map>(); + for (const fi of frameworkInstances) { + const controlsMap = new Map(); + for (const rm of fi.requirementsMapped) { + if (rm.control && !controlsMap.has(rm.control.id)) { + controlsMap.set(rm.control.id, { id: rm.control.id }); + } + } + const controls = Array.from(controlsMap.values()); + frameworkControlsMap.set(fi.id, controls); + frameworkControlIdsMap.set(fi.id, new Set(controls.map((c) => c.id))); + } + + // Fetch all tasks linked to any of these controls + const allControlIds = [ + ...new Set( + Array.from(frameworkControlsMap.values()) + .flat() + .map((c) => c.id), + ), + ]; + + const hasAutoFindingsPhase = phases.some( + (phase) => phase.completionType === PhaseCompletionType.AUTO_FINDINGS, + ); + + if (allControlIds.length === 0 && !hasAutoFindingsPhase) return; + + // Fetch tasks and overview scores in parallel + const tasksPromise: Promise = + allControlIds.length > 0 + ? db.task.findMany({ + where: { + organizationId, + controls: { some: { id: { in: allControlIds } } }, + }, + select: { + id: true, + status: true, + controls: { select: { id: true } }, + }, + }) + : Promise.resolve([]); + + const [tasks, scores] = await Promise.all([ + tasksPromise, + getOverviewScores(organizationId), + ]); + + const frameworkTaskIdsMap = new Map>(); + for (const fiId of frameworkInstanceIds) { + const controlIds = frameworkControlIdsMap.get(fiId) ?? new Set(); + const taskIds = new Set(); + for (const task of tasks) { + const matchesFramework = task.controls.some((c) => controlIds.has(c.id)); + if (matchesFramework) taskIds.add(task.id); + } + frameworkTaskIdsMap.set(fiId, taskIds); + } + + let findingsForAutoCompletion: Array<{ + status: FindingStatus; + createdAt: Date; + type: FindingType; + }> = []; + + if (hasAutoFindingsPhase) { + const autoFindingPhaseStartDates = phases + .filter( + (phase) => phase.completionType === PhaseCompletionType.AUTO_FINDINGS, + ) + .map((phase) => phase.startDate) + .filter((d): d is Date => d instanceof Date); + const findingTypes = Array.from( + new Set( + phases + .filter( + (phase) => + phase.completionType === PhaseCompletionType.AUTO_FINDINGS, + ) + .map((phase) => + getFindingTypeForFrameworkName( + phase.instance.frameworkInstance.framework?.name, + ), + ) + .filter((t): t is FindingType => !!t), + ), + ); + + if ( + autoFindingPhaseStartDates.length > 0 && + findingTypes.length > 0 + ) { + const earliestStartDate = new Date( + Math.min(...autoFindingPhaseStartDates.map((d) => d.getTime())), + ); + findingsForAutoCompletion = await db.finding.findMany({ + where: { + organizationId, + createdAt: { gte: earliestStartDate }, + type: { in: findingTypes }, + }, + select: { + status: true, + createdAt: true, + type: true, + }, + }); + } + } + + for (const phase of phases) { + const fiId = phase.instance.frameworkInstanceId; + const controls = frameworkControlsMap.get(fiId); + let shouldComplete = false; + + if (phase.completionType === PhaseCompletionType.AUTO_TASKS) { + if (!controls || controls.length === 0) continue; + const controlIds = controls.map((c) => c.id); + const fiTasks = tasks.filter((t) => + t.controls.some((c) => controlIds.includes(c.id)), + ); + if (fiTasks.length === 0) continue; + shouldComplete = fiTasks.every( + (t) => t.status === 'done' || t.status === 'not_relevant', + ); + } else if (phase.completionType === PhaseCompletionType.AUTO_POLICIES) { + const { total, published } = scores.policies; + shouldComplete = total > 0 && published >= total; + } else if (phase.completionType === PhaseCompletionType.AUTO_PEOPLE) { + const { total, completed } = scores.people; + shouldComplete = total > 0 && completed >= total; + } else if (phase.completionType === PhaseCompletionType.AUTO_FINDINGS) { + if (!phase.startDate) continue; + + const phaseStartTime = phase.startDate.getTime(); + const findingType = getFindingTypeForFrameworkName( + phase.instance.frameworkInstance.framework?.name, + ); + + if (!findingType) continue; + + const relevantFindings = findingsForAutoCompletion.filter((finding) => { + if (finding.createdAt.getTime() < phaseStartTime) return false; + return finding.type === findingType; + }); + + // Don't auto-complete phases where no findings were ever raised. + if (relevantFindings.length === 0) continue; + + shouldComplete = relevantFindings.every( + (finding) => finding.status === FindingStatus.closed, + ); + } + + if (!shouldComplete) continue; + + try { + await timelinesService.completePhase( + phase.instance.id, + phase.id, + phase.instance.organizationId, + ); + } catch (err) { + logger.warn( + `Auto-complete failed for phase ${phase.id}`, + err instanceof Error ? err.message : err, + ); + } + } +} diff --git a/apps/api/src/frameworks/frameworks.module.ts b/apps/api/src/frameworks/frameworks.module.ts index b6d956e7f..53068441f 100644 --- a/apps/api/src/frameworks/frameworks.module.ts +++ b/apps/api/src/frameworks/frameworks.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { TimelinesModule } from '../timelines/timelines.module'; import { FrameworksController } from './frameworks.controller'; import { FrameworksService } from './frameworks.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, TimelinesModule], controllers: [FrameworksController], providers: [FrameworksService], exports: [FrameworksService], diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts index 6947aa852..f00ec0768 100644 --- a/apps/api/src/frameworks/frameworks.service.ts +++ b/apps/api/src/frameworks/frameworks.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { db, type EvidenceFormType } from '@db'; @@ -10,6 +11,8 @@ import { computeFrameworkComplianceScore, } from './frameworks-scores.helper'; import { upsertOrgFrameworkStructure } from './frameworks-upsert.helper'; +import { createTimelinesForFrameworks } from './frameworks-timeline.helper'; +import { TimelinesService } from '../timelines/timelines.service'; type RequirementDef = { id: string; @@ -22,6 +25,10 @@ type RequirementDef = { @Injectable() export class FrameworksService { + private readonly logger = new Logger(FrameworksService.name); + + constructor(private readonly timelinesService: TimelinesService) {} + private async loadRequirementDefinitions(fi: { frameworkId: string | null; customFrameworkId: string | null; @@ -414,6 +421,11 @@ export class FrameworksService { getOverviewScores(organizationId), userId ? getCurrentMember(organizationId, userId) : Promise.resolve(null), ]); + + // checkAutoCompletePhases is driven from mutation hooks in + // tasks/policies/people/findings/evidence-forms services (it also triggers + // regression reconciliation via reconcileAutoPhasesForOrganization), so + // the dashboard read path no longer needs to fire it on every call. return { ...scores, currentMember }; } @@ -439,10 +451,23 @@ export class FrameworksService { tx, }); - return { success: true, frameworksAdded: finalIds.length }; + return { success: true, frameworksAdded: finalIds.length, finalIds }; + }); + + // Auto-create timeline instances from templates for newly added + // frameworks. Fire-and-forget so a timeline-creation failure never masks + // the primary transaction's success — partial state (e.g. only one SOC 2 + // track created) is repaired on the next /timelines read because + // ensureTimelinesExist now always calls backfill (idempotent per track). + createTimelinesForFrameworks({ + organizationId, + frameworkEditorIds: result.finalIds, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('createTimelinesForFrameworks failed after framework add', err); }); - return result; + return { success: result.success, frameworksAdded: result.frameworksAdded }; } async findRequirement( diff --git a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts index ccf49286f..46de4839c 100644 --- a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts +++ b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts @@ -12,8 +12,8 @@ import { UseInterceptors, Req, } from '@nestjs/common'; -import { Throttle } from '@nestjs/throttler'; import { ApiExcludeController } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; import { OAuthCredentialsService } from '../services/oauth-credentials.service'; import { PlatformCredentialRepository } from '../repositories/platform-credential.repository'; import { getAllManifests, getManifest } from '@trycompai/integration-platform'; diff --git a/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts b/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts index 8a827f18c..9bdb30f44 100644 --- a/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts +++ b/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts @@ -68,8 +68,20 @@ export class DynamicManifestLoaderService if (error instanceof Prisma.PrismaClientInitializationError) { return true; } + // Prisma known-request errors use P-prefixed codes — P1001 is + // "Can't reach database server". System-level codes like ECONNREFUSED + // only appear in the underlying Error.message, handled below. + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P1001' + ) { + return true; + } if (error instanceof Error) { - return error.message.includes("Can't reach database server"); + return ( + error.message.includes("Can't reach database server") || + error.message.includes('ECONNREFUSED') + ); } return false; } diff --git a/apps/api/src/people/people-invite.service.spec.ts b/apps/api/src/people/people-invite.service.spec.ts index f3c9e31fd..420c2c876 100644 --- a/apps/api/src/people/people-invite.service.spec.ts +++ b/apps/api/src/people/people-invite.service.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException, ForbiddenException } from '@nestjs/common'; import { PeopleInviteService } from './people-invite.service'; +import { TimelinesService } from '../timelines/timelines.service'; jest.mock('@db', () => ({ db: { @@ -42,9 +43,14 @@ const mockTriggerEmail = triggerEmail as jest.Mock; describe('PeopleInviteService', () => { let service: PeopleInviteService; + const mockTimelinesService = {}; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [PeopleInviteService], + providers: [ + PeopleInviteService, + { provide: TimelinesService, useValue: mockTimelinesService }, + ], }).compile(); service = module.get(PeopleInviteService); diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts index 5a55212ce..3f6dcc0c9 100644 --- a/apps/api/src/people/people-invite.service.ts +++ b/apps/api/src/people/people-invite.service.ts @@ -8,6 +8,8 @@ import { db } from '@db'; import { triggerEmail } from '../email/trigger-email'; import { InviteEmail } from '../email/templates/invite-member'; import type { InviteItemDto } from './dto/invite-people.dto'; +import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; +import { TimelinesService } from '../timelines/timelines.service'; export interface InviteResult { email: string; @@ -20,6 +22,8 @@ export interface InviteResult { export class PeopleInviteService { private readonly logger = new Logger(PeopleInviteService.name); + constructor(private readonly timelinesService: TimelinesService) {} + async inviteMembers(params: { organizationId: string; invites: InviteItemDto[]; @@ -95,6 +99,17 @@ export class PeopleInviteService { } } + // Check timeline auto-completion after inviting members (people metrics may change) + const hasSuccessfulInvites = results.some((r) => r.success); + if (hasSuccessfulInvites) { + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + } + return results; } diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts index 87c878e8a..8d26064f9 100644 --- a/apps/api/src/people/people.module.ts +++ b/apps/api/src/people/people.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { TimelinesModule } from '../timelines/timelines.module'; import { FleetService } from '../lib/fleet.service'; import { PeopleController } from './people.controller'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; @Module({ - imports: [AuthModule], + imports: [AuthModule, TimelinesModule], controllers: [PeopleController], providers: [PeopleService, PeopleInviteService, FleetService], exports: [PeopleService], diff --git a/apps/api/src/people/people.service.spec.ts b/apps/api/src/people/people.service.spec.ts index b6ce94971..ea0a77b7d 100644 --- a/apps/api/src/people/people.service.spec.ts +++ b/apps/api/src/people/people.service.spec.ts @@ -6,6 +6,7 @@ import { } from '@nestjs/common'; import { PeopleService } from './people.service'; import { FleetService } from '../lib/fleet.service'; +import { TimelinesService } from '../timelines/timelines.service'; import { MemberValidator } from './utils/member-validator'; import { MemberQueries } from './utils/member-queries'; @@ -85,11 +86,14 @@ describe('PeopleService', () => { removeHostById: jest.fn(), }; + const mockTimelinesService = {}; + beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ PeopleService, { provide: FleetService, useValue: mockFleetService }, + { provide: TimelinesService, useValue: mockTimelinesService }, ], }).compile(); diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 9f36b2cba..e0ab4cc2e 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -20,12 +20,17 @@ import { removeMemberFromOrgChart, notifyOwnerOfUnassignedItems, } from './utils/member-deactivation'; +import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; +import { TimelinesService } from '../timelines/timelines.service'; @Injectable() export class PeopleService { private readonly logger = new Logger(PeopleService.name); - constructor(private readonly fleetService: FleetService) {} + constructor( + private readonly fleetService: FleetService, + private readonly timelinesService: TimelinesService, + ) {} async findAllByOrganization( organizationId: string, @@ -170,6 +175,15 @@ export class PeopleService { this.logger.log( `Created member: ${member.user.name} (${member.id}) for organization ${organizationId}`, ); + + // Check timeline auto-completion after member creation (people metrics may change) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return member; } catch (error) { if ( @@ -251,6 +265,16 @@ export class PeopleService { `Bulk create completed for organization ${organizationId}: ${summary.successful}/${summary.total} successful`, ); + // Check timeline auto-completion after bulk member creation + if (created.length > 0) { + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + } + return { created, errors, summary }; } catch (error) { if (error instanceof NotFoundException) { @@ -380,6 +404,14 @@ export class PeopleService { `Deactivated member: ${member.user.name} (${memberId}) from organization ${organizationId}`, ); + // Check timeline auto-completion after member deactivation (people metrics may change) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { success: true, deletedMember: { @@ -414,11 +446,21 @@ export class PeopleService { ); } - return db.member.update({ + const reactivatedMember = await db.member.update({ where: { id: memberId, organizationId }, data: { deactivated: false, isActive: true }, select: MemberQueries.MEMBER_SELECT, }); + + // Check timeline auto-completion after member reactivation (people metrics may change) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + + return reactivatedMember; } async unlinkDevice( diff --git a/apps/api/src/policies/policies.module.ts b/apps/api/src/policies/policies.module.ts index e4e742c3b..14ff3901a 100644 --- a/apps/api/src/policies/policies.module.ts +++ b/apps/api/src/policies/policies.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { AttachmentsModule } from '../attachments/attachments.module'; import { AuthModule } from '../auth/auth.module'; +import { TimelinesModule } from '../timelines/timelines.module'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; import { PoliciesController } from './policies.controller'; import { PoliciesService } from './policies.service'; @Module({ - imports: [AuthModule, AttachmentsModule], + imports: [AuthModule, AttachmentsModule, TimelinesModule], controllers: [PoliciesController], providers: [PoliciesService, PolicyPdfRendererService], exports: [PoliciesService], diff --git a/apps/api/src/policies/policies.service.spec.ts b/apps/api/src/policies/policies.service.spec.ts index 8642a55bc..42487bc8a 100644 --- a/apps/api/src/policies/policies.service.spec.ts +++ b/apps/api/src/policies/policies.service.spec.ts @@ -3,6 +3,7 @@ import { NotFoundException } from '@nestjs/common'; import { PoliciesService } from './policies.service'; import { AttachmentsService } from '../attachments/attachments.service'; import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service'; +import { TimelinesService } from '../timelines/timelines.service'; jest.mock('@db', () => ({ db: { @@ -93,6 +94,9 @@ describe('PoliciesService', () => { PoliciesService, { provide: AttachmentsService, useValue: mockAttachmentsService }, { provide: PolicyPdfRendererService, useValue: {} }, + // TimelinesService is injected for timeline auto-completion hooks; + // tests don't exercise that path so a bare stub is enough. + { provide: TimelinesService, useValue: {} }, ], }).compile(); service = module.get(PoliciesService); diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 9f3b2ad4b..ff4b6d2fa 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -17,6 +17,8 @@ import type { SubmitForApprovalDto, UpdateVersionContentDto, } from './dto/version.dto'; +import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; +import { TimelinesService } from '../timelines/timelines.service'; function computeNextReviewDate(frequency: Frequency | null | undefined): Date { const now = new Date(); @@ -40,6 +42,7 @@ export class PoliciesService { constructor( private readonly attachmentsService: AttachmentsService, private readonly pdfRendererService: PolicyPdfRendererService, + private readonly timelinesService: TimelinesService, ) {} async findAll(organizationId: string) { @@ -158,6 +161,14 @@ export class PoliciesService { organizationId, ); + // Check timeline auto-completion after bulk publish + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed after publish-all', err); + }); + return { success: true, publishedCount: draftPolicies.length, @@ -304,6 +315,15 @@ export class PoliciesService { }); this.logger.log(`Created policy: ${policy.name} (${policy.id})`); + + // Check timeline auto-completion after policy creation + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return policy; } catch (error) { this.logger.error( @@ -406,6 +426,15 @@ export class PoliciesService { }); this.logger.log(`Updated policy: ${updatedPolicy.name} (${id})`); + + // Check timeline auto-completion after policy update (status may have changed) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return updatedPolicy; } catch (error) { if (error instanceof NotFoundException) { @@ -476,6 +505,15 @@ export class PoliciesService { }); this.logger.log(`Deleted policy: ${policy.name} (${id})`); + + // Check timeline auto-completion after policy deletion + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { success: true, deletedPolicy: policy }; } catch (error) { if (error instanceof NotFoundException) { @@ -837,7 +875,7 @@ export class PoliciesService { for (let attempt = 1; attempt <= this.versionCreateRetries; attempt += 1) { try { - return await db.$transaction(async (tx) => { + const result = await db.$transaction(async (tx) => { const latestVersion = await tx.policyVersion.findFirst({ where: { policyId }, orderBy: { version: 'desc' }, @@ -880,6 +918,16 @@ export class PoliciesService { version: nextVersion, }; }); + + // Check timeline auto-completion after publishing a version + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + + return result; } catch (error) { if ( this.isUniqueConstraintError(error) && @@ -935,6 +983,14 @@ export class PoliciesService { }, }); + // Check timeline auto-completion after setting active version + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { versionId: version.id, version: version.version, @@ -1087,6 +1143,14 @@ export class PoliciesService { }); }); + // Check timeline auto-completion after accepting changes (policy published) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { versionId: version.id, version: version.version }; } diff --git a/apps/api/src/tasks/tasks.module.ts b/apps/api/src/tasks/tasks.module.ts index 888d2503e..0693bf30c 100644 --- a/apps/api/src/tasks/tasks.module.ts +++ b/apps/api/src/tasks/tasks.module.ts @@ -2,13 +2,14 @@ import { Module, forwardRef } from '@nestjs/common'; import { AttachmentsModule } from '../attachments/attachments.module'; import { AuthModule } from '../auth/auth.module'; import { AutomationsModule } from './automations/automations.module'; +import { TimelinesModule } from '../timelines/timelines.module'; import { NovuService } from '../notifications/novu.service'; import { TasksController } from './tasks.controller'; import { TasksService } from './tasks.service'; import { TaskNotifierService } from './task-notifier.service'; @Module({ - imports: [AuthModule, AttachmentsModule, forwardRef(() => AutomationsModule)], + imports: [AuthModule, AttachmentsModule, forwardRef(() => AutomationsModule), TimelinesModule], controllers: [TasksController], providers: [TasksService, TaskNotifierService, NovuService], exports: [TasksService, TaskNotifierService], diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index dd19c9dda..ae4896ad1 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -3,12 +3,15 @@ import { ForbiddenException, Injectable, InternalServerErrorException, + Logger, NotFoundException, } from '@nestjs/common'; import { filterDescriptionByFrameworks } from './description-framework-filter'; import { db, TaskStatus, Prisma, TaskFrequency, Departments } from '@db'; import { TaskResponseDto } from './dto/task-responses.dto'; import { TaskNotifierService } from './task-notifier.service'; +import { checkAutoCompletePhases } from '../frameworks/frameworks-timeline.helper'; +import { TimelinesService } from '../timelines/timelines.service'; function computeNextTaskReviewDate( frequency: TaskFrequency | null | undefined, @@ -32,7 +35,12 @@ function computeNextTaskReviewDate( @Injectable() export class TasksService { - constructor(private readonly taskNotifierService: TaskNotifierService) {} + private readonly logger = new Logger(TasksService.name); + + constructor( + private readonly taskNotifierService: TaskNotifierService, + private readonly timelinesService: TimelinesService, + ) {} /** * Resolve a user actor for API-key authenticated requests. @@ -406,6 +414,17 @@ export class TasksService { ); }); + // Any task status change can shift AUTO_TASKS metric in either + // direction (done/not_relevant can complete a phase; back to + // todo/in_progress can regress one). checkAutoCompletePhases also + // kicks off regression reconciliation, so fire on every change. + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { updatedCount: result.count }; } catch (error) { console.error('Error updating task statuses:', error); @@ -505,6 +524,14 @@ export class TasksService { ); } + // Check timeline auto-completion after bulk task deletion (total task count changed) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { deletedCount: result.count }; } catch (error) { console.error('Error deleting tasks:', error); @@ -691,6 +718,15 @@ export class TasksService { .catch((error) => { console.error('Failed to send status change notifications:', error); }); + + // Any status change can shift AUTO_TASKS metric in either direction, + // and checkAutoCompletePhases also triggers regression reconciliation. + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); } // Write audit logs and send notifications for assignee changes @@ -821,6 +857,16 @@ export class TasksService { }, }); + // Task creation drops the AUTO_TASKS completion % (denominator up, + // numerator unchanged), so reconciliation can regress a COMPLETED + // phase back to IN_PROGRESS if we're now under 100%. + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return { id: task.id, title: task.title, @@ -900,6 +946,14 @@ export class TasksService { await db.task.delete({ where: { id: taskId }, }); + + // Check timeline auto-completion after task deletion (total task count changed) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); } /** @@ -1164,6 +1218,14 @@ export class TasksService { return updated; }); + // Check timeline auto-completion when task is approved (status changed to done) + checkAutoCompletePhases({ + organizationId, + timelinesService: this.timelinesService, + }).catch((err) => { + this.logger.warn('timeline auto-complete check failed', err); + }); + return updatedTask; } diff --git a/apps/api/src/timelines/admin-org-timelines.controller.ts b/apps/api/src/timelines/admin-org-timelines.controller.ts new file mode 100644 index 000000000..d794f8e24 --- /dev/null +++ b/apps/api/src/timelines/admin-org-timelines.controller.ts @@ -0,0 +1,181 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + Req, + BadRequestException, + UseGuards, + UseInterceptors, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { AdminAuditLogInterceptor } from '../admin-organizations/admin-audit-log.interceptor'; +import { TimelinesService } from './timelines.service'; +import { TimelinesPhasesService } from './timelines-phases.service'; +import { ActivateTimelineDto } from './dto/activate-timeline.dto'; +import { UpdatePhaseDto } from './dto/update-phase.dto'; +import { AddPhaseToInstanceDto } from './dto/create-phase-template.dto'; +import { UnlockTimelineDto } from './dto/unlock-timeline.dto'; +import { PhaseCompletionType } from '@db'; + +@ApiExcludeController() +@Controller({ path: 'admin/organizations', version: '1' }) +@UseGuards(PlatformAdminGuard) +@UseInterceptors(AdminAuditLogInterceptor) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +@UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), +) +export class AdminOrgTimelinesController { + constructor( + private readonly timelinesService: TimelinesService, + private readonly phasesService: TimelinesPhasesService, + ) {} + + @Get(':orgId/timelines') + async findAll(@Param('orgId') orgId: string) { + const data = await this.timelinesService.findAllForOrganization(orgId); + return { data, count: data.length }; + } + + @Post(':orgId/timelines/:id/activate') + async activate( + @Param('orgId') orgId: string, + @Param('id') id: string, + @Body() dto: ActivateTimelineDto, + ) { + return this.timelinesService.activate( + id, + orgId, + new Date(dto.startDate), + ); + } + + @Post(':orgId/timelines/:id/pause') + async pause( + @Param('orgId') orgId: string, + @Param('id') id: string, + ) { + return this.timelinesService.pauseTimeline(id, orgId); + } + + @Post(':orgId/timelines/:id/resume') + async resume( + @Param('orgId') orgId: string, + @Param('id') id: string, + ) { + return this.timelinesService.resumeTimeline(id, orgId); + } + + @Patch(':orgId/timelines/:id/phases/:phaseId') + async updatePhase( + @Param('orgId') orgId: string, + @Param('id') id: string, + @Param('phaseId') phaseId: string, + @Body() dto: UpdatePhaseDto, + ) { + return this.phasesService.updatePhase(id, phaseId, orgId, { + name: dto.name, + description: dto.description, + durationWeeks: dto.durationWeeks, + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + datesPinned: dto.datesPinned, + completionType: dto.completionType as PhaseCompletionType | undefined, + locksTimelineOnComplete: dto.locksTimelineOnComplete, + }); + } + + @Post(':orgId/timelines/:id/phases') + async addPhase( + @Param('orgId') orgId: string, + @Param('id') id: string, + @Body() dto: AddPhaseToInstanceDto, + ) { + return this.phasesService.addPhase(id, orgId, { + name: dto.name, + description: dto.description, + orderIndex: dto.orderIndex, + durationWeeks: dto.durationWeeks, + completionType: dto.completionType as PhaseCompletionType | undefined, + }); + } + + @Delete(':orgId/timelines/:id/phases/:phaseId') + async removePhase( + @Param('orgId') orgId: string, + @Param('id') id: string, + @Param('phaseId') phaseId: string, + ) { + return this.phasesService.removePhase(id, phaseId, orgId); + } + + @Post(':orgId/timelines/:id/phases/:phaseId/complete') + async completePhase( + @Param('orgId') orgId: string, + @Param('id') id: string, + @Param('phaseId') phaseId: string, + @Req() req: { userId?: string }, + ) { + return this.timelinesService.completePhase(id, phaseId, orgId, req.userId); + } + + @Post(':orgId/timelines/:id/next-cycle') + async startNextCycle( + @Param('orgId') orgId: string, + @Param('id') id: string, + ) { + return this.timelinesService.startNextCycle(id, orgId); + } + + @Post(':orgId/timelines/:id/reset') + async resetTimeline( + @Param('orgId') orgId: string, + @Param('id') id: string, + ) { + return this.timelinesService.resetInstance(id, orgId); + } + + @Post(':orgId/timelines/:id/unlock') + async unlockTimeline( + @Param('orgId') orgId: string, + @Param('id') id: string, + @Body() dto: UnlockTimelineDto, + @Req() req: { userId?: string }, + ) { + if (!req.userId) { + throw new BadRequestException('Unable to resolve acting admin user'); + } + + return this.timelinesService.unlockTimeline( + id, + orgId, + req.userId, + dto.unlockReason, + ); + } + + @Delete(':orgId/timelines/:id') + async deleteTimeline( + @Param('orgId') orgId: string, + @Param('id') id: string, + ) { + return this.timelinesService.deleteInstance(id, orgId); + } + + @Post(':orgId/timelines/recreate') + async recreateTimelines(@Param('orgId') orgId: string) { + return this.timelinesService.recreateAllForOrganization(orgId); + } +} diff --git a/apps/api/src/timelines/admin-timeline-templates.controller.ts b/apps/api/src/timelines/admin-timeline-templates.controller.ts new file mode 100644 index 000000000..572051a20 --- /dev/null +++ b/apps/api/src/timelines/admin-timeline-templates.controller.ts @@ -0,0 +1,112 @@ +import { + Controller, + Get, + Post, + Patch, + Delete, + Param, + Body, + UseGuards, + UsePipes, + ValidationPipe, +} from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import { Throttle } from '@nestjs/throttler'; +import { PlatformAdminGuard } from '../auth/platform-admin.guard'; +import { TimelinesTemplatesService } from './timelines-templates.service'; +import { CreateTemplateDto } from './dto/create-template.dto'; +import { UpdateTemplateDto } from './dto/update-template.dto'; +import { CreatePhaseTemplateDto } from './dto/create-phase-template.dto'; +import { UpdatePhaseTemplateDto } from './dto/update-phase-template.dto'; +import { PhaseCompletionType } from '@db'; + +@ApiExcludeController() +@Controller({ path: 'admin/timeline-templates', version: '1' }) +@UseGuards(PlatformAdminGuard) +@Throttle({ default: { ttl: 60000, limit: 30 } }) +@UsePipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), +) +export class AdminTimelineTemplatesController { + constructor( + private readonly templatesService: TimelinesTemplatesService, + ) {} + + @Get() + async findAll() { + const data = await this.templatesService.findAll(); + return { data, count: data.length }; + } + + @Get(':id') + async findOne(@Param('id') id: string) { + return this.templatesService.findOne(id); + } + + @Post() + async create(@Body() dto: CreateTemplateDto) { + return this.templatesService.create({ + frameworkId: dto.frameworkId, + name: dto.name, + cycleNumber: dto.cycleNumber, + }); + } + + @Patch(':id') + async update(@Param('id') id: string, @Body() dto: UpdateTemplateDto) { + return this.templatesService.update(id, { + name: dto.name, + cycleNumber: dto.cycleNumber, + }); + } + + @Delete(':id') + async delete(@Param('id') id: string) { + return this.templatesService.delete(id); + } + + @Post(':id/phases') + async addPhase( + @Param('id') templateId: string, + @Body() dto: CreatePhaseTemplateDto, + ) { + return this.templatesService.addPhase(templateId, { + name: dto.name, + description: dto.description, + groupLabel: dto.groupLabel, + orderIndex: dto.orderIndex, + defaultDurationWeeks: dto.defaultDurationWeeks, + completionType: dto.completionType as PhaseCompletionType | undefined, + locksTimelineOnComplete: dto.locksTimelineOnComplete, + }); + } + + @Patch(':id/phases/:phaseId') + async updatePhase( + @Param('id') templateId: string, + @Param('phaseId') phaseId: string, + @Body() dto: UpdatePhaseTemplateDto, + ) { + return this.templatesService.updatePhase(templateId, phaseId, { + name: dto.name, + description: dto.description, + groupLabel: dto.groupLabel, + orderIndex: dto.orderIndex, + defaultDurationWeeks: dto.defaultDurationWeeks, + completionType: dto.completionType as PhaseCompletionType | undefined, + locksTimelineOnComplete: dto.locksTimelineOnComplete, + }); + } + + @Delete(':id/phases/:phaseId') + async deletePhase( + @Param('id') templateId: string, + @Param('phaseId') phaseId: string, + ) { + return this.templatesService.deletePhase(templateId, phaseId); + } +} diff --git a/apps/api/src/timelines/default-templates.spec.ts b/apps/api/src/timelines/default-templates.spec.ts new file mode 100644 index 000000000..34ac24e11 --- /dev/null +++ b/apps/api/src/timelines/default-templates.spec.ts @@ -0,0 +1,98 @@ +import { + getDefaultTemplateForCycle, + getDefaultTemplatesForFramework, + GENERIC_DEFAULT_TIMELINE_TEMPLATE, +} from './default-templates'; + +describe('default-templates', () => { + it('matches framework defaults using normalized names', () => { + const soc2Templates = getDefaultTemplatesForFramework('SOC2'); + + expect(soc2Templates.map((template) => template.cycleNumber).sort()).toEqual([1, 1, 2]); + }); + + it('defines independent SOC 2 tracks with their own cycle 1 templates', () => { + const soc2Templates = getDefaultTemplatesForFramework('SOC 2'); + + const cycle1Tracks = soc2Templates + .filter((template) => template.cycleNumber === 1) + .map((template) => template.trackKey); + + expect(cycle1Tracks.sort()).toEqual(['soc2_type1', 'soc2_type2']); + }); + + it('returns SOC 2 Type 2 default with observation phase lock enabled', () => { + const template = getDefaultTemplateForCycle('SOC2', 1, { + trackKey: 'soc2_type2', + }); + + expect(template?.name).toBe('SOC 2 Type 2'); + const observationPhase = template?.phases.find((phase) => + phase.name.toLowerCase().includes('observation'), + ); + + expect(observationPhase).toBeDefined(); + expect(observationPhase?.locksTimelineOnComplete).toBe(true); + }); + + it('sets Auditor Review phases to AUTO_FINDINGS for SOC 2 templates', () => { + const type1 = getDefaultTemplateForCycle('SOC 2', 1, { + trackKey: 'soc2_type1', + }); + const type2 = getDefaultTemplateForCycle('SOC 2', 1, { + trackKey: 'soc2_type2', + }); + const legacyType1 = getDefaultTemplateForCycle('SOC 2 v.1', 1, { + trackKey: 'primary', + }); + + const type1AuditorReview = type1?.phases.find((phase) => phase.name === 'Auditor Review'); + const type2AuditorReview = type2?.phases.find((phase) => phase.name === 'Auditor Review'); + const legacyAuditorReview = legacyType1?.phases.find( + (phase) => phase.name === 'Auditor Review', + ); + + expect(type1AuditorReview?.completionType).toBe('AUTO_FINDINGS'); + expect(type2AuditorReview?.completionType).toBe('AUTO_FINDINGS'); + expect(legacyAuditorReview?.completionType).toBe('AUTO_FINDINGS'); + }); + + it('defines explicit SOC 2 progression within each independent track', () => { + const type1 = getDefaultTemplateForCycle('SOC 2', 1, { + trackKey: 'soc2_type1', + }); + const type2Year1 = getDefaultTemplateForCycle('SOC 2', 1, { + trackKey: 'soc2_type2', + }); + const renewal = getDefaultTemplateForCycle('SOC 2', 2, { + trackKey: 'soc2_type2', + }); + + expect(type1?.templateKey).toBe('soc2_type1'); + expect(type1?.nextTemplateKey).toBe('soc2_type1'); + + expect(type2Year1?.templateKey).toBe('soc2_type2_year1'); + expect(type2Year1?.nextTemplateKey).toBe('soc2_type2_renewal'); + + expect(renewal?.templateKey).toBe('soc2_type2_renewal'); + expect(renewal?.nextTemplateKey).toBe('soc2_type2_renewal'); + expect(renewal?.name).toBe('SOC 2 Type 2'); + }); + + it('falls back to framework defaults when an unknown track key is requested', () => { + const template = getDefaultTemplateForCycle('SOC 2', 1, { + trackKey: 'unknown_track', + }); + + expect(template?.frameworkName).toBe('SOC 2'); + expect(template?.name).toBe('SOC 2 Type 1'); + }); + + it('returns generic fallback template when framework has no specific default', () => { + const template = getDefaultTemplateForCycle('Custom Framework XYZ', 1); + + expect(template?.name).toBe(GENERIC_DEFAULT_TIMELINE_TEMPLATE.name); + expect(template?.phases.length).toBe(GENERIC_DEFAULT_TIMELINE_TEMPLATE.phases.length); + expect(template?.phases.some((phase) => phase.locksTimelineOnComplete)).toBe(false); + }); +}); diff --git a/apps/api/src/timelines/default-templates.ts b/apps/api/src/timelines/default-templates.ts new file mode 100644 index 000000000..93350b75f --- /dev/null +++ b/apps/api/src/timelines/default-templates.ts @@ -0,0 +1,487 @@ +/** + * Mirrors the PhaseCompletionType enum from packages/db/prisma/schema/timeline.prisma. + * Defined locally because the API's Prisma client hasn't been regenerated with + * the timeline schema yet. Values MUST stay in sync with the Prisma enum. + */ +export const PhaseCompletionType = { + AUTO_TASKS: 'AUTO_TASKS', + AUTO_POLICIES: 'AUTO_POLICIES', + AUTO_PEOPLE: 'AUTO_PEOPLE', + AUTO_FINDINGS: 'AUTO_FINDINGS', + AUTO_UPLOAD: 'AUTO_UPLOAD', + MANUAL: 'MANUAL', +} as const; + +export type PhaseCompletionType = + (typeof PhaseCompletionType)[keyof typeof PhaseCompletionType]; + +export interface DefaultPhaseTemplate { + name: string; + description: string; + groupLabel?: string; + orderIndex: number; + defaultDurationWeeks: number; + completionType: PhaseCompletionType; + /** + * When true, completing this phase should lock timeline automation state. + * This is intended for milestones like SOC 2 Observation Period completion. + */ + locksTimelineOnComplete?: boolean; +} + +export interface DefaultTimelineTemplate { + frameworkName: string; // Matched against normalized FrameworkEditorFramework.name + name: string; // Display name, e.g. "SOC 2 Type 2" + cycleNumber: number; + /** + * Independent track identifier within a framework. + * Example: SOC 2 has separate tracks for Type 1 and Type 2. + */ + trackKey?: string; + /** + * Stable key to identify this template's semantic meaning. + * Enables deterministic transitions across cycles. + */ + templateKey?: string; + /** + * Stable key for which template should be used on "next cycle". + */ + nextTemplateKey?: string; + phases: DefaultPhaseTemplate[]; +} + +export const DEFAULT_TIMELINE_TEMPLATES: DefaultTimelineTemplate[] = [ + // SOC 2 Type 1 - quick point-in-time snapshot (cycle 1) + { + frameworkName: 'SOC 2', + name: 'SOC 2 Type 1', + cycleNumber: 1, + trackKey: 'soc2_type1', + templateKey: 'soc2_type1', + nextTemplateKey: 'soc2_type1', + phases: [ + { + name: 'Policies', + description: 'Review and publish all required compliance policies.', + groupLabel: 'Preparing for Audit', + orderIndex: 0, + defaultDurationWeeks: 3, + completionType: PhaseCompletionType.AUTO_POLICIES, + }, + { + name: 'Evidence', + description: 'Complete all evidence collection tasks.', + groupLabel: 'Preparing for Audit', + orderIndex: 1, + defaultDurationWeeks: 4, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'People', + description: 'Ensure all employees complete security training and acknowledgements.', + groupLabel: 'Preparing for Audit', + orderIndex: 2, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_PEOPLE, + }, + { + name: 'Auditor Review', + description: 'Auditor reviews your account.', + orderIndex: 3, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_FINDINGS, + }, + { + name: 'Draft Report', + description: 'Auditor delivers draft report for review.', + orderIndex: 4, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Final Report', + description: 'Final report delivered.', + orderIndex: 5, + defaultDurationWeeks: 1, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, + + // SOC 2 Type 2 (cycle 1 in its own track) + { + frameworkName: 'SOC 2', + name: 'SOC 2 Type 2', + cycleNumber: 1, + trackKey: 'soc2_type2', + templateKey: 'soc2_type2_year1', + nextTemplateKey: 'soc2_type2_renewal', + phases: [ + { + name: 'Policies', + description: + 'Review and publish all required compliance policies.', + groupLabel: 'Preparing for Audit', + orderIndex: 0, + defaultDurationWeeks: 3, + completionType: PhaseCompletionType.AUTO_POLICIES, + }, + { + name: 'Evidence', + description: 'Complete all evidence collection tasks.', + groupLabel: 'Preparing for Audit', + orderIndex: 1, + defaultDurationWeeks: 4, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'People', + description: + 'Ensure all employees complete security training and acknowledgements.', + groupLabel: 'Preparing for Audit', + orderIndex: 2, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_PEOPLE, + }, + { + name: 'Observation Period + Pentest', + description: + 'Observation period to prove sustained compliance. Penetration test is arranged during this phase.', + orderIndex: 3, + defaultDurationWeeks: 4, + completionType: PhaseCompletionType.MANUAL, + locksTimelineOnComplete: true, + }, + { + name: 'Auditor Review', + description: 'Auditor reviews evidence and addresses any feedback.', + orderIndex: 4, + defaultDurationWeeks: 4, + completionType: PhaseCompletionType.AUTO_FINDINGS, + }, + { + name: 'Draft Report', + description: 'Auditor delivers draft report for review.', + orderIndex: 5, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Final Report', + description: + 'Final report delivered. Your SOC 2 Type 2 certification is complete.', + orderIndex: 6, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, + + // SOC 2 Type 2 renewal (cycle 2+ in the same Type 2 track) + { + frameworkName: 'SOC 2', + name: 'SOC 2 Type 2', + cycleNumber: 2, + trackKey: 'soc2_type2', + templateKey: 'soc2_type2_renewal', + nextTemplateKey: 'soc2_type2_renewal', + phases: [ + { + name: 'Policies', + description: + 'Review and publish all required compliance policies.', + groupLabel: 'Preparing for Audit', + orderIndex: 0, + defaultDurationWeeks: 3, + completionType: PhaseCompletionType.AUTO_POLICIES, + }, + { + name: 'Evidence', + description: 'Complete all evidence collection tasks.', + groupLabel: 'Preparing for Audit', + orderIndex: 1, + defaultDurationWeeks: 4, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'People', + description: + 'Ensure all employees complete security training and acknowledgements.', + groupLabel: 'Preparing for Audit', + orderIndex: 2, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_PEOPLE, + }, + { + name: 'Observation Period + Pentest', + description: + 'Observation period to prove sustained compliance. Penetration test is arranged during this phase.', + orderIndex: 3, + defaultDurationWeeks: 18, + completionType: PhaseCompletionType.MANUAL, + locksTimelineOnComplete: true, + }, + { + name: 'Auditor Review', + description: 'Auditor reviews evidence and addresses any feedback.', + orderIndex: 4, + defaultDurationWeeks: 4, + completionType: PhaseCompletionType.AUTO_FINDINGS, + }, + { + name: 'Draft Report', + description: 'Auditor delivers draft report for review.', + orderIndex: 5, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Final Report', + description: + 'Final report delivered. Your SOC 2 Type 2 renewal is complete.', + orderIndex: 6, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, + + // SOC 2 v.1 (legacy separate framework - same as Type 1) + { + frameworkName: 'SOC 2 v.1', + name: 'SOC 2 Type 1', + cycleNumber: 1, + trackKey: 'soc2v1_type1', + templateKey: 'soc2v1_type1', + nextTemplateKey: 'soc2v1_type1', + phases: [ + { + name: 'Evidence Gathering', + description: + 'Complete all platform tasks. This is a point-in-time snapshot assessment.', + orderIndex: 0, + defaultDurationWeeks: 8, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'Auditor Review', + description: 'Auditor reviews your account.', + orderIndex: 1, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.AUTO_FINDINGS, + }, + { + name: 'Final Report', + description: 'Final report delivered.', + orderIndex: 2, + defaultDurationWeeks: 1, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, + + // ISO 27001 + { + frameworkName: 'ISO27001', + name: 'ISO 27001', + cycleNumber: 1, + trackKey: 'primary', + templateKey: 'iso27001_primary', + nextTemplateKey: 'iso27001_primary', + phases: [ + { + name: 'Evidence Gathering', + description: + 'Complete all platform tasks and employee requirements.', + orderIndex: 0, + defaultDurationWeeks: 8, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'Stage 1 & 2 Audit', + description: + 'Auditor conducts Stage 1 (Policy) and Stage 2 (Evidence) reviews.', + orderIndex: 1, + defaultDurationWeeks: 5, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Certification', + description: 'Certification delivered.', + orderIndex: 2, + defaultDurationWeeks: 1, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, + + // HIPAA + { + frameworkName: 'HIPAA', + name: 'HIPAA', + cycleNumber: 1, + trackKey: 'primary', + templateKey: 'hipaa_primary', + nextTemplateKey: 'hipaa_primary', + phases: [ + { + name: 'Evidence Gathering & Training', + description: + 'Complete all platform tasks, evidence, and employee training.', + orderIndex: 0, + defaultDurationWeeks: 8, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'Review', + description: + 'We review your compliance and address any findings.', + orderIndex: 1, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Attestation', + description: 'Attestation report delivered.', + orderIndex: 2, + defaultDurationWeeks: 1, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, + + // GDPR + { + frameworkName: 'GDPR', + name: 'GDPR', + cycleNumber: 1, + trackKey: 'primary', + templateKey: 'gdpr_primary', + nextTemplateKey: 'gdpr_primary', + phases: [ + { + name: 'Evidence Gathering & Training', + description: + 'Complete all platform tasks, evidence, and employee training.', + orderIndex: 0, + defaultDurationWeeks: 8, + completionType: PhaseCompletionType.AUTO_TASKS, + }, + { + name: 'Review', + description: + 'We review your compliance and address any findings.', + orderIndex: 1, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Attestation', + description: 'Attestation report delivered.', + orderIndex: 2, + defaultDurationWeeks: 1, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], + }, +]; + +/** + * Fallback used when a framework has no explicit code-default timeline. + * This provides a safe, editable baseline for CX/admin teams. + */ +export const GENERIC_DEFAULT_TIMELINE_TEMPLATE: DefaultTimelineTemplate = { + frameworkName: '*', + name: 'Baseline Compliance Timeline', + cycleNumber: 1, + trackKey: 'primary', + templateKey: 'baseline_primary', + nextTemplateKey: 'baseline_primary', + phases: [ + { + name: 'Scoping & Planning', + description: 'Define scope, owners, and audit goals for this cycle.', + orderIndex: 0, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Evidence Collection', + description: 'Collect required evidence and complete implementation tasks.', + orderIndex: 1, + defaultDurationWeeks: 6, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Internal Review', + description: 'Validate readiness and resolve open findings before external review.', + orderIndex: 2, + defaultDurationWeeks: 2, + completionType: PhaseCompletionType.MANUAL, + }, + { + name: 'Final Report', + description: 'Upload final attestation, report, or certification deliverable.', + orderIndex: 3, + defaultDurationWeeks: 1, + completionType: PhaseCompletionType.AUTO_UPLOAD, + }, + ], +}; + +function normalizeFrameworkName(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ''); +} + +/** + * Find matching default templates for a framework by name. + * Returns all matching templates (could be multiple for different cycle numbers). + */ +export function getDefaultTemplatesForFramework( + frameworkName: string, +): DefaultTimelineTemplate[] { + const normalized = normalizeFrameworkName(frameworkName); + return DEFAULT_TIMELINE_TEMPLATES.filter( + (t) => normalizeFrameworkName(t.frameworkName) === normalized, + ); +} + +/** + * Find a specific default template for a framework and cycle number. + * Falls back to highest cycle number <= requested (same as DB fallback logic). + */ +export function getDefaultTemplateForCycle( + frameworkName: string, + cycleNumber: number, + options?: { trackKey?: string }, +): DefaultTimelineTemplate | undefined { + const trackKey = options?.trackKey; + const templatesForFramework = getDefaultTemplatesForFramework(frameworkName); + const trackScopedTemplates = trackKey + ? templatesForFramework.filter((t) => (t.trackKey ?? 'primary') === trackKey) + : templatesForFramework; + const templates = + trackScopedTemplates.length > 0 + ? trackScopedTemplates + : templatesForFramework; + + // Unknown framework: use a generic baseline that admins can customize. + if (templates.length === 0) { + if (cycleNumber < GENERIC_DEFAULT_TIMELINE_TEMPLATE.cycleNumber) { + return undefined; + } + return { + ...GENERIC_DEFAULT_TIMELINE_TEMPLATE, + phases: GENERIC_DEFAULT_TIMELINE_TEMPLATE.phases.map((phase) => ({ ...phase })), + }; + } + + // Exact match first + const exact = templates.find((t) => t.cycleNumber === cycleNumber); + if (exact) return exact; + + // Fallback to highest cycle <= requested + return templates + .filter((t) => t.cycleNumber <= cycleNumber) + .sort((a, b) => b.cycleNumber - a.cycleNumber)[0]; +} diff --git a/apps/api/src/timelines/dto/activate-timeline.dto.ts b/apps/api/src/timelines/dto/activate-timeline.dto.ts new file mode 100644 index 000000000..e7e6299ad --- /dev/null +++ b/apps/api/src/timelines/dto/activate-timeline.dto.ts @@ -0,0 +1,11 @@ +import { IsDateString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class ActivateTimelineDto { + @ApiProperty({ + description: 'The start date for the timeline', + example: '2026-05-01T00:00:00.000Z', + }) + @IsDateString() + startDate: string; +} diff --git a/apps/api/src/timelines/dto/create-phase-template.dto.ts b/apps/api/src/timelines/dto/create-phase-template.dto.ts new file mode 100644 index 000000000..1f1250da5 --- /dev/null +++ b/apps/api/src/timelines/dto/create-phase-template.dto.ts @@ -0,0 +1,99 @@ +import { + IsString, + IsNotEmpty, + IsOptional, + IsInt, + IsIn, + Min, + IsBoolean, +} from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { COMPLETION_TYPES, type CompletionType } from '../timeline-constants'; + +export class CreatePhaseTemplateDto { + @ApiProperty({ description: 'Phase name', example: 'Gap Assessment' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ description: 'Phase description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Group label for sub-phase grouping' }) + @IsOptional() + @IsString() + groupLabel?: string; + + @ApiProperty({ + description: 'Position in the phase sequence (0-based)', + minimum: 0, + }) + @IsInt() + @Min(0) + orderIndex: number; + + @ApiProperty({ + description: 'Default duration in weeks', + minimum: 1, + example: 4, + }) + @IsInt() + @Min(1) + defaultDurationWeeks: number; + + @ApiPropertyOptional({ + description: 'How the phase is completed', + enum: COMPLETION_TYPES, + }) + @IsOptional() + @IsIn(COMPLETION_TYPES) + completionType?: CompletionType; + + @ApiPropertyOptional({ + description: + 'If true, completing this phase locks timeline automation state', + default: false, + }) + @IsOptional() + @IsBoolean() + locksTimelineOnComplete?: boolean; +} + +export class AddPhaseToInstanceDto { + @ApiProperty({ description: 'Phase name', example: 'Remediation' }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiPropertyOptional({ description: 'Phase description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Position in the phase sequence (0-based)', + minimum: 0, + }) + @IsInt() + @Min(0) + orderIndex: number; + + @ApiProperty({ + description: 'Duration in weeks', + minimum: 1, + example: 4, + }) + @IsInt() + @Min(1) + durationWeeks: number; + + @ApiPropertyOptional({ + description: 'How the phase is completed', + enum: COMPLETION_TYPES, + }) + @IsOptional() + @IsIn(COMPLETION_TYPES) + completionType?: CompletionType; +} diff --git a/apps/api/src/timelines/dto/create-template.dto.ts b/apps/api/src/timelines/dto/create-template.dto.ts new file mode 100644 index 000000000..7cbfc2262 --- /dev/null +++ b/apps/api/src/timelines/dto/create-template.dto.ts @@ -0,0 +1,26 @@ +import { IsString, IsNotEmpty, IsInt, Min } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; + +export class CreateTemplateDto { + @ApiProperty({ description: 'Framework ID this template belongs to' }) + @IsString() + @IsNotEmpty() + frameworkId: string; + + @ApiProperty({ + description: 'Template name', + example: 'SOC 2 Initial Audit', + }) + @IsString() + @IsNotEmpty() + name: string; + + @ApiProperty({ + description: 'Cycle number (1 = initial, 2+ = renewal)', + minimum: 1, + example: 1, + }) + @IsInt() + @Min(1) + cycleNumber: number; +} diff --git a/apps/api/src/timelines/dto/unlock-timeline.dto.ts b/apps/api/src/timelines/dto/unlock-timeline.dto.ts new file mode 100644 index 000000000..01b2f8eed --- /dev/null +++ b/apps/api/src/timelines/dto/unlock-timeline.dto.ts @@ -0,0 +1,16 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +export class UnlockTimelineDto { + @ApiProperty({ + description: 'Required reason for unlocking a locked timeline', + example: 'Audit scope changed; reopening timeline for correction.', + }) + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @IsNotEmpty() + @MaxLength(2000) + unlockReason: string; +} + diff --git a/apps/api/src/timelines/dto/update-phase-template.dto.ts b/apps/api/src/timelines/dto/update-phase-template.dto.ts new file mode 100644 index 000000000..1fbc963b2 --- /dev/null +++ b/apps/api/src/timelines/dto/update-phase-template.dto.ts @@ -0,0 +1,61 @@ +import { + IsOptional, + IsString, + IsInt, + IsIn, + Min, + IsBoolean, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { COMPLETION_TYPES, type CompletionType } from '../timeline-constants'; + +export class UpdatePhaseTemplateDto { + @ApiPropertyOptional({ description: 'Phase name' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: 'Phase description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Group label for sub-phase grouping' }) + @IsOptional() + @IsString() + groupLabel?: string; + + @ApiPropertyOptional({ + description: 'Position in the phase sequence (0-based)', + minimum: 0, + }) + @IsOptional() + @IsInt() + @Min(0) + orderIndex?: number; + + @ApiPropertyOptional({ + description: 'Default duration in weeks', + minimum: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + defaultDurationWeeks?: number; + + @ApiPropertyOptional({ + description: 'How the phase is completed', + enum: COMPLETION_TYPES, + }) + @IsOptional() + @IsIn(COMPLETION_TYPES) + completionType?: CompletionType; + + @ApiPropertyOptional({ + description: + 'If true, completing this phase locks timeline automation state', + }) + @IsOptional() + @IsBoolean() + locksTimelineOnComplete?: boolean; +} diff --git a/apps/api/src/timelines/dto/update-phase.dto.ts b/apps/api/src/timelines/dto/update-phase.dto.ts new file mode 100644 index 000000000..04a9abbff --- /dev/null +++ b/apps/api/src/timelines/dto/update-phase.dto.ts @@ -0,0 +1,67 @@ +import { + IsOptional, + IsString, + IsInt, + IsDateString, + IsBoolean, + IsIn, + Min, +} from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { COMPLETION_TYPES, type CompletionType } from '../timeline-constants'; + +export class UpdatePhaseDto { + @ApiPropertyOptional({ description: 'Phase name' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ description: 'Phase description' }) + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional({ description: 'Duration in weeks', minimum: 1 }) + @IsOptional() + @IsInt() + @Min(1) + durationWeeks?: number; + + @ApiPropertyOptional({ + description: 'Phase start date', + example: '2026-05-01T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + startDate?: string; + + @ApiPropertyOptional({ + description: 'Phase end date', + example: '2026-06-01T00:00:00.000Z', + }) + @IsOptional() + @IsDateString() + endDate?: string; + + @ApiPropertyOptional({ + description: 'Whether dates are pinned (not recalculated automatically)', + }) + @IsOptional() + @IsBoolean() + datesPinned?: boolean; + + @ApiPropertyOptional({ + description: 'How the phase is completed', + enum: COMPLETION_TYPES, + }) + @IsOptional() + @IsIn(COMPLETION_TYPES) + completionType?: CompletionType; + + @ApiPropertyOptional({ + description: 'Whether completing this phase should lock the timeline', + }) + @IsOptional() + @IsBoolean() + locksTimelineOnComplete?: boolean; +} diff --git a/apps/api/src/timelines/dto/update-template.dto.ts b/apps/api/src/timelines/dto/update-template.dto.ts new file mode 100644 index 000000000..752e40ce4 --- /dev/null +++ b/apps/api/src/timelines/dto/update-template.dto.ts @@ -0,0 +1,18 @@ +import { IsOptional, IsString, IsInt, Min } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +export class UpdateTemplateDto { + @ApiPropertyOptional({ description: 'Template name' }) + @IsOptional() + @IsString() + name?: string; + + @ApiPropertyOptional({ + description: 'Cycle number', + minimum: 1, + }) + @IsOptional() + @IsInt() + @Min(1) + cycleNumber?: number; +} diff --git a/apps/api/src/timelines/timeline-constants.ts b/apps/api/src/timelines/timeline-constants.ts new file mode 100644 index 000000000..64704c9b5 --- /dev/null +++ b/apps/api/src/timelines/timeline-constants.ts @@ -0,0 +1,10 @@ +export const COMPLETION_TYPES = [ + 'AUTO_TASKS', + 'AUTO_POLICIES', + 'AUTO_PEOPLE', + 'AUTO_FINDINGS', + 'AUTO_UPLOAD', + 'MANUAL', +] as const; + +export type CompletionType = (typeof COMPLETION_TYPES)[number]; diff --git a/apps/api/src/timelines/timelines-backfill.helper.ts b/apps/api/src/timelines/timelines-backfill.helper.ts new file mode 100644 index 000000000..e818ab9db --- /dev/null +++ b/apps/api/src/timelines/timelines-backfill.helper.ts @@ -0,0 +1,360 @@ +import { db, TimelineStatus, TimelinePhaseStatus } from '@db'; +import { + resolveTemplate, + createInstanceFromTemplate, +} from './timelines-template-resolver'; +import { recalculatePhaseDates } from './timelines-date.helper'; + +// --------------------------------------------------------------------------- +// Framework name -> Trust field mapping +// --------------------------------------------------------------------------- + +const FRAMEWORK_TRUST_MAP: Record< + string, + { statusField: string; trustFramework: string } +> = { + 'SOC 2': { statusField: 'soc2type2_status', trustFramework: 'soc2_type2' }, + 'SOC 2 v.1': { statusField: 'soc2type1_status', trustFramework: 'soc2_type1' }, + 'ISO 27001': { statusField: 'iso27001_status', trustFramework: 'iso_27001' }, + ISO27001: { statusField: 'iso27001_status', trustFramework: 'iso_27001' }, + 'ISO 42001': { statusField: 'iso42001_status', trustFramework: 'iso_42001' }, + HIPAA: { statusField: 'hipaa_status', trustFramework: 'hipaa' }, + GDPR: { statusField: 'gdpr_status', trustFramework: 'gdpr' }, + 'PCI DSS': { statusField: 'pci_dss_status', trustFramework: 'pci_dss' }, + 'NEN 7510': { statusField: 'nen7510_status', trustFramework: 'nen_7510' }, + 'ISO 9001': { statusField: 'iso9001_status', trustFramework: 'iso_9001' }, +}; + +// --------------------------------------------------------------------------- +// Shared types +// --------------------------------------------------------------------------- + +interface PhaseInput { + id: string; + orderIndex: number; + durationWeeks: number; + datesPinned: boolean; + startDate: Date | null; + endDate: Date | null; +} + +interface PhaseUpdate { + id: string; + status: TimelinePhaseStatus; + startDate: Date; + endDate: Date; + completedAt: Date | null; +} + +// --------------------------------------------------------------------------- +// Data queries +// --------------------------------------------------------------------------- + +async function queryTrustData({ + organizationId, + frameworkName, +}: { + organizationId: string; + frameworkName: string; +}): Promise<{ isCompliant: boolean; hasTrustResource: boolean }> { + const mapping = FRAMEWORK_TRUST_MAP[frameworkName]; + if (!mapping) return { isCompliant: false, hasTrustResource: false }; + + const [trust, trustResource] = await Promise.all([ + db.trust.findUnique({ where: { organizationId } }), + db.trustResource.findUnique({ + where: { + organizationId_framework: { + organizationId, + framework: mapping.trustFramework as never, + }, + }, + }), + ]); + + const statusValue = trust + ? (trust as Record)[mapping.statusField] + : null; + + return { + isCompliant: statusValue === 'compliant', + hasTrustResource: !!trustResource, + }; +} + +interface TaskScoreResult { + totalTasks: number; + allDone: boolean; + lastTaskCompletionDate: Date | null; +} + +async function queryTaskScore( + frameworkInstanceId: string, +): Promise { + const requirementMaps = await db.requirementMap.findMany({ + where: { frameworkInstanceId }, + select: { controlId: true }, + distinct: ['controlId'], + }); + + const controlIds = requirementMaps.map((rm) => rm.controlId); + if (controlIds.length === 0) { + return { totalTasks: 0, allDone: false, lastTaskCompletionDate: null }; + } + + const tasks = await db.task.findMany({ + where: { controls: { some: { id: { in: controlIds } } } }, + select: { id: true, status: true, updatedAt: true }, + distinct: ['id'], + }); + + const totalTasks = tasks.length; + const completed = tasks.filter( + (t) => t.status === 'done' || t.status === 'not_relevant', + ); + const allDone = totalTasks > 0 && completed.length === totalTasks; + + const doneOnly = completed.filter((t) => t.status === 'done'); + const lastTaskCompletionDate = + doneOnly.length > 0 + ? doneOnly.reduce( + (latest, t) => (t.updatedAt > latest ? t.updatedAt : latest), + doneOnly[0].updatedAt, + ) + : null; + + return { totalTasks, allDone, lastTaskCompletionDate }; +} + +// --------------------------------------------------------------------------- +// Phase status assignment +// --------------------------------------------------------------------------- + +function assignCompletedPhases( + phases: PhaseInput[], + startDate: Date, + completedAt: Date, +): PhaseUpdate[] { + return recalculatePhaseDates(phases, startDate).map((p) => ({ + id: p.id, + status: TimelinePhaseStatus.COMPLETED, + startDate: p.startDate, + endDate: p.endDate, + completedAt, + })); +} + +function assignActivePhases( + phases: PhaseInput[], + startDate: Date, + evidenceGatheringDone: boolean, +): PhaseUpdate[] { + const now = new Date(); + const recalculated = recalculatePhaseDates(phases, startDate); + + if (!evidenceGatheringDone) { + return recalculated.map((p, idx) => ({ + id: p.id, + status: idx === 0 ? TimelinePhaseStatus.IN_PROGRESS : TimelinePhaseStatus.PENDING, + startDate: p.startDate, + endDate: p.endDate, + completedAt: null, + })); + } + + // Evidence gathering done -- walk forward through phases + let foundCurrent = false; + return recalculated.map((p, idx) => { + // First phase is always completed + if (idx === 0) { + const endDate = p.endDate < now ? p.endDate : now; + return { + id: p.id, status: TimelinePhaseStatus.COMPLETED, + startDate: p.startDate, endDate, completedAt: endDate, + }; + } + if (foundCurrent) { + return { + id: p.id, status: TimelinePhaseStatus.PENDING, + startDate: p.startDate, endDate: p.endDate, completedAt: null, + }; + } + if (p.endDate < now) { + return { + id: p.id, status: TimelinePhaseStatus.COMPLETED, + startDate: p.startDate, endDate: p.endDate, completedAt: p.endDate, + }; + } + // Current phase + foundCurrent = true; + return { + id: p.id, status: TimelinePhaseStatus.IN_PROGRESS, + startDate: p.startDate, endDate: p.endDate, completedAt: null, + }; + }); +} + +// --------------------------------------------------------------------------- +// Shared DB update +// --------------------------------------------------------------------------- + +async function applyBackfillState( + instanceId: string, + phaseUpdates: PhaseUpdate[], + instanceData: { status: TimelineStatus; startDate: Date; completedAt?: Date }, +) { + await db.$transaction(async (tx) => { + for (const phase of phaseUpdates) { + await tx.timelinePhase.update({ + where: { id: phase.id }, + data: { + status: phase.status, + startDate: phase.startDate, + endDate: phase.endDate, + completedAt: phase.completedAt, + }, + }); + } + await tx.timelineInstance.update({ + where: { id: instanceId }, + data: { + status: instanceData.status, + startDate: instanceData.startDate, + completedAt: instanceData.completedAt ?? null, + }, + }); + }); +} + +// --------------------------------------------------------------------------- +// Main backfill function +// --------------------------------------------------------------------------- + +/** + * Frameworks that need multiple timelines per instance (e.g., SOC 2 has Type 1 + Type 2). + * Maps framework name → array of cycle numbers to create. + */ +const MULTI_TIMELINE_FRAMEWORKS: Record< + string, + Array<{ cycleNumber: number; trackKey: string }> +> = { + // Type 1 and Type 2 are independent tracks with their own cycle 1. + 'SOC 2': [ + { cycleNumber: 1, trackKey: 'soc2_type1' }, + { cycleNumber: 1, trackKey: 'soc2_type2' }, + ], +}; + +export async function backfillTimeline({ + organizationId, + frameworkInstance, + forceRefresh = false, +}: { + organizationId: string; + frameworkInstance: { + id: string; + frameworkId: string; + framework: { id: string; name: string }; + }; + forceRefresh?: boolean; +}): Promise { + const { framework } = frameworkInstance; + const timelinesToCreate = + MULTI_TIMELINE_FRAMEWORKS[framework.name] ?? [ + { cycleNumber: 1, trackKey: 'primary' }, + ]; + + for (const timelineToCreate of timelinesToCreate) { + try { + await backfillSingleTimeline({ + organizationId, + frameworkInstance, + cycleNumber: timelineToCreate.cycleNumber, + trackKey: timelineToCreate.trackKey, + forceRefresh, + }); + } catch { + // Non-blocking per-cycle — continue with others + } + } +} + +async function backfillSingleTimeline({ + organizationId, + frameworkInstance, + cycleNumber, + trackKey, + forceRefresh = false, +}: { + organizationId: string; + frameworkInstance: { + id: string; + frameworkId: string; + framework: { id: string; name: string }; + }; + cycleNumber: number; + trackKey: string; + forceRefresh?: boolean; +}): Promise { + const { framework } = frameworkInstance; + + // Check if this specific cycle already exists + const existing = await db.timelineInstance.findFirst({ + where: { + frameworkInstanceId: frameworkInstance.id, + trackKey, + cycleNumber, + }, + }); + if (existing) return; + + // Step 1: Resolve and create DRAFT instance from template + const template = await resolveTemplate( + frameworkInstance.frameworkId, + framework.name, + cycleNumber, + { forceRefresh, trackKey }, + ); + if (!template) return; + + const instance = await createInstanceFromTemplate({ + organizationId, + frameworkInstanceId: frameworkInstance.id, + cycleNumber, + template, + }); + + // Step 2: Query trust + task data in parallel + const [trustData, taskScore] = await Promise.all([ + queryTrustData({ organizationId, frameworkName: framework.name }), + queryTaskScore(frameworkInstance.id), + ]); + + // Infer start date from latest task completion or fall back to ~6 months ago + const inferredStartDate = + taskScore.lastTaskCompletionDate ?? + new Date(Date.now() - 26 * 7 * 24 * 60 * 60 * 1000); + + // Step 3: Determine state and update + + if (trustData.isCompliant || trustData.hasTrustResource) { + const completedAt = new Date(); + await applyBackfillState( + instance.id, + assignCompletedPhases(instance.phases, inferredStartDate, completedAt), + { status: TimelineStatus.COMPLETED, startDate: inferredStartDate, completedAt }, + ); + return; + } + + if (taskScore.totalTasks > 0) { + await applyBackfillState( + instance.id, + assignActivePhases(instance.phases, inferredStartDate, taskScore.allDone), + { status: TimelineStatus.ACTIVE, startDate: inferredStartDate }, + ); + return; + } + + // No trust data, no tasks -- keep as DRAFT (already created that way) +} diff --git a/apps/api/src/timelines/timelines-date.helper.spec.ts b/apps/api/src/timelines/timelines-date.helper.spec.ts new file mode 100644 index 000000000..f101ceaa2 --- /dev/null +++ b/apps/api/src/timelines/timelines-date.helper.spec.ts @@ -0,0 +1,131 @@ +import { recalculatePhaseDates } from './timelines-date.helper'; + +interface TestPhase { + orderIndex: number; + durationWeeks: number; + datesPinned: boolean; + startDate: Date | null; + endDate: Date | null; +} + +describe('recalculatePhaseDates', () => { + it('calculates sequential dates from start date', () => { + const phases: TestPhase[] = [ + { + orderIndex: 0, + durationWeeks: 8, + datesPinned: false, + startDate: null, + endDate: null, + }, + { + orderIndex: 1, + durationWeeks: 4, + datesPinned: false, + startDate: null, + endDate: null, + }, + { + orderIndex: 2, + durationWeeks: 2, + datesPinned: false, + startDate: null, + endDate: null, + }, + ]; + const startDate = new Date('2026-01-15'); + const result = recalculatePhaseDates(phases, startDate); + + expect(result[0].startDate).toEqual(new Date('2026-01-15')); + expect(result[0].endDate).toEqual(new Date('2026-03-12')); + expect(result[1].startDate).toEqual(new Date('2026-03-12')); + expect(result[1].endDate).toEqual(new Date('2026-04-09')); + expect(result[2].startDate).toEqual(new Date('2026-04-09')); + expect(result[2].endDate).toEqual(new Date('2026-04-23')); + }); + + it('skips pinned phases but uses their endDate for the next phase', () => { + const phases: TestPhase[] = [ + { + orderIndex: 0, + durationWeeks: 8, + datesPinned: false, + startDate: null, + endDate: null, + }, + { + orderIndex: 1, + durationWeeks: 4, + datesPinned: true, + startDate: new Date('2026-03-15'), + endDate: new Date('2026-04-20'), + }, + { + orderIndex: 2, + durationWeeks: 2, + datesPinned: false, + startDate: null, + endDate: null, + }, + ]; + const startDate = new Date('2026-01-15'); + const result = recalculatePhaseDates(phases, startDate); + + expect(result[0].startDate).toEqual(new Date('2026-01-15')); + // Phase 1 is pinned -- dates unchanged + expect(result[1].startDate).toEqual(new Date('2026-03-15')); + expect(result[1].endDate).toEqual(new Date('2026-04-20')); + // Phase 2 starts from pinned phase's endDate + expect(result[2].startDate).toEqual(new Date('2026-04-20')); + expect(result[2].endDate).toEqual(new Date('2026-05-04')); + }); + + it('handles single phase', () => { + const phases: TestPhase[] = [ + { + orderIndex: 0, + durationWeeks: 8, + datesPinned: false, + startDate: null, + endDate: null, + }, + ]; + const startDate = new Date('2026-01-15'); + const result = recalculatePhaseDates(phases, startDate); + + expect(result).toHaveLength(1); + expect(result[0].startDate).toEqual(new Date('2026-01-15')); + }); + + it('sorts phases by orderIndex regardless of input order', () => { + const phases: TestPhase[] = [ + { + orderIndex: 2, + durationWeeks: 2, + datesPinned: false, + startDate: null, + endDate: null, + }, + { + orderIndex: 0, + durationWeeks: 8, + datesPinned: false, + startDate: null, + endDate: null, + }, + { + orderIndex: 1, + durationWeeks: 4, + datesPinned: false, + startDate: null, + endDate: null, + }, + ]; + const startDate = new Date('2026-01-15'); + const result = recalculatePhaseDates(phases, startDate); + + expect(result[0].orderIndex).toBe(0); + expect(result[1].orderIndex).toBe(1); + expect(result[2].orderIndex).toBe(2); + }); +}); diff --git a/apps/api/src/timelines/timelines-date.helper.ts b/apps/api/src/timelines/timelines-date.helper.ts new file mode 100644 index 000000000..0cefa3dc9 --- /dev/null +++ b/apps/api/src/timelines/timelines-date.helper.ts @@ -0,0 +1,40 @@ +interface PhaseForRecalculation { + orderIndex: number; + durationWeeks: number; + datesPinned: boolean; + startDate: Date | null; + endDate: Date | null; +} + +function addWeeks(date: Date, weeks: number): Date { + const result = new Date(date); + result.setUTCDate(result.getUTCDate() + weeks * 7); + return result; +} + +export function recalculatePhaseDates( + phases: T[], + timelineStartDate: Date, +): (T & { startDate: Date; endDate: Date })[] { + const sorted = [...phases].sort((a, b) => a.orderIndex - b.orderIndex); + // Defensively copy the caller's start date so downstream mutation of any + // returned startDate/endDate can't write back into the input. + let currentDate = new Date(timelineStartDate); + + return sorted.map((phase) => { + if (phase.datesPinned && phase.startDate && phase.endDate) { + // Also defensively copy the pinned dates so the returned objects don't + // alias the input phase rows. + const pinnedStart = new Date(phase.startDate); + const pinnedEnd = new Date(phase.endDate); + currentDate = new Date(pinnedEnd); + return { ...phase, startDate: pinnedStart, endDate: pinnedEnd }; + } + + const startDate = new Date(currentDate); + const endDate = addWeeks(startDate, phase.durationWeeks); + currentDate = new Date(endDate); + + return { ...phase, startDate, endDate }; + }); +} diff --git a/apps/api/src/timelines/timelines-lifecycle.service.spec.ts b/apps/api/src/timelines/timelines-lifecycle.service.spec.ts new file mode 100644 index 000000000..04a8c55fa --- /dev/null +++ b/apps/api/src/timelines/timelines-lifecycle.service.spec.ts @@ -0,0 +1,422 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { TimelinesLifecycleService } from './timelines-lifecycle.service'; + +jest.mock('@db', () => ({ + db: { + timelineInstance: { + findUnique: jest.fn(), + update: jest.fn(), + }, + timelinePhase: { + update: jest.fn(), + findMany: jest.fn(), + count: jest.fn(), + }, + $transaction: jest.fn(), + }, + TimelineStatus: { + DRAFT: 'DRAFT', + ACTIVE: 'ACTIVE', + PAUSED: 'PAUSED', + COMPLETED: 'COMPLETED', + }, + TimelinePhaseStatus: { + PENDING: 'PENDING', + IN_PROGRESS: 'IN_PROGRESS', + COMPLETED: 'COMPLETED', + }, +})); + +jest.mock('./timelines-slack.helper', () => ({ + notifyPhaseCompleted: jest.fn(), + notifyTimelineCompleted: jest.fn(), +})); + +import { db } from '@db'; + +const mockDb = db as jest.Mocked; + +describe('TimelinesLifecycleService', () => { + let service: TimelinesLifecycleService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new TimelinesLifecycleService(); + }); + + it('activates a draft timeline and marks only first phase as IN_PROGRESS', async () => { + const instance = { + id: 'tli_1', + organizationId: 'org_1', + status: 'DRAFT', + phases: [ + { + id: 'p1', + orderIndex: 0, + durationWeeks: 2, + datesPinned: false, + startDate: null, + endDate: null, + }, + { + id: 'p2', + orderIndex: 1, + durationWeeks: 3, + datesPinned: false, + startDate: null, + endDate: null, + }, + ], + }; + + const tx = { + timelinePhase: { update: jest.fn() }, + timelineInstance: { + update: jest.fn().mockResolvedValue({ id: 'tli_1', status: 'ACTIVE' }), + }, + }; + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue(instance); + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => fn(tx)); + + const startDate = new Date('2026-01-01T00:00:00.000Z'); + await service.activate('tli_1', 'org_1', startDate); + + expect(tx.timelinePhase.update).toHaveBeenCalledTimes(2); + expect(tx.timelinePhase.update).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + where: { id: 'p1' }, + data: expect.objectContaining({ status: 'IN_PROGRESS' }), + }), + ); + expect(tx.timelinePhase.update).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + where: { id: 'p2' }, + data: expect.objectContaining({ status: 'PENDING' }), + }), + ); + }); + + it('shifts only eligible phases on resume', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-01-08T00:00:00.000Z')); + + const pausedAt = new Date('2026-01-01T00:00:00.000Z'); + const instance = { + id: 'tli_1', + organizationId: 'org_1', + status: 'PAUSED', + pausedAt, + phases: [ + { + id: 'shift_me', + status: 'PENDING', + datesPinned: false, + startDate: new Date('2026-01-02T00:00:00.000Z'), + endDate: new Date('2026-01-09T00:00:00.000Z'), + }, + { + id: 'completed', + status: 'COMPLETED', + datesPinned: false, + startDate: new Date('2026-01-02T00:00:00.000Z'), + endDate: new Date('2026-01-09T00:00:00.000Z'), + }, + { + id: 'pinned', + status: 'PENDING', + datesPinned: true, + startDate: new Date('2026-01-02T00:00:00.000Z'), + endDate: new Date('2026-01-09T00:00:00.000Z'), + }, + { + id: 'no_dates', + status: 'PENDING', + datesPinned: false, + startDate: null, + endDate: null, + }, + ], + }; + + const tx = { + timelinePhase: { update: jest.fn() }, + timelineInstance: { + update: jest.fn().mockResolvedValue({ id: 'tli_1', status: 'ACTIVE' }), + }, + }; + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue(instance); + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => fn(tx)); + + await service.resume('tli_1', 'org_1'); + + expect(tx.timelinePhase.update).toHaveBeenCalledTimes(1); + expect(tx.timelinePhase.update).toHaveBeenCalledWith({ + where: { id: 'shift_me' }, + data: { + startDate: new Date('2026-01-09T00:00:00.000Z'), + endDate: new Date('2026-01-16T00:00:00.000Z'), + }, + }); + + jest.useRealTimers(); + }); + + it('throws when completing a phase that is not IN_PROGRESS', async () => { + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + phases: [ + { id: 'p1', orderIndex: 0, status: 'IN_PROGRESS' }, + { id: 'p2', orderIndex: 1, status: 'PENDING' }, + ], + }); + + await expect( + service.completePhase('tli_1', 'p2', 'org_1', 'usr_1'), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when prior phases are not completed', async () => { + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + phases: [ + { id: 'p1', orderIndex: 0, status: 'PENDING' }, + { id: 'p2', orderIndex: 1, status: 'IN_PROGRESS' }, + ], + }); + + await expect( + service.completePhase('tli_1', 'p2', 'org_1', 'usr_1'), + ).rejects.toThrow(BadRequestException); + }); + + it('marks timeline completed when last phase is completed', async () => { + const tx = { + timelinePhase: { + update: jest.fn(), + findMany: jest.fn().mockResolvedValue([ + { + id: 'p1', + orderIndex: 0, + status: 'COMPLETED', + durationWeeks: 2, + datesPinned: false, + startDate: new Date('2026-01-01T00:00:00.000Z'), + endDate: new Date('2026-01-08T00:00:00.000Z'), + }, + ]), + count: jest.fn().mockResolvedValue(0), + }, + timelineInstance: { + update: jest.fn(), + findUnique: jest.fn().mockResolvedValue({ + id: 'tli_1', + status: 'COMPLETED', + phases: [{ id: 'p1', status: 'COMPLETED' }], + organization: { id: 'org_1', name: 'Acme' }, + template: { id: 'tml_1', name: 'SOC 2 Type 2' }, + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + }), + }, + }; + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'p1', + name: 'Final Report', + completionType: 'AUTO_UPLOAD', + orderIndex: 0, + status: 'IN_PROGRESS', + durationWeeks: 2, + datesPinned: false, + startDate: new Date('2026-01-01T00:00:00.000Z'), + endDate: new Date('2099-01-15T00:00:00.000Z'), + }, + ], + }); + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => fn(tx)); + + await service.completePhase('tli_1', 'p1', 'org_1', 'usr_1'); + + expect(tx.timelineInstance.update).toHaveBeenCalledWith({ + where: { id: 'tli_1' }, + data: expect.objectContaining({ + status: 'COMPLETED', + completedAt: expect.any(Date), + lockedAt: expect.any(Date), + lockedById: 'usr_1', + }), + }); + expect(tx.timelinePhase.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'p1' }, + data: expect.objectContaining({ + status: 'COMPLETED', + completedById: 'usr_1', + datesPinned: true, + endDate: expect.any(Date), + }), + }), + ); + }); + + it('locks timeline when completing a phase flagged to lock on completion', async () => { + const tx = { + timelinePhase: { + update: jest.fn(), + findMany: jest.fn().mockResolvedValue([ + { + id: 'p1', + orderIndex: 0, + status: 'COMPLETED', + durationWeeks: 2, + datesPinned: false, + startDate: new Date('2026-01-01T00:00:00.000Z'), + endDate: new Date('2026-01-08T00:00:00.000Z'), + }, + { + id: 'p2', + orderIndex: 1, + status: 'PENDING', + durationWeeks: 2, + datesPinned: false, + startDate: new Date('2026-01-08T00:00:00.000Z'), + endDate: new Date('2026-01-15T00:00:00.000Z'), + }, + ]), + count: jest.fn().mockResolvedValue(1), + }, + timelineInstance: { + update: jest.fn(), + findUnique: jest.fn().mockResolvedValue({ + id: 'tli_1', + status: 'ACTIVE', + phases: [], + organization: { id: 'org_1', name: 'Acme' }, + template: { id: 'tml_1', name: 'SOC 2 Type 2' }, + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + }), + }, + }; + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + lockedAt: null, + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'p1', + name: 'Observation', + completionType: 'MANUAL', + locksTimelineOnComplete: true, + orderIndex: 0, + status: 'IN_PROGRESS', + durationWeeks: 2, + datesPinned: false, + startDate: new Date('2026-01-01T00:00:00.000Z'), + endDate: new Date('2099-01-15T00:00:00.000Z'), + }, + { + id: 'p2', + name: 'Audit Review', + completionType: 'MANUAL', + locksTimelineOnComplete: false, + orderIndex: 1, + status: 'PENDING', + durationWeeks: 2, + datesPinned: false, + startDate: new Date('2026-01-15T00:00:00.000Z'), + endDate: new Date('2099-01-29T00:00:00.000Z'), + }, + ], + }); + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => fn(tx)); + + await service.completePhase('tli_1', 'p1', 'org_1', 'usr_1'); + + expect(tx.timelineInstance.update).toHaveBeenCalledWith({ + where: { id: 'tli_1' }, + data: { + lockedAt: expect.any(Date), + lockedById: 'usr_1', + unlockedAt: null, + unlockedById: null, + unlockReason: null, + }, + }); + }); + + it('unlocks a locked timeline and stores unlock metadata', async () => { + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + status: 'ACTIVE', + lockedAt: new Date('2026-01-10T00:00:00.000Z'), + }); + (mockDb.timelineInstance.update as jest.Mock).mockResolvedValue({ + id: 'tli_1', + lockedAt: null, + unlockedById: 'usr_admin', + unlockReason: 'Audit scope changed', + }); + + await service.unlock('tli_1', 'org_1', 'usr_admin', 'Audit scope changed'); + + expect(mockDb.timelineInstance.update).toHaveBeenCalledWith({ + where: { id: 'tli_1' }, + data: { + lockedAt: null, + lockedById: null, + unlockedAt: expect.any(Date), + unlockedById: 'usr_admin', + unlockReason: 'Audit scope changed', + }, + include: expect.any(Object), + }); + }); + + it('throws when unlocking a timeline that is not locked', async () => { + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + status: 'ACTIVE', + lockedAt: null, + }); + + await expect( + service.unlock('tli_1', 'org_1', 'usr_admin', 'Need to adjust phase data'), + ).rejects.toThrow(BadRequestException); + }); + + it('throws when unlocking a completed timeline', async () => { + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + status: 'COMPLETED', + lockedAt: new Date('2026-01-10T00:00:00.000Z'), + }); + + await expect( + service.unlock('tli_1', 'org_1', 'usr_admin', 'Need to adjust phase data'), + ).rejects.toThrow(BadRequestException); + + expect(mockDb.timelineInstance.update).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException for missing timeline on activate', async () => { + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + service.activate('missing', 'org_1', new Date('2026-01-01')), + ).rejects.toThrow(NotFoundException); + }); +}); diff --git a/apps/api/src/timelines/timelines-lifecycle.service.ts b/apps/api/src/timelines/timelines-lifecycle.service.ts new file mode 100644 index 000000000..8afcdc430 --- /dev/null +++ b/apps/api/src/timelines/timelines-lifecycle.service.ts @@ -0,0 +1,331 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db, TimelineStatus, TimelinePhaseStatus } from '@db'; +import { recalculatePhaseDates } from './timelines-date.helper'; +import { notifyPhaseCompleted, notifyTimelineCompleted } from './timelines-slack.helper'; + +/** Shared Prisma include for timeline instance queries. */ +const INSTANCE_INCLUDE = { + phases: { orderBy: { orderIndex: 'asc' } as const }, + frameworkInstance: { include: { framework: true } }, + organization: { select: { id: true, name: true } }, + template: true, +}; + +@Injectable() +export class TimelinesLifecycleService { + async activate(id: string, organizationId: string, startDate: Date) { + const instance = await db.timelineInstance.findUnique({ + where: { id, organizationId }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + if (instance.status !== TimelineStatus.DRAFT) { + throw new BadRequestException('Only draft timelines can be activated'); + } + + const recalculated = recalculatePhaseDates(instance.phases, startDate); + + return db.$transaction(async (tx) => { + for (const phase of recalculated) { + const isFirst = phase.orderIndex === recalculated[0].orderIndex; + await tx.timelinePhase.update({ + where: { id: phase.id }, + data: { + startDate: phase.startDate, + endDate: phase.endDate, + status: isFirst + ? TimelinePhaseStatus.IN_PROGRESS + : TimelinePhaseStatus.PENDING, + }, + }); + } + + return tx.timelineInstance.update({ + where: { id }, + data: { startDate, status: TimelineStatus.ACTIVE }, + include: INSTANCE_INCLUDE, + }); + }); + } + + async pause(id: string, organizationId: string) { + const instance = await db.timelineInstance.findUnique({ + where: { id, organizationId }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + if (instance.status !== TimelineStatus.ACTIVE) { + throw new BadRequestException('Only active timelines can be paused'); + } + + return db.timelineInstance.update({ + where: { id }, + data: { status: TimelineStatus.PAUSED, pausedAt: new Date() }, + include: INSTANCE_INCLUDE, + }); + } + + async resume(id: string, organizationId: string) { + const instance = await db.timelineInstance.findUnique({ + where: { id, organizationId }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + if (instance.status !== TimelineStatus.PAUSED) { + throw new BadRequestException('Only paused timelines can be resumed'); + } + + const pausedAt = instance.pausedAt; + if (!pausedAt) { + throw new BadRequestException('Timeline has no pause timestamp'); + } + + const pauseDurationMs = Date.now() - pausedAt.getTime(); + + return db.$transaction(async (tx) => { + for (const phase of instance.phases) { + if (phase.datesPinned) continue; + if (phase.status === TimelinePhaseStatus.COMPLETED) continue; + if (!phase.startDate || !phase.endDate) continue; + + await tx.timelinePhase.update({ + where: { id: phase.id }, + data: { + startDate: new Date(phase.startDate.getTime() + pauseDurationMs), + endDate: new Date(phase.endDate.getTime() + pauseDurationMs), + }, + }); + } + + return tx.timelineInstance.update({ + where: { id }, + data: { status: TimelineStatus.ACTIVE, pausedAt: null }, + include: INSTANCE_INCLUDE, + }); + }); + } + + async completePhase( + instanceId: string, + phaseId: string, + organizationId: string, + userId?: string, + ) { + const instance = await db.timelineInstance.findUnique({ + where: { id: instanceId, organizationId }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + const phase = instance.phases.find((p) => p.id === phaseId); + if (!phase) { + throw new NotFoundException('Phase not found'); + } + + if (phase.status === TimelinePhaseStatus.COMPLETED) { + throw new BadRequestException('Phase is already completed'); + } + + if (phase.status !== TimelinePhaseStatus.IN_PROGRESS) { + throw new BadRequestException('Only in-progress phases can be completed'); + } + + const hasIncompletePrior = instance.phases + .filter((p) => p.orderIndex < phase.orderIndex) + .some((p) => p.status !== TimelinePhaseStatus.COMPLETED); + + if (hasIncompletePrior) { + throw new BadRequestException( + 'Cannot complete phase before prior phases are completed', + ); + } + + const txResult = await db.$transaction(async (tx) => { + const now = new Date(); + let lockApplied = false; + + // If completing before planned end, update endDate and pin it + // so downstream recalculation anchors off the actual completion date + const finishedEarly = !phase.endDate || now.getTime() < new Date(phase.endDate).getTime(); + + await tx.timelinePhase.update({ + where: { id: phaseId }, + data: { + status: TimelinePhaseStatus.COMPLETED, + completedAt: now, + completedById: userId ?? null, + ...(finishedEarly ? { endDate: now, datesPinned: true } : {}), + }, + }); + + if (phase.locksTimelineOnComplete && !instance.lockedAt) { + await tx.timelineInstance.update({ + where: { id: instanceId }, + data: { + lockedAt: now, + lockedById: userId ?? null, + unlockedAt: null, + unlockedById: null, + unlockReason: null, + }, + }); + lockApplied = true; + } + + // Advance next pending phase to IN_PROGRESS — but only if all prior phases are completed + const freshPhases = await tx.timelinePhase.findMany({ + where: { instanceId }, + orderBy: { orderIndex: 'asc' }, + }); + + const nextPhase = freshPhases.find( + (p) => p.status === TimelinePhaseStatus.PENDING, + ); + + const allPriorCompleted = nextPhase + ? freshPhases + .filter((p) => p.orderIndex < nextPhase.orderIndex) + .every((p) => p.status === TimelinePhaseStatus.COMPLETED) + : false; + + if (nextPhase && allPriorCompleted) { + await tx.timelinePhase.update({ + where: { id: nextPhase.id }, + data: { status: TimelinePhaseStatus.IN_PROGRESS }, + }); + + // Recalculate downstream dates + if (instance.startDate) { + const allPhases = await tx.timelinePhase.findMany({ + where: { instanceId }, + orderBy: { orderIndex: 'asc' }, + }); + + const recalculated = recalculatePhaseDates( + allPhases, + instance.startDate, + ); + + for (const rp of recalculated) { + if (rp.orderIndex <= phase.orderIndex) continue; + await tx.timelinePhase.update({ + where: { id: rp.id }, + data: { startDate: rp.startDate, endDate: rp.endDate }, + }); + } + } + } + + // Check if all phases are now completed + const remainingPending = await tx.timelinePhase.count({ + where: { + instanceId, + status: { not: TimelinePhaseStatus.COMPLETED }, + id: { not: phaseId }, + }, + }); + + const allCompleted = remainingPending === 0; + + if (allCompleted) { + await tx.timelineInstance.update({ + where: { id: instanceId }, + data: { + status: TimelineStatus.COMPLETED, + completedAt: now, + ...(!instance.lockedAt && !lockApplied + ? { + lockedAt: now, + lockedById: userId ?? null, + unlockedAt: null, + unlockedById: null, + unlockReason: null, + } + : {}), + }, + }); + } + + const result = await tx.timelineInstance.findUnique({ + where: { id: instanceId }, + include: INSTANCE_INCLUDE, + }); + + return { result, allCompleted, phaseName: phase.name, completionType: phase.completionType }; + }); + + // Fire-and-forget Slack notifications + const orgName = txResult.result?.organization?.name ?? organizationId; + const frameworkName = + txResult.result?.template?.name ?? + txResult.result?.frameworkInstance?.framework?.name ?? + 'Unknown'; + + notifyPhaseCompleted({ + orgId: organizationId, + orgName, + frameworkName, + phaseName: txResult.phaseName, + completionType: txResult.completionType, + }); + + if (txResult.allCompleted) { + notifyTimelineCompleted({ orgId: organizationId, orgName, frameworkName }); + } + + return txResult.result; + } + + async unlock( + id: string, + organizationId: string, + unlockedById: string, + unlockReason: string, + ) { + const instance = await db.timelineInstance.findUnique({ + where: { id, organizationId }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + if (instance.status === TimelineStatus.COMPLETED) { + throw new BadRequestException('Completed timelines cannot be unlocked'); + } + + if (!instance.lockedAt) { + throw new BadRequestException('Timeline is not locked'); + } + + return db.timelineInstance.update({ + where: { id }, + data: { + lockedAt: null, + lockedById: null, + unlockedAt: new Date(), + unlockedById, + unlockReason, + }, + include: INSTANCE_INCLUDE, + }); + } +} diff --git a/apps/api/src/timelines/timelines-phases.service.spec.ts b/apps/api/src/timelines/timelines-phases.service.spec.ts new file mode 100644 index 000000000..e8c434a53 --- /dev/null +++ b/apps/api/src/timelines/timelines-phases.service.spec.ts @@ -0,0 +1,238 @@ +import { NotFoundException } from '@nestjs/common'; +import { TimelinesPhasesService } from './timelines-phases.service'; + +jest.mock('@db', () => ({ + db: { + timelineInstance: { + findUnique: jest.fn(), + }, + timelinePhase: { + update: jest.fn(), + findMany: jest.fn(), + }, + }, + PhaseCompletionType: { + AUTO_TASKS: 'AUTO_TASKS', + AUTO_POLICIES: 'AUTO_POLICIES', + AUTO_PEOPLE: 'AUTO_PEOPLE', + AUTO_FINDINGS: 'AUTO_FINDINGS', + AUTO_UPLOAD: 'AUTO_UPLOAD', + MANUAL: 'MANUAL', + }, +})); + +import { db } from '@db'; + +const mockDb = db as jest.Mocked; + +describe('TimelinesPhasesService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('auto-completes AUTO_UPLOAD phase when documentUrl is provided', async () => { + const lifecycle = { + completePhase: jest.fn().mockResolvedValue({ id: 'phase_1', status: 'COMPLETED' }), + }; + const service = new TimelinesPhasesService(lifecycle as any); + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'phase_1', + completionType: 'AUTO_UPLOAD', + status: 'IN_PROGRESS', + durationWeeks: 2, + startDate: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + }); + (mockDb.timelinePhase.update as jest.Mock).mockResolvedValue({ + id: 'phase_1', + completionType: 'AUTO_UPLOAD', + status: 'IN_PROGRESS', + }); + + const result = await service.updatePhase('tli_1', 'phase_1', 'org_1', { + documentUrl: 'https://files.example.com/final-report.pdf', + }); + + expect(lifecycle.completePhase).toHaveBeenCalledWith('tli_1', 'phase_1', 'org_1'); + expect(result).toEqual({ id: 'phase_1', status: 'COMPLETED' }); + }); + + it('does not auto-complete MANUAL phase when documentUrl is provided', async () => { + const lifecycle = { + completePhase: jest.fn(), + }; + const service = new TimelinesPhasesService(lifecycle as any); + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'phase_1', + completionType: 'MANUAL', + status: 'IN_PROGRESS', + durationWeeks: 2, + startDate: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + }); + (mockDb.timelinePhase.update as jest.Mock).mockResolvedValue({ + id: 'phase_1', + completionType: 'MANUAL', + status: 'IN_PROGRESS', + }); + + const result = await service.updatePhase('tli_1', 'phase_1', 'org_1', { + documentUrl: 'https://files.example.com/evidence.pdf', + }); + + expect(lifecycle.completePhase).not.toHaveBeenCalled(); + expect(result).toEqual({ + id: 'phase_1', + completionType: 'MANUAL', + status: 'IN_PROGRESS', + }); + }); + + it('does not auto-complete AUTO_UPLOAD phase when timeline is locked', async () => { + const lifecycle = { + completePhase: jest.fn(), + }; + const service = new TimelinesPhasesService(lifecycle as any); + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + lockedAt: new Date('2026-01-20T00:00:00.000Z'), + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'phase_1', + completionType: 'AUTO_UPLOAD', + status: 'IN_PROGRESS', + durationWeeks: 2, + startDate: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + }); + (mockDb.timelinePhase.update as jest.Mock).mockResolvedValue({ + id: 'phase_1', + completionType: 'AUTO_UPLOAD', + status: 'IN_PROGRESS', + }); + + await service.updatePhase('tli_1', 'phase_1', 'org_1', { + documentUrl: 'https://files.example.com/final-report.pdf', + }); + + expect(lifecycle.completePhase).not.toHaveBeenCalled(); + }); + + it('updates locksTimelineOnComplete when provided', async () => { + const lifecycle = { + completePhase: jest.fn(), + }; + const service = new TimelinesPhasesService(lifecycle as any); + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'phase_1', + completionType: 'MANUAL', + status: 'IN_PROGRESS', + durationWeeks: 2, + startDate: new Date('2026-01-01T00:00:00.000Z'), + locksTimelineOnComplete: false, + }, + ], + }); + + (mockDb.timelinePhase.update as jest.Mock).mockResolvedValue({ + id: 'phase_1', + completionType: 'MANUAL', + status: 'IN_PROGRESS', + locksTimelineOnComplete: true, + }); + + const result = await service.updatePhase('tli_1', 'phase_1', 'org_1', { + locksTimelineOnComplete: true, + } as any); + + expect(mockDb.timelinePhase.update).toHaveBeenCalledWith({ + where: { id: 'phase_1' }, + data: expect.objectContaining({ + locksTimelineOnComplete: true, + }), + }); + expect(result).toEqual( + expect.objectContaining({ + id: 'phase_1', + locksTimelineOnComplete: true, + }), + ); + }); + + it('updates completionType when provided', async () => { + const lifecycle = { + completePhase: jest.fn(), + }; + const service = new TimelinesPhasesService(lifecycle as any); + + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue({ + id: 'tli_1', + organizationId: 'org_1', + startDate: new Date('2026-01-01T00:00:00.000Z'), + phases: [ + { + id: 'phase_1', + completionType: 'MANUAL', + status: 'IN_PROGRESS', + durationWeeks: 2, + startDate: new Date('2026-01-01T00:00:00.000Z'), + }, + ], + }); + (mockDb.timelinePhase.update as jest.Mock).mockResolvedValue({ + id: 'phase_1', + completionType: 'AUTO_FINDINGS', + status: 'IN_PROGRESS', + }); + + const result = await service.updatePhase('tli_1', 'phase_1', 'org_1', { + completionType: 'AUTO_FINDINGS', + } as any); + + expect(mockDb.timelinePhase.update).toHaveBeenCalledWith({ + where: { id: 'phase_1' }, + data: expect.objectContaining({ + completionType: 'AUTO_FINDINGS', + }), + }); + expect(result).toEqual( + expect.objectContaining({ + id: 'phase_1', + completionType: 'AUTO_FINDINGS', + }), + ); + }); + + it('throws when timeline instance is missing', async () => { + const service = new TimelinesPhasesService({ completePhase: jest.fn() } as any); + (mockDb.timelineInstance.findUnique as jest.Mock).mockResolvedValue(null); + + await expect( + service.updatePhase('missing', 'phase_1', 'org_1', { name: 'Updated' }), + ).rejects.toThrow(NotFoundException); + }); +}); diff --git a/apps/api/src/timelines/timelines-phases.service.ts b/apps/api/src/timelines/timelines-phases.service.ts new file mode 100644 index 000000000..dec25f9cd --- /dev/null +++ b/apps/api/src/timelines/timelines-phases.service.ts @@ -0,0 +1,202 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db, PhaseCompletionType } from '@db'; +import { TimelinesLifecycleService } from './timelines-lifecycle.service'; +import { recalculatePhaseDates } from './timelines-date.helper'; + +@Injectable() +export class TimelinesPhasesService { + constructor( + private readonly lifecycle: TimelinesLifecycleService, + ) {} + + async updatePhase( + instanceId: string, + phaseId: string, + organizationId: string, + data: { + name?: string; + description?: string; + startDate?: Date; + endDate?: Date; + durationWeeks?: number; + datesPinned?: boolean; + completionType?: PhaseCompletionType; + documentUrl?: string; + documentName?: string; + locksTimelineOnComplete?: boolean; + }, + ) { + const instance = await db.timelineInstance.findUnique({ + where: { id: instanceId, organizationId }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + const phase = instance.phases.find((p) => p.id === phaseId); + if (!phase) { + throw new NotFoundException('Phase not found'); + } + + // Resolve the effective endDate: prefer an explicit endDate from the + // caller, otherwise compute from startDate + duration when either changed. + const newDuration = data.durationWeeks ?? phase.durationWeeks; + const newStartDate = data.startDate ?? phase.startDate; + let newEndDate: Date | undefined = data.endDate; + + if (newEndDate === undefined && (data.startDate || data.durationWeeks)) { + if (newStartDate) { + const end = new Date(newStartDate); + end.setUTCDate(end.getUTCDate() + newDuration * 7); + newEndDate = end; + } + } + + // Pin dates when the caller explicitly sets any date field. If the user + // sets a date AND explicitly asks to unpin, the pin wins — storing a + // specific date with datesPinned=false would desynchronize this phase + // from the downstream recalc anchor and allow the next refresh to + // overwrite it. An explicit un-pin is only honored when no date field + // is provided in the same call. + const datesPinned = + data.startDate !== undefined || data.endDate !== undefined + ? true + : data.datesPinned; + + const updated = await db.timelinePhase.update({ + where: { id: phaseId }, + data: { + ...(data.name !== undefined && { name: data.name }), + ...(data.description !== undefined && { description: data.description }), + ...(data.durationWeeks !== undefined && { durationWeeks: data.durationWeeks }), + ...(data.completionType !== undefined && { + completionType: data.completionType, + }), + ...(data.startDate !== undefined && { startDate: data.startDate }), + ...(newEndDate !== undefined && { endDate: newEndDate }), + ...(data.documentUrl !== undefined && { documentUrl: data.documentUrl }), + ...(data.documentName !== undefined && { documentName: data.documentName }), + ...(datesPinned !== undefined && { datesPinned }), + ...(data.locksTimelineOnComplete !== undefined && { + locksTimelineOnComplete: data.locksTimelineOnComplete, + }), + }, + }); + + // Recalculate downstream phases when dates change + if (newEndDate && instance.startDate) { + const allPhases = await db.timelinePhase.findMany({ + where: { instanceId }, + orderBy: { orderIndex: 'asc' }, + }); + const recalculated = recalculatePhaseDates(allPhases, instance.startDate); + for (const rp of recalculated) { + if (rp.id === phaseId) continue; // already updated + if (rp.datesPinned || rp.status === 'COMPLETED') continue; + await db.timelinePhase.update({ + where: { id: rp.id }, + data: { startDate: rp.startDate, endDate: rp.endDate }, + }); + } + } + + // Auto-complete if documentUrl is set on an AUTO_UPLOAD phase + if ( + data.documentUrl && + updated.completionType === PhaseCompletionType.AUTO_UPLOAD && + !instance.lockedAt && + updated.status !== 'COMPLETED' + ) { + return this.lifecycle.completePhase(instanceId, phaseId, organizationId); + } + + return updated; + } + + async addPhase( + instanceId: string, + organizationId: string, + data: { + name: string; + description?: string; + orderIndex: number; + durationWeeks: number; + completionType?: PhaseCompletionType; + }, + ) { + const instance = await db.timelineInstance.findUnique({ + where: { id: instanceId, organizationId }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + return db.$transaction(async (tx) => { + // Shift existing phases at or after the new orderIndex + await tx.timelinePhase.updateMany({ + where: { + instanceId, + orderIndex: { gte: data.orderIndex }, + }, + data: { orderIndex: { increment: 1 } }, + }); + + return tx.timelinePhase.create({ + data: { + instanceId, + name: data.name, + description: data.description, + orderIndex: data.orderIndex, + durationWeeks: data.durationWeeks, + completionType: data.completionType ?? PhaseCompletionType.MANUAL, + }, + }); + }); + } + + async removePhase( + instanceId: string, + phaseId: string, + organizationId: string, + ) { + const instance = await db.timelineInstance.findUnique({ + where: { id: instanceId, organizationId }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + const phase = instance.phases.find((p) => p.id === phaseId); + if (!phase) { + throw new NotFoundException('Phase not found'); + } + + return db.$transaction(async (tx) => { + await tx.timelinePhase.delete({ where: { id: phaseId } }); + + // Re-index remaining phases + const remaining = instance.phases + .filter((p) => p.id !== phaseId) + .sort((a, b) => a.orderIndex - b.orderIndex); + + for (let i = 0; i < remaining.length; i++) { + if (remaining[i].orderIndex !== i) { + await tx.timelinePhase.update({ + where: { id: remaining[i].id }, + data: { orderIndex: i }, + }); + } + } + + return { success: true }; + }); + } +} diff --git a/apps/api/src/timelines/timelines-slack.helper.ts b/apps/api/src/timelines/timelines-slack.helper.ts new file mode 100644 index 000000000..ee8f5d199 --- /dev/null +++ b/apps/api/src/timelines/timelines-slack.helper.ts @@ -0,0 +1,153 @@ +import { Logger } from '@nestjs/common'; + +const logger = new Logger('TimelinesSlack'); + +function appUrl() { + return ( + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL ?? + process.env.APP_URL ?? + 'https://app.trycomp.ai' + ); +} + +function adminTimelineUrl(orgId: string) { + return `${appUrl()}/${orgId}/admin/organizations/${orgId}`; +} + +/** + * Escape dynamic user-supplied text before interpolating into Slack mrkdwn. + * Prevents org names containing `<`, `>`, `&`, `|` from breaking link + * syntax like `` or rendering as unintended markup. + */ +function escapeMrkdwn(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +async function sendSlack(blocks: unknown[], fallbackText: string): Promise { + const webhookUrl = process.env.SLACK_CX_WEBHOOK_URL; + if (!webhookUrl) return; + try { + const response = await fetch(webhookUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: fallbackText, blocks }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + logger.warn( + `Slack webhook returned ${response.status}: ${body.slice(0, 200)}`, + ); + } + } catch (err) { + logger.warn('Failed to send Slack notification', err); + } +} + +function section(text: string) { + return { type: 'section', text: { type: 'mrkdwn', text } }; +} + +function context(...items: string[]) { + return { + type: 'context', + elements: items.map((t) => ({ type: 'mrkdwn', text: t })), + }; +} + +function divider() { + return { type: 'divider' }; +} + +export function notifyReadyForReview({ + orgId, + orgName, + frameworkName, + phaseName, +}: { + orgId: string; + orgName: string; + frameworkName: string; + phaseName: string; +}): Promise { + const link = adminTimelineUrl(orgId); + const safeOrg = escapeMrkdwn(orgName); + const safePhase = escapeMrkdwn(phaseName); + const safeFramework = escapeMrkdwn(frameworkName); + return sendSlack( + [ + section(`:bell: *Ready for Review*`), + section( + `*<${link}|${safeOrg}>* (\`${orgId}\`)\n` + + `Marked *${safePhase}* as ready · _${safeFramework}_`, + ), + context(':arrow_right: Customer is waiting for CX to begin the next phase'), + divider(), + ], + `${orgName} - ${frameworkName}: ${phaseName} ready for review`, + ); +} + +export function notifyPhaseCompleted({ + orgId, + orgName, + frameworkName, + phaseName, + completionType, +}: { + orgId: string; + orgName: string; + frameworkName: string; + phaseName: string; + completionType: string; +}): Promise { + const typeLabel = + completionType === 'AUTO_TASKS' ? ':clipboard: All evidence tasks completed' : + completionType === 'AUTO_POLICIES' ? ':page_facing_up: All policies published' : + completionType === 'AUTO_PEOPLE' ? ':busts_in_silhouette: All employees compliant' : + completionType === 'AUTO_FINDINGS' ? ':mag: All auditor findings resolved' : + completionType === 'AUTO_UPLOAD' ? ':paperclip: Document uploaded' : + ':pencil: Manually completed'; + + const link = adminTimelineUrl(orgId); + const safeOrg = escapeMrkdwn(orgName); + const safePhase = escapeMrkdwn(phaseName); + const safeFramework = escapeMrkdwn(frameworkName); + return sendSlack( + [ + section(`:white_check_mark: *Phase Completed*`), + section( + `*<${link}|${safeOrg}>* (\`${orgId}\`)\n` + + `Phase: *${safePhase}* · _${safeFramework}_`, + ), + context(typeLabel), + divider(), + ], + `${orgName} - ${frameworkName}: ${phaseName} completed`, + ); +} + +export function notifyTimelineCompleted({ + orgId, + orgName, + frameworkName, +}: { + orgId: string; + orgName: string; + frameworkName: string; +}): Promise { + const link = adminTimelineUrl(orgId); + const safeOrg = escapeMrkdwn(orgName); + const safeFramework = escapeMrkdwn(frameworkName); + return sendSlack( + [ + section(`:tada: *Timeline Completed*`), + section( + `*<${link}|${safeOrg}>* (\`${orgId}\`) has completed all phases for *${safeFramework}*`, + ), + context(':checkered_flag: Ready for final report delivery'), + divider(), + ], + `${orgName} - ${frameworkName}: all phases complete`, + ); +} diff --git a/apps/api/src/timelines/timelines-template-resolver.spec.ts b/apps/api/src/timelines/timelines-template-resolver.spec.ts new file mode 100644 index 000000000..88b756d92 --- /dev/null +++ b/apps/api/src/timelines/timelines-template-resolver.spec.ts @@ -0,0 +1,157 @@ +import { + findTemplateForCycle, + createInstanceFromTemplate, +} from './timelines-template-resolver'; + +jest.mock('@db', () => ({ + db: { + timelineTemplate: { + findUnique: jest.fn(), + findFirst: jest.fn(), + }, + timelineInstance: { + create: jest.fn(), + findUniqueOrThrow: jest.fn(), + }, + timelinePhase: { + create: jest.fn(), + }, + $transaction: jest.fn(), + }, + TimelineStatus: { + DRAFT: 'DRAFT', + ACTIVE: 'ACTIVE', + PAUSED: 'PAUSED', + COMPLETED: 'COMPLETED', + }, + PhaseCompletionType: { + AUTO_TASKS: 'AUTO_TASKS', + AUTO_POLICIES: 'AUTO_POLICIES', + AUTO_PEOPLE: 'AUTO_PEOPLE', + AUTO_UPLOAD: 'AUTO_UPLOAD', + MANUAL: 'MANUAL', + }, +})); + +import { db } from '@db'; + +const mockDb = db as jest.Mocked; + +describe('timelines-template-resolver', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns exact cycle template when available', async () => { + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue({ + id: 'tml_2', + cycleNumber: 2, + }); + + const result = await findTemplateForCycle('frk_1', 2, 'soc2_type2'); + + expect(result).toEqual({ id: 'tml_2', cycleNumber: 2 }); + expect(mockDb.timelineTemplate.findUnique).toHaveBeenCalledWith({ + where: { + frameworkId_trackKey_cycleNumber: { + frameworkId: 'frk_1', + trackKey: 'soc2_type2', + cycleNumber: 2, + }, + }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + expect(mockDb.timelineTemplate.findFirst).not.toHaveBeenCalled(); + }); + + it('falls back to highest template with cycle <= requested cycle', async () => { + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue(null); + (mockDb.timelineTemplate.findFirst as jest.Mock).mockResolvedValue({ + id: 'tml_2', + cycleNumber: 2, + }); + + const result = await findTemplateForCycle('frk_1', 5, 'soc2_type2'); + + expect(mockDb.timelineTemplate.findFirst).toHaveBeenCalledWith({ + where: { + frameworkId: 'frk_1', + trackKey: 'soc2_type2', + cycleNumber: { lte: 5 }, + }, + orderBy: { cycleNumber: 'desc' }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + expect(result).toEqual({ id: 'tml_2', cycleNumber: 2 }); + }); + + it('snapshots locksTimelineOnComplete from template phases to instance phases', async () => { + const tx = { + timelineInstance: { + create: jest.fn().mockResolvedValue({ id: 'tli_1' }), + findUniqueOrThrow: jest.fn().mockResolvedValue({ + id: 'tli_1', + phases: [ + { id: 'p1', locksTimelineOnComplete: true }, + { id: 'p2', locksTimelineOnComplete: false }, + ], + }), + }, + timelinePhase: { + create: jest.fn(), + }, + }; + + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => fn(tx)); + + await createInstanceFromTemplate({ + organizationId: 'org_1', + frameworkInstanceId: 'fi_1', + cycleNumber: 2, + template: { + id: 'tml_1', + phases: [ + { + id: 'tpt_1', + name: 'Observation', + description: null, + groupLabel: null, + orderIndex: 0, + defaultDurationWeeks: 4, + completionType: 'MANUAL' as any, + locksTimelineOnComplete: true, + }, + { + id: 'tpt_2', + name: 'Auditor Review', + description: null, + groupLabel: null, + orderIndex: 1, + defaultDurationWeeks: 4, + completionType: 'MANUAL' as any, + locksTimelineOnComplete: false, + }, + ], + }, + }); + + expect(tx.timelinePhase.create).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + data: expect.objectContaining({ + phaseTemplateId: 'tpt_1', + locksTimelineOnComplete: true, + }), + }), + ); + expect(tx.timelinePhase.create).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + data: expect.objectContaining({ + phaseTemplateId: 'tpt_2', + locksTimelineOnComplete: false, + }), + }), + ); + }); +}); diff --git a/apps/api/src/timelines/timelines-template-resolver.ts b/apps/api/src/timelines/timelines-template-resolver.ts new file mode 100644 index 000000000..0edb0c7fe --- /dev/null +++ b/apps/api/src/timelines/timelines-template-resolver.ts @@ -0,0 +1,202 @@ +import { db, TimelineStatus, PhaseCompletionType } from '@db'; +import { getDefaultTemplateForCycle } from './default-templates'; +import type { DefaultTimelineTemplate } from './default-templates'; + +/** + * Finds the best-matching DB template for a framework + cycle number. + * Tries exact cycleNumber match first, then falls back to highest cycle <= N. + */ +export async function findTemplateForCycle( + frameworkId: string, + cycleNumber: number, + trackKey = 'primary', +) { + const exact = await db.timelineTemplate.findUnique({ + where: { + frameworkId_trackKey_cycleNumber: { frameworkId, trackKey, cycleNumber }, + }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (exact) return exact; + + return db.timelineTemplate.findFirst({ + where: { + frameworkId, + trackKey, + cycleNumber: { lte: cycleNumber }, + }, + orderBy: { cycleNumber: 'desc' }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); +} + +/** + * Upserts a code-default template into the DB so it can be referenced + * by TimelineInstance.templateId and later customized by admins. + */ +export async function upsertDefaultTemplate( + frameworkId: string, + defaultTemplate: DefaultTimelineTemplate, + { forceRefresh = false }: { forceRefresh?: boolean } = {}, +) { + return db.$transaction(async (tx) => { + const trackKey = defaultTemplate.trackKey ?? 'primary'; + + const template = await tx.timelineTemplate.upsert({ + where: { + frameworkId_trackKey_cycleNumber: { + frameworkId, + trackKey, + cycleNumber: defaultTemplate.cycleNumber, + }, + }, + update: { + name: defaultTemplate.name, + trackKey, + templateKey: defaultTemplate.templateKey ?? null, + nextTemplateKey: defaultTemplate.nextTemplateKey ?? null, + }, + create: { + frameworkId, + name: defaultTemplate.name, + trackKey, + cycleNumber: defaultTemplate.cycleNumber, + templateKey: defaultTemplate.templateKey, + nextTemplateKey: defaultTemplate.nextTemplateKey, + }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + const shouldSeedPhases = template.phases.length === 0 || forceRefresh; + + if (shouldSeedPhases) { + // Clear existing phases if refreshing + if (template.phases.length > 0) { + await tx.timelinePhaseTemplate.deleteMany({ + where: { templateId: template.id }, + }); + } + + for (const phase of defaultTemplate.phases) { + await tx.timelinePhaseTemplate.create({ + data: { + templateId: template.id, + name: phase.name, + description: phase.description, + groupLabel: phase.groupLabel, + orderIndex: phase.orderIndex, + defaultDurationWeeks: phase.defaultDurationWeeks, + completionType: phase.completionType, + locksTimelineOnComplete: phase.locksTimelineOnComplete ?? false, + }, + }); + } + + return tx.timelineTemplate.findUniqueOrThrow({ + where: { id: template.id }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + } + + return template; + }); +} + +/** + * Resolves a template for a given framework + cycle, checking DB first, + * then falling back to code defaults (auto-upserting them into the DB). + */ +export async function resolveTemplate( + frameworkId: string, + frameworkName: string, + cycleNumber: number, + { + forceRefresh = false, + trackKey = 'primary', + }: { forceRefresh?: boolean; trackKey?: string } = {}, +) { + if (!forceRefresh) { + const dbTemplate = await findTemplateForCycle(frameworkId, cycleNumber, trackKey); + if (dbTemplate) return dbTemplate; + } + + const codeDefault = getDefaultTemplateForCycle(frameworkName, cycleNumber, { + trackKey, + }); + if (!codeDefault) { + // No code default — fall back to DB even if forceRefresh + return findTemplateForCycle(frameworkId, cycleNumber, trackKey); + } + + return upsertDefaultTemplate(frameworkId, codeDefault, { forceRefresh }); +} + +/** + * Creates a TimelineInstance with phases copied from a resolved DB template. + */ +export async function createInstanceFromTemplate({ + organizationId, + frameworkInstanceId, + cycleNumber, + template, +}: { + organizationId: string; + frameworkInstanceId: string; + cycleNumber: number; + template: { + id: string; + frameworkId?: string; + trackKey?: string; + templateKey?: string | null; + nextTemplateKey?: string | null; + phases: Array<{ + id: string; + name: string; + description: string | null; + groupLabel: string | null; + orderIndex: number; + defaultDurationWeeks: number; + completionType: PhaseCompletionType; + locksTimelineOnComplete: boolean; + }>; + }; +}) { + return db.$transaction(async (tx) => { + const instance = await tx.timelineInstance.create({ + data: { + organizationId, + frameworkInstanceId, + templateId: template.id, + trackKey: template.trackKey ?? 'primary', + cycleNumber, + status: TimelineStatus.DRAFT, + }, + }); + + for (const phase of template.phases) { + await tx.timelinePhase.create({ + data: { + instanceId: instance.id, + phaseTemplateId: phase.id, + name: phase.name, + description: phase.description, + groupLabel: phase.groupLabel, + orderIndex: phase.orderIndex, + durationWeeks: phase.defaultDurationWeeks, + completionType: phase.completionType, + locksTimelineOnComplete: phase.locksTimelineOnComplete, + }, + }); + } + + return tx.timelineInstance.findUniqueOrThrow({ + where: { id: instance.id }, + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + frameworkInstance: { include: { framework: true } }, + template: true, + }, + }); + }); +} diff --git a/apps/api/src/timelines/timelines-templates.service.spec.ts b/apps/api/src/timelines/timelines-templates.service.spec.ts new file mode 100644 index 000000000..f12af67f8 --- /dev/null +++ b/apps/api/src/timelines/timelines-templates.service.spec.ts @@ -0,0 +1,207 @@ +import { TimelinesTemplatesService } from './timelines-templates.service'; + +jest.mock('@db', () => ({ + db: { + frameworkEditorFramework: { + findMany: jest.fn(), + }, + timelineTemplate: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + timelinePhaseTemplate: { + findFirst: jest.fn(), + updateMany: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + }, + $transaction: jest.fn(), + }, + PhaseCompletionType: { + AUTO_TASKS: 'AUTO_TASKS', + AUTO_POLICIES: 'AUTO_POLICIES', + AUTO_PEOPLE: 'AUTO_PEOPLE', + AUTO_UPLOAD: 'AUTO_UPLOAD', + MANUAL: 'MANUAL', + }, +})); + +jest.mock('./timelines-template-resolver', () => ({ + upsertDefaultTemplate: jest.fn(), +})); + +jest.mock('./default-templates', () => ({ + getDefaultTemplatesForFramework: jest.fn(), + GENERIC_DEFAULT_TIMELINE_TEMPLATE: { + frameworkName: '*', + name: 'Baseline Compliance Timeline', + cycleNumber: 1, + phases: [ + { + name: 'Scoping & Planning', + description: 'Define scope, owners, and audit goals for this cycle.', + orderIndex: 0, + defaultDurationWeeks: 2, + completionType: 'MANUAL', + }, + ], + }, +})); + +import { db } from '@db'; +import { upsertDefaultTemplate } from './timelines-template-resolver'; +import { getDefaultTemplatesForFramework } from './default-templates'; + +const mockDb = db as jest.Mocked; + +describe('TimelinesTemplatesService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('seeds framework-specific defaults for all frameworks before listing templates', async () => { + const service = new TimelinesTemplatesService(); + + (mockDb.frameworkEditorFramework.findMany as jest.Mock).mockResolvedValue([ + { id: 'frk_soc2', name: 'SOC2' }, + { id: 'frk_iso', name: 'ISO 27001' }, + ]); + + (getDefaultTemplatesForFramework as jest.Mock) + .mockReturnValueOnce([ + { frameworkName: 'SOC 2', name: 'SOC 2 Type 1', cycleNumber: 1, phases: [] }, + { frameworkName: 'SOC 2', name: 'SOC 2 Type 2', cycleNumber: 2, phases: [] }, + ]) + .mockReturnValueOnce([ + { frameworkName: 'ISO27001', name: 'ISO 27001', cycleNumber: 1, phases: [] }, + ]); + + (mockDb.timelineTemplate.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue(null); + + await service.findAll(); + + expect(mockDb.frameworkEditorFramework.findMany).toHaveBeenCalledWith({ + select: { id: true, name: true }, + }); + + expect(upsertDefaultTemplate).toHaveBeenCalledWith( + 'frk_soc2', + expect.objectContaining({ name: 'SOC 2 Type 1', cycleNumber: 1 }), + ); + expect(upsertDefaultTemplate).toHaveBeenCalledWith( + 'frk_soc2', + expect.objectContaining({ name: 'SOC 2 Type 2', cycleNumber: 2 }), + ); + expect(upsertDefaultTemplate).toHaveBeenCalledWith( + 'frk_iso', + expect.objectContaining({ name: 'ISO 27001', cycleNumber: 1 }), + ); + expect(mockDb.timelineTemplate.findMany).toHaveBeenCalledWith({ + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + framework: true, + }, + orderBy: [{ frameworkId: 'asc' }, { trackKey: 'asc' }, { cycleNumber: 'asc' }], + }); + }); + + it('seeds generic baseline template when framework has no specific default', async () => { + const service = new TimelinesTemplatesService(); + + (mockDb.frameworkEditorFramework.findMany as jest.Mock).mockResolvedValue([ + { id: 'frk_custom', name: 'My Custom Framework' }, + ]); + (getDefaultTemplatesForFramework as jest.Mock).mockReturnValue([]); + (mockDb.timelineTemplate.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue(null); + + await service.findAll(); + + expect(upsertDefaultTemplate).toHaveBeenCalledWith( + 'frk_custom', + expect.objectContaining({ + cycleNumber: 1, + frameworkName: 'My Custom Framework', + }), + ); + }); + + it('does not overwrite existing templates when framework cycle already exists', async () => { + const service = new TimelinesTemplatesService(); + + (mockDb.frameworkEditorFramework.findMany as jest.Mock).mockResolvedValue([ + { id: 'frk_soc2', name: 'SOC2' }, + ]); + (getDefaultTemplatesForFramework as jest.Mock).mockReturnValue([ + { frameworkName: 'SOC 2', name: 'SOC 2 Type 1', cycleNumber: 1, phases: [] }, + ]); + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue({ id: 'tml_existing' }); + (mockDb.timelineTemplate.findMany as jest.Mock).mockResolvedValue([]); + + await service.findAll(); + + expect(upsertDefaultTemplate).not.toHaveBeenCalled(); + }); + + it('normalizes legacy default SOC 2 renewal template name', async () => { + const service = new TimelinesTemplatesService(); + + (mockDb.frameworkEditorFramework.findMany as jest.Mock).mockResolvedValue([ + { id: 'frk_soc2', name: 'SOC 2' }, + ]); + (getDefaultTemplatesForFramework as jest.Mock).mockReturnValue([ + { + frameworkName: 'SOC 2', + name: 'SOC 2 Type 2', + cycleNumber: 2, + trackKey: 'soc2_type2', + templateKey: 'soc2_type2_renewal', + nextTemplateKey: 'soc2_type2_renewal', + phases: [], + }, + ]); + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue({ + id: 'tml_soc2_renewal', + name: 'SOC 2 Type 2 - Year 2+', + templateKey: 'soc2_type2_renewal', + nextTemplateKey: 'soc2_type2_renewal', + }); + (mockDb.timelineTemplate.findMany as jest.Mock).mockResolvedValue([]); + + await service.findAll(); + + expect(mockDb.timelineTemplate.update).toHaveBeenCalledWith({ + where: { id: 'tml_soc2_renewal' }, + data: { + name: 'SOC 2 Type 2', + templateKey: 'soc2_type2_renewal', + nextTemplateKey: 'soc2_type2_renewal', + }, + }); + expect(upsertDefaultTemplate).not.toHaveBeenCalled(); + }); + + it('returns an empty list when no frameworks exist', async () => { + const service = new TimelinesTemplatesService(); + + (mockDb.frameworkEditorFramework.findMany as jest.Mock).mockResolvedValue([]); + (mockDb.timelineTemplate.findMany as jest.Mock).mockResolvedValue([]); + + const result = await service.findAll(); + + expect(result).toEqual([]); + expect(mockDb.timelineTemplate.findMany).toHaveBeenCalledWith({ + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + framework: true, + }, + orderBy: [{ frameworkId: 'asc' }, { trackKey: 'asc' }, { cycleNumber: 'asc' }], + }); + }); +}); diff --git a/apps/api/src/timelines/timelines-templates.service.ts b/apps/api/src/timelines/timelines-templates.service.ts new file mode 100644 index 000000000..b6bbe74a5 --- /dev/null +++ b/apps/api/src/timelines/timelines-templates.service.ts @@ -0,0 +1,290 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db, PhaseCompletionType } from '@db'; +import { upsertDefaultTemplate } from './timelines-template-resolver'; +import { + GENERIC_DEFAULT_TIMELINE_TEMPLATE, + getDefaultTemplatesForFramework, +} from './default-templates'; + +const LEGACY_DEFAULT_TEMPLATE_NAME_BY_KEY: Record = { + soc2_type2_renewal: 'SOC 2 Type 2 - Year 2+', +}; + +@Injectable() +export class TimelinesTemplatesService { + private async ensureCatalogTemplatesExist() { + const frameworks = await db.frameworkEditorFramework.findMany({ + select: { id: true, name: true }, + }); + + for (const framework of frameworks) { + const defaults = getDefaultTemplatesForFramework(framework.name); + + if (defaults.length > 0) { + for (const template of defaults) { + const trackKey = template.trackKey ?? 'primary'; + const existing = await db.timelineTemplate.findUnique({ + where: { + frameworkId_trackKey_cycleNumber: { + frameworkId: framework.id, + trackKey, + cycleNumber: template.cycleNumber, + }, + }, + select: { + id: true, + name: true, + templateKey: true, + nextTemplateKey: true, + }, + }); + if (existing) { + const expectedTemplateKey = template.templateKey ?? null; + const expectedNextTemplateKey = template.nextTemplateKey ?? null; + const legacyDefaultName = + expectedTemplateKey + ? LEGACY_DEFAULT_TEMPLATE_NAME_BY_KEY[expectedTemplateKey] + : undefined; + const shouldNormalizeLegacyDefaultName = + !!legacyDefaultName && + existing.name === legacyDefaultName && + template.name !== legacyDefaultName; + + if ( + existing.templateKey !== expectedTemplateKey || + existing.nextTemplateKey !== expectedNextTemplateKey || + shouldNormalizeLegacyDefaultName + ) { + await db.timelineTemplate.update({ + where: { id: existing.id }, + data: { + ...(shouldNormalizeLegacyDefaultName + ? { name: template.name } + : {}), + templateKey: expectedTemplateKey, + nextTemplateKey: expectedNextTemplateKey, + }, + }); + } + continue; + } + + await upsertDefaultTemplate(framework.id, template); + } + continue; + } + + const existingGeneric = await db.timelineTemplate.findUnique({ + where: { + frameworkId_trackKey_cycleNumber: { + frameworkId: framework.id, + trackKey: GENERIC_DEFAULT_TIMELINE_TEMPLATE.trackKey ?? 'primary', + cycleNumber: GENERIC_DEFAULT_TIMELINE_TEMPLATE.cycleNumber, + }, + }, + select: { id: true }, + }); + if (existingGeneric) continue; + + await upsertDefaultTemplate(framework.id, { + ...GENERIC_DEFAULT_TIMELINE_TEMPLATE, + frameworkName: framework.name, + name: `${framework.name} Timeline`, + phases: GENERIC_DEFAULT_TIMELINE_TEMPLATE.phases.map((phase) => ({ + ...phase, + })), + }); + } + } + + async findAll() { + await this.ensureCatalogTemplatesExist(); + + return db.timelineTemplate.findMany({ + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + framework: true, + }, + orderBy: [{ frameworkId: 'asc' }, { trackKey: 'asc' }, { cycleNumber: 'asc' }], + }); + } + + async findOne(id: string) { + const template = await db.timelineTemplate.findUnique({ + where: { id }, + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + framework: true, + }, + }); + + if (!template) { + throw new NotFoundException('Timeline template not found'); + } + + return template; + } + + async create(data: { + frameworkId: string; + name: string; + cycleNumber: number; + }) { + return db.timelineTemplate.create({ + data: { + frameworkId: data.frameworkId, + name: data.name, + cycleNumber: data.cycleNumber, + }, + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + framework: true, + }, + }); + } + + async update(id: string, data: { name?: string; frameworkId?: string; cycleNumber?: number }) { + const template = await db.timelineTemplate.findUnique({ + where: { id }, + }); + + if (!template) { + throw new NotFoundException('Timeline template not found'); + } + + return db.timelineTemplate.update({ + where: { id }, + data, + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + framework: true, + }, + }); + } + + async delete(id: string) { + const template = await db.timelineTemplate.findUnique({ + where: { id }, + include: { instances: { select: { id: true }, take: 1 } }, + }); + + if (!template) { + throw new NotFoundException('Timeline template not found'); + } + + if (template.instances.length > 0) { + throw new BadRequestException( + 'Cannot delete a template that has existing timeline instances', + ); + } + + await db.timelineTemplate.delete({ where: { id } }); + return { success: true }; + } + + async addPhase( + templateId: string, + data: { + name: string; + description?: string; + groupLabel?: string; + orderIndex: number; + defaultDurationWeeks: number; + completionType?: PhaseCompletionType; + locksTimelineOnComplete?: boolean; + }, + ) { + const template = await db.timelineTemplate.findUnique({ + where: { id: templateId }, + }); + + if (!template) { + throw new NotFoundException('Timeline template not found'); + } + + return db.$transaction(async (tx) => { + await tx.timelinePhaseTemplate.updateMany({ + where: { + templateId, + orderIndex: { gte: data.orderIndex }, + }, + data: { orderIndex: { increment: 1 } }, + }); + + return tx.timelinePhaseTemplate.create({ + data: { + templateId, + name: data.name, + description: data.description, + groupLabel: data.groupLabel, + orderIndex: data.orderIndex, + defaultDurationWeeks: data.defaultDurationWeeks, + completionType: data.completionType ?? PhaseCompletionType.MANUAL, + locksTimelineOnComplete: data.locksTimelineOnComplete ?? false, + }, + }); + }); + } + + async updatePhase( + templateId: string, + phaseId: string, + data: { + name?: string; + description?: string; + groupLabel?: string; + orderIndex?: number; + defaultDurationWeeks?: number; + completionType?: PhaseCompletionType; + locksTimelineOnComplete?: boolean; + }, + ) { + const phase = await db.timelinePhaseTemplate.findFirst({ + where: { id: phaseId, templateId }, + }); + + if (!phase) { + throw new NotFoundException('Phase template not found'); + } + + return db.timelinePhaseTemplate.update({ + where: { id: phaseId }, + data, + }); + } + + async deletePhase(templateId: string, phaseId: string) { + const phase = await db.timelinePhaseTemplate.findFirst({ + where: { id: phaseId, templateId }, + }); + + if (!phase) { + throw new NotFoundException('Phase template not found'); + } + + return db.$transaction(async (tx) => { + await tx.timelinePhaseTemplate.delete({ where: { id: phaseId } }); + + // Re-index remaining phases + const remaining = await tx.timelinePhaseTemplate.findMany({ + where: { templateId }, + orderBy: { orderIndex: 'asc' }, + }); + + for (let i = 0; i < remaining.length; i++) { + if (remaining[i].orderIndex !== i) { + await tx.timelinePhaseTemplate.update({ + where: { id: remaining[i].id }, + data: { orderIndex: i }, + }); + } + } + + return { success: true }; + }); + } +} diff --git a/apps/api/src/timelines/timelines.controller.ts b/apps/api/src/timelines/timelines.controller.ts new file mode 100644 index 000000000..6edddfde6 --- /dev/null +++ b/apps/api/src/timelines/timelines.controller.ts @@ -0,0 +1,64 @@ +import { Controller, Get, Post, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import { OrganizationId } from '../auth/auth-context.decorator'; +import { TimelinesService } from './timelines.service'; +import { notifyReadyForReview } from './timelines-slack.helper'; + +@ApiTags('Timelines') +@ApiBearerAuth() +@UseGuards(HybridAuthGuard, PermissionGuard) +@Controller({ path: 'timelines', version: '1' }) +export class TimelinesController { + constructor(private readonly timelinesService: TimelinesService) {} + + @Get() + @RequirePermission('framework', 'read') + @ApiOperation({ summary: 'List timelines for the organization' }) + async findAll(@OrganizationId() organizationId: string) { + const data = + await this.timelinesService.findAllForOrganization(organizationId); + return { data, count: data.length }; + } + + @Get(':id') + @RequirePermission('framework', 'read') + @ApiOperation({ summary: 'Get a single timeline instance with phases' }) + async findOne( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.timelinesService.findOne(id, organizationId); + } + + @Post(':id/phases/:phaseId/ready') + @RequirePermission('framework', 'update') + @ApiOperation({ summary: 'Mark a phase as ready for review' }) + async markReadyForReview( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Param('phaseId') phaseId: string, + ) { + const result = await this.timelinesService.markReadyForReview( + id, + phaseId, + organizationId, + ); + + // Only notify Slack on the first transition — service returns + // alreadyReady=true on retries / double-clicks so the CX channel + // doesn't get pinged repeatedly for the same phase. + if (!result.alreadyReady) { + notifyReadyForReview({ + orgId: organizationId, + orgName: result.organization.name, + frameworkName: result.framework?.name ?? 'Unknown framework', + phaseName: result.phase.name, + }); + } + + return result; + } +} diff --git a/apps/api/src/timelines/timelines.module.ts b/apps/api/src/timelines/timelines.module.ts new file mode 100644 index 000000000..3fd034992 --- /dev/null +++ b/apps/api/src/timelines/timelines.module.ts @@ -0,0 +1,26 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { TimelinesController } from './timelines.controller'; +import { AdminTimelineTemplatesController } from './admin-timeline-templates.controller'; +import { AdminOrgTimelinesController } from './admin-org-timelines.controller'; +import { TimelinesService } from './timelines.service'; +import { TimelinesLifecycleService } from './timelines-lifecycle.service'; +import { TimelinesTemplatesService } from './timelines-templates.service'; +import { TimelinesPhasesService } from './timelines-phases.service'; + +@Module({ + imports: [AuthModule], + controllers: [ + TimelinesController, + AdminTimelineTemplatesController, + AdminOrgTimelinesController, + ], + providers: [ + TimelinesService, + TimelinesLifecycleService, + TimelinesTemplatesService, + TimelinesPhasesService, + ], + exports: [TimelinesService], +}) +export class TimelinesModule {} diff --git a/apps/api/src/timelines/timelines.service.spec.ts b/apps/api/src/timelines/timelines.service.spec.ts new file mode 100644 index 000000000..eb70ab885 --- /dev/null +++ b/apps/api/src/timelines/timelines.service.spec.ts @@ -0,0 +1,753 @@ +import { TimelinesService } from './timelines.service'; +import { BadRequestException } from '@nestjs/common'; + +jest.mock('@db', () => ({ + db: { + frameworkInstance: { + findMany: jest.fn(), + }, + timelineInstance: { + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + deleteMany: jest.fn(), + }, + timelineTemplate: { + findUnique: jest.fn(), + }, + timelinePhase: { + update: jest.fn(), + deleteMany: jest.fn(), + }, + $transaction: jest.fn(), + }, + TimelinePhaseStatus: { + PENDING: 'PENDING', + IN_PROGRESS: 'IN_PROGRESS', + COMPLETED: 'COMPLETED', + }, +})); + +jest.mock('../frameworks/frameworks-scores.helper', () => ({ + getOverviewScores: jest.fn(), +})); + +jest.mock('./timelines-backfill.helper', () => ({ + backfillTimeline: jest.fn(), +})); + +jest.mock('./timelines-template-resolver', () => ({ + resolveTemplate: jest.fn(), + createInstanceFromTemplate: jest.fn(), +})); + +import { db } from '@db'; +import { getOverviewScores } from '../frameworks/frameworks-scores.helper'; +import { createInstanceFromTemplate } from './timelines-template-resolver'; +import { backfillTimeline } from './timelines-backfill.helper'; + +const mockDb = db as jest.Mocked; + +function cloneTimeline(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +describe('TimelinesService', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('cascades AUTO_* phase completions when live metrics are already 100%', async () => { + const orgId = 'org_1'; + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + + const service = new TimelinesService(lifecycle as any); + + const timelineState = { + id: 'tli_1', + organizationId: orgId, + frameworkInstanceId: 'fi_1', + templateId: 'tml_1', + cycleNumber: 2, + status: 'ACTIVE', + startDate: '2026-01-01T00:00:00.000Z', + pausedAt: null, + completedAt: null, + phases: [ + { + id: 'p1', + name: 'Policies', + orderIndex: 0, + status: 'IN_PROGRESS', + completionType: 'AUTO_POLICIES', + completedAt: null, + }, + { + id: 'p2', + name: 'Evidence', + orderIndex: 1, + status: 'PENDING', + completionType: 'AUTO_TASKS', + completedAt: null, + }, + { + id: 'p3', + name: 'People', + orderIndex: 2, + status: 'PENDING', + completionType: 'AUTO_PEOPLE', + completedAt: null, + }, + { + id: 'p4', + name: 'Auditor Review', + orderIndex: 3, + status: 'PENDING', + completionType: 'MANUAL', + completedAt: null, + }, + ], + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + template: { id: 'tml_1', name: 'SOC 2 Type 2' }, + }; + + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { + id: 'fi_1', + frameworkId: 'frk_1', + framework: { id: 'frk_1', name: 'SOC 2' }, + timelineInstances: [{ id: 'tli_1' }], + }, + ]); + + (mockDb.timelineInstance.findMany as jest.Mock).mockImplementation(() => + Promise.resolve([cloneTimeline(timelineState)]), + ); + + (getOverviewScores as jest.Mock).mockResolvedValue({ + policies: { total: 10, published: 10 }, + tasks: { total: 20, done: 20 }, + people: { total: 5, completed: 5 }, + }); + + lifecycle.completePhase.mockImplementation(async (_instanceId: string, phaseId: string) => { + const phase = timelineState.phases.find((p) => p.id === phaseId); + if (!phase) return cloneTimeline(timelineState); + + phase.status = 'COMPLETED'; + phase.completedAt = '2026-01-10T00:00:00.000Z'; + + const nextPending = timelineState.phases.find((p) => p.status === 'PENDING'); + if (nextPending) { + const allPriorComplete = timelineState.phases + .filter((p) => p.orderIndex < nextPending.orderIndex) + .every((p) => p.status === 'COMPLETED'); + + if (allPriorComplete) { + nextPending.status = 'IN_PROGRESS'; + } + } + + return cloneTimeline(timelineState); + }); + + const result = await service.findAllForOrganization(orgId); + + expect(lifecycle.completePhase).toHaveBeenCalledTimes(3); + expect(result[0].phases.map((p) => p.status)).toEqual([ + 'COMPLETED', + 'COMPLETED', + 'COMPLETED', + 'IN_PROGRESS', + ]); + }); + + it('does not auto-complete AUTO phases while a timeline is paused', async () => { + const orgId = 'org_1'; + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + const timelineState = { + id: 'tli_paused', + organizationId: orgId, + frameworkInstanceId: 'fi_1', + templateId: 'tml_1', + cycleNumber: 2, + status: 'PAUSED', + startDate: '2026-01-01T00:00:00.000Z', + pausedAt: '2026-01-10T00:00:00.000Z', + completedAt: null, + phases: [ + { + id: 'p1', + name: 'Policies', + orderIndex: 0, + status: 'IN_PROGRESS', + completionType: 'AUTO_POLICIES', + completedAt: null, + }, + ], + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + template: { id: 'tml_1', name: 'SOC 2 Type 2' }, + }; + + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { + id: 'fi_1', + frameworkId: 'frk_1', + framework: { id: 'frk_1', name: 'SOC 2' }, + timelineInstances: [{ id: 'tli_paused' }], + }, + ]); + (mockDb.timelineInstance.findMany as jest.Mock).mockImplementation(() => + Promise.resolve([cloneTimeline(timelineState)]), + ); + (getOverviewScores as jest.Mock).mockResolvedValue({ + policies: { total: 10, published: 10 }, + tasks: { total: 1, done: 1 }, + people: { total: 1, completed: 1 }, + }); + + const result = await service.findAllForOrganization(orgId); + + expect(lifecycle.completePhase).not.toHaveBeenCalled(); + expect(result[0].phases[0].status).toBe('IN_PROGRESS'); + }); + + it('records regressedAt when a completed AUTO phase drops below 100%', async () => { + const orgId = 'org_1'; + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + const timelineState = { + id: 'tli_reopen', + organizationId: orgId, + frameworkInstanceId: 'fi_1', + templateId: 'tml_1', + cycleNumber: 2, + status: 'ACTIVE', + startDate: '2026-01-01T00:00:00.000Z', + pausedAt: null, + completedAt: null, + phases: [ + { + id: 'p1', + name: 'Policies', + orderIndex: 0, + status: 'COMPLETED', + completionType: 'AUTO_POLICIES', + completedAt: '2026-01-03T00:00:00.000Z', + regressedAt: null, + }, + ], + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + template: { id: 'tml_1', name: 'SOC 2 Type 2' }, + }; + + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { + id: 'fi_1', + frameworkId: 'frk_1', + framework: { id: 'frk_1', name: 'SOC 2' }, + timelineInstances: [{ id: 'tli_reopen' }], + }, + ]); + (mockDb.timelineInstance.findMany as jest.Mock).mockImplementation(() => + Promise.resolve([cloneTimeline(timelineState)]), + ); + (mockDb.timelinePhase.update as jest.Mock).mockImplementation(async ({ where, data }: any) => { + const phase = timelineState.phases.find((p) => p.id === where.id); + if (!phase) return null; + Object.assign(phase, data); + return phase; + }); + (getOverviewScores as jest.Mock).mockResolvedValue({ + policies: { total: 10, published: 8 }, + tasks: { total: 1, done: 1 }, + people: { total: 1, completed: 1 }, + }); + + const result = await service.findAllForOrganization(orgId); + + expect(mockDb.timelinePhase.update).toHaveBeenCalledWith({ + where: { id: 'p1' }, + data: { regressedAt: expect.any(Date) }, + }); + expect(result[0].phases[0].status).toBe('COMPLETED'); + expect(result[0].phases[0].regressedAt).toBeTruthy(); + }); + + it('re-opens AUTO_PEOPLE immediately when live people score drops below 100%', async () => { + const orgId = 'org_1'; + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + const timelineState = { + id: 'tli_people_reopen', + organizationId: orgId, + frameworkInstanceId: 'fi_1', + templateId: 'tml_1', + cycleNumber: 1, + status: 'ACTIVE', + lockedAt: null, + startDate: '2026-01-01T00:00:00.000Z', + pausedAt: null, + completedAt: null, + phases: [ + { + id: 'p1', + name: 'People', + orderIndex: 0, + status: 'COMPLETED', + completionType: 'AUTO_PEOPLE', + completedAt: '2026-01-10T00:00:00.000Z', + regressedAt: null, + completedById: null, + readyForReview: false, + readyForReviewAt: null, + }, + { + id: 'p2', + name: 'Auditor Review', + orderIndex: 1, + status: 'PENDING', + completionType: 'MANUAL', + completedAt: null, + regressedAt: null, + completedById: null, + readyForReview: false, + readyForReviewAt: null, + }, + ], + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + template: { id: 'tml_1', name: 'SOC 2 Type 1' }, + }; + + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { + id: 'fi_1', + frameworkId: 'frk_1', + framework: { id: 'frk_1', name: 'SOC 2' }, + timelineInstances: [{ id: 'tli_people_reopen' }], + }, + ]); + (mockDb.timelineInstance.findMany as jest.Mock).mockImplementation(() => + Promise.resolve([cloneTimeline(timelineState)]), + ); + + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => { + const tx = { + timelinePhase: { + findMany: jest.fn(async () => cloneTimeline(timelineState.phases)), + update: jest.fn(async ({ where, data }: any) => { + const phase = timelineState.phases.find((p) => p.id === where.id); + if (!phase) return null; + Object.assign(phase, data); + return phase; + }), + }, + }; + return fn(tx); + }); + + (getOverviewScores as jest.Mock).mockResolvedValue({ + policies: { total: 10, published: 10 }, + tasks: { total: 1, done: 1 }, + people: { total: 2, completed: 1 }, + }); + + const result = await service.findAllForOrganization(orgId); + + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(result[0].phases.map((phase) => phase.status)).toEqual([ + 'IN_PROGRESS', + 'PENDING', + ]); + expect(result[0].phases[0].regressedAt).toBeNull(); + }); + + it('re-opens a regressed AUTO phase after the 24-hour grace window', async () => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-15T00:00:00.000Z')); + + const orgId = 'org_1'; + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + const timelineState = { + id: 'tli_reopen_after_grace', + organizationId: orgId, + frameworkInstanceId: 'fi_1', + templateId: 'tml_1', + cycleNumber: 2, + status: 'ACTIVE', + lockedAt: null, + startDate: '2026-01-01T00:00:00.000Z', + pausedAt: null, + completedAt: null, + phases: [ + { + id: 'p1', + name: 'Policies', + orderIndex: 0, + status: 'COMPLETED', + completionType: 'AUTO_POLICIES', + completedAt: '2026-01-03T00:00:00.000Z', + regressedAt: '2026-02-10T00:00:00.000Z', + completedById: 'usr_1', + readyForReview: true, + readyForReviewAt: '2026-01-03T01:00:00.000Z', + }, + { + id: 'p2', + name: 'Auditor Review', + orderIndex: 1, + status: 'COMPLETED', + completionType: 'MANUAL', + completedAt: '2026-01-20T00:00:00.000Z', + regressedAt: null, + completedById: 'usr_2', + readyForReview: false, + readyForReviewAt: null, + }, + ], + frameworkInstance: { framework: { id: 'frk_1', name: 'SOC 2' } }, + template: { id: 'tml_1', name: 'SOC 2 Type 2' }, + }; + + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { + id: 'fi_1', + frameworkId: 'frk_1', + framework: { id: 'frk_1', name: 'SOC 2' }, + timelineInstances: [{ id: 'tli_reopen_after_grace' }], + }, + ]); + (mockDb.timelineInstance.findMany as jest.Mock).mockImplementation(() => + Promise.resolve([cloneTimeline(timelineState)]), + ); + + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => { + const tx = { + timelinePhase: { + findMany: jest.fn(async () => cloneTimeline(timelineState.phases)), + update: jest.fn(async ({ where, data }: any) => { + const phase = timelineState.phases.find((p) => p.id === where.id); + if (!phase) return null; + Object.assign(phase, data); + return phase; + }), + }, + }; + return fn(tx); + }); + + (getOverviewScores as jest.Mock).mockResolvedValue({ + policies: { total: 10, published: 8 }, + tasks: { total: 1, done: 1 }, + people: { total: 1, completed: 1 }, + }); + + const result = await service.findAllForOrganization(orgId); + + expect(mockDb.$transaction).toHaveBeenCalledTimes(1); + expect(result[0].phases.map((phase) => phase.status)).toEqual([ + 'IN_PROGRESS', + 'PENDING', + ]); + expect(result[0].phases[0].regressedAt).toBeNull(); + jest.useRealTimers(); + }); + + it('rejects starting next cycle when current timeline is not completed', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + jest.spyOn(service, 'findOne').mockResolvedValue({ + id: 'tli_1', + frameworkInstanceId: 'fi_1', + cycleNumber: 1, + status: 'ACTIVE', + } as any); + + await expect(service.startNextCycle('tli_1', 'org_1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('rejects starting next cycle when next cycle already exists', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + jest.spyOn(service, 'findOne').mockResolvedValue({ + id: 'tli_1', + frameworkInstanceId: 'fi_1', + cycleNumber: 2, + status: 'COMPLETED', + } as any); + (mockDb.timelineInstance.findFirst as jest.Mock).mockResolvedValue({ + id: 'existing_cycle_3', + }); + + await expect(service.startNextCycle('tli_1', 'org_1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('creates next cycle from template when eligible', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + jest.spyOn(service, 'findOne').mockResolvedValue({ + id: 'tli_1', + frameworkInstanceId: 'fi_1', + cycleNumber: 2, + status: 'COMPLETED', + } as any); + (mockDb.timelineInstance.findFirst as jest.Mock).mockResolvedValue(null); + + const createSpy = jest + .spyOn(service, 'createFromTemplate') + .mockResolvedValue({ id: 'tli_3', cycleNumber: 3 } as any); + + const result = await service.startNextCycle('tli_1', 'org_1'); + + expect(createSpy).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkInstanceId: 'fi_1', + cycleNumber: 3, + trackKey: 'primary', + }); + expect(result).toEqual({ id: 'tli_3', cycleNumber: 3 }); + }); + + it('uses explicit template progression when current template defines nextTemplateKey', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + jest.spyOn(service, 'findOne').mockResolvedValue({ + id: 'tli_2', + frameworkInstanceId: 'fi_1', + cycleNumber: 2, + trackKey: 'soc2_type2', + status: 'COMPLETED', + template: { + id: 'tml_soc2_y1', + frameworkId: 'frk_soc2', + trackKey: 'soc2_type2', + templateKey: 'soc2_type2_year1', + nextTemplateKey: 'soc2_type2_renewal', + }, + } as any); + + (mockDb.timelineInstance.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue({ + id: 'tml_soc2_renewal', + phases: [], + }); + (createInstanceFromTemplate as jest.Mock).mockResolvedValue({ + id: 'tli_3', + cycleNumber: 3, + }); + + const createSpy = jest + .spyOn(service, 'createFromTemplate') + .mockResolvedValue({ id: 'fallback', cycleNumber: 3 } as any); + + const result = await service.startNextCycle('tli_2', 'org_1'); + + expect(mockDb.timelineTemplate.findUnique).toHaveBeenCalledWith({ + where: { + frameworkId_templateKey: { + frameworkId: 'frk_soc2', + templateKey: 'soc2_type2_renewal', + }, + }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + expect(createSpy).not.toHaveBeenCalled(); + expect(result).toEqual(expect.objectContaining({ id: 'tli_3', cycleNumber: 3 })); + }); + + it('checks for existing next cycle within the same track only', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + jest.spyOn(service, 'findOne').mockResolvedValue({ + id: 'tli_type2_1', + frameworkInstanceId: 'fi_1', + cycleNumber: 1, + trackKey: 'soc2_type2', + status: 'COMPLETED', + template: { + id: 'tml_soc2_type2_y1', + frameworkId: 'frk_soc2', + trackKey: 'soc2_type2', + templateKey: 'soc2_type2_year1', + nextTemplateKey: 'soc2_type2_renewal', + }, + } as any); + (mockDb.timelineInstance.findFirst as jest.Mock).mockResolvedValue(null); + (mockDb.timelineTemplate.findUnique as jest.Mock).mockResolvedValue({ + id: 'tml_soc2_renewal', + trackKey: 'soc2_type2', + phases: [], + }); + (createInstanceFromTemplate as jest.Mock).mockResolvedValue({ + id: 'tli_type2_2', + cycleNumber: 2, + trackKey: 'soc2_type2', + }); + + await service.startNextCycle('tli_type2_1', 'org_1'); + + expect(mockDb.timelineInstance.findFirst).toHaveBeenCalledWith({ + where: { + frameworkInstanceId: 'fi_1', + trackKey: 'soc2_type2', + cycleNumber: 2, + }, + }); + }); + + it('resets timeline instance and phases back to draft baseline', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + jest.spyOn(service, 'findOne').mockResolvedValue({ + id: 'tli_1', + phases: [ + { id: 'p1' }, + { id: 'p2' }, + ], + } as any); + + const tx = { + timelinePhase: { update: jest.fn() }, + timelineInstance: { update: jest.fn() }, + }; + (mockDb.$transaction as jest.Mock).mockImplementation(async (fn: any) => fn(tx)); + + const refreshed = { id: 'tli_1', status: 'DRAFT' }; + jest.spyOn(service, 'findOne').mockResolvedValueOnce({ + id: 'tli_1', + phases: [{ id: 'p1' }, { id: 'p2' }], + } as any).mockResolvedValueOnce(refreshed as any); + + const result = await service.resetInstance('tli_1', 'org_1'); + + expect(tx.timelinePhase.update).toHaveBeenCalledTimes(2); + expect(tx.timelinePhase.update).toHaveBeenCalledWith({ + where: { id: 'p1' }, + data: expect.objectContaining({ regressedAt: null }), + }); + expect(tx.timelineInstance.update).toHaveBeenCalledWith({ + where: { id: 'tli_1' }, + data: { + status: 'DRAFT', + startDate: null, + pausedAt: null, + lockedAt: null, + lockedById: null, + unlockedAt: null, + unlockedById: null, + unlockReason: null, + completedAt: null, + }, + }); + expect(result).toEqual(refreshed); + }); + + it('recreate clears grace periods by bypassing regression grace on refresh', async () => { + const lifecycle = { + activate: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + completePhase: jest.fn(), + }; + const service = new TimelinesService(lifecycle as any); + + (mockDb.timelineInstance.findMany as jest.Mock).mockResolvedValue([ + { id: 'tli_1' }, + ]); + (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([ + { + id: 'fi_1', + frameworkId: 'frk_1', + framework: { id: 'frk_1', name: 'SOC 2' }, + timelineInstances: [], + }, + ]); + + const findAllSpy = jest + .spyOn(service, 'findAllForOrganization') + .mockResolvedValue([] as any); + + await service.recreateAllForOrganization('org_1'); + + expect(backfillTimeline).toHaveBeenCalledWith({ + organizationId: 'org_1', + frameworkInstance: expect.objectContaining({ id: 'fi_1' }), + forceRefresh: true, + }); + expect(findAllSpy).toHaveBeenLastCalledWith('org_1', { + bypassRegressionGrace: true, + }); + }); +}); diff --git a/apps/api/src/timelines/timelines.service.ts b/apps/api/src/timelines/timelines.service.ts new file mode 100644 index 000000000..3467a8f3a --- /dev/null +++ b/apps/api/src/timelines/timelines.service.ts @@ -0,0 +1,609 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { db, TimelinePhaseStatus } from '@db'; +import { TimelinesLifecycleService } from './timelines-lifecycle.service'; +import { backfillTimeline } from './timelines-backfill.helper'; +import { + resolveTemplate, + createInstanceFromTemplate, +} from './timelines-template-resolver'; +import { getOverviewScores } from '../frameworks/frameworks-scores.helper'; + +const AUTO_PHASE_TYPES = new Set([ + 'AUTO_POLICIES', + 'AUTO_TASKS', + 'AUTO_PEOPLE', +]); +const AUTO_PHASE_TYPES_NO_GRACE = new Set(['AUTO_PEOPLE']); +const REGRESSION_GRACE_MS = 24 * 60 * 60 * 1000; + +interface TimelinesQueryOptions { + bypassRegressionGrace?: boolean; +} + +@Injectable() +export class TimelinesService { + constructor( + private readonly lifecycle: TimelinesLifecycleService, + ) {} + + // --------------------------------------------------------------------------- + // Customer-facing queries + // --------------------------------------------------------------------------- + + /** + * Customer-facing read of timelines. + * + * Pure read (except for the one-time ensureTimelinesExist backfill). The + * AUTO_* phase advancement + regression sync lives in + * reconcileAutoPhasesForOrganization and fires from mutation event hooks + * (task/policy/people/findings updates) so GET /timelines is idempotent. + */ + async findAllForOrganization(organizationId: string) { + await this.ensureTimelinesExist(organizationId); + + const timelines = await db.timelineInstance.findMany({ + where: { organizationId }, + include: { + phases: { orderBy: { orderIndex: 'asc' } }, + frameworkInstance: { include: { framework: true } }, + template: true, + }, + }); + + // Enrich AUTO_* phases with live completion percentages in-memory. + const scores = await getOverviewScores(organizationId).catch(() => null); + if (!scores) return timelines; + + const pctMap = this.buildAutoPctMap(scores); + + for (const timeline of timelines) { + if (timeline.status !== 'ACTIVE') continue; + for (const phase of timeline.phases) { + const livePct = pctMap[phase.completionType]; + if (livePct !== undefined) { + (phase as any).completionPercent = livePct; + } + } + } + + return timelines; + } + + /** + * Reconcile AUTO_* phase status with live metrics. Handles both directions: + * advances phases when metrics hit 100% and reverts completed phases whose + * metric dropped below 100% (respecting the regression grace period). + * + * Called from mutation hooks in tasks/policies/people/findings services + * after events that could shift the underlying scores. + */ + async reconcileAutoPhasesForOrganization( + organizationId: string, + options: TimelinesQueryOptions = {}, + ): Promise { + const scores = await getOverviewScores(organizationId).catch(() => null); + if (!scores) return; + + const pctMap = this.buildAutoPctMap(scores); + + const fetchTimelines = () => + db.timelineInstance.findMany({ + where: { organizationId }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + let timelines = await fetchTimelines(); + + const maxSyncPasses = 20; + for (let pass = 0; pass < maxSyncPasses; pass++) { + let changed = false; + + for (const timeline of timelines) { + if (timeline.status !== 'ACTIVE' && timeline.status !== 'COMPLETED') { + continue; + } + + const locked = + timeline.status === 'COMPLETED' || !!timeline.lockedAt; + const canAutoTransition = timeline.status === 'ACTIVE' && !locked; + + for (const phase of timeline.phases) { + if (!AUTO_PHASE_TYPES.has(phase.completionType)) continue; + + const livePct = pctMap[phase.completionType]; + if (livePct === undefined) continue; + + // Non-completed phase: clear regression flag if it somehow lingers. + if ( + phase.status !== TimelinePhaseStatus.COMPLETED && + phase.regressedAt + ) { + await db.timelinePhase.update({ + where: { id: phase.id }, + data: { regressedAt: null }, + }); + changed = true; + break; + } + + if (phase.status === TimelinePhaseStatus.COMPLETED) { + if (livePct < 100) { + if ( + canAutoTransition && + (AUTO_PHASE_TYPES_NO_GRACE.has(phase.completionType) || + options.bypassRegressionGrace === true) + ) { + await this.reopenFromRegressedPhase( + timeline.id, + phase.orderIndex, + ); + changed = true; + break; + } + + if (!phase.regressedAt) { + await db.timelinePhase.update({ + where: { id: phase.id }, + data: { regressedAt: new Date() }, + }); + changed = true; + break; + } + + if (canAutoTransition) { + const elapsedMs = + Date.now() - new Date(phase.regressedAt).getTime(); + if (elapsedMs >= REGRESSION_GRACE_MS) { + await this.reopenFromRegressedPhase( + timeline.id, + phase.orderIndex, + ); + changed = true; + break; + } + } + } else if (phase.regressedAt) { + await db.timelinePhase.update({ + where: { id: phase.id }, + data: { regressedAt: null }, + }); + changed = true; + break; + } + } + + if ( + canAutoTransition && + phase.status === TimelinePhaseStatus.IN_PROGRESS && + livePct >= 100 + ) { + try { + await this.completePhase( + timeline.id, + phase.id, + timeline.organizationId, + ); + changed = true; + break; + } catch { + // Phase may not be completable (e.g., prior phases not done). + } + } + } + + if (changed) break; + } + + if (!changed) break; + timelines = await fetchTimelines(); + } + } + + private buildAutoPctMap(scores: { + policies: { total: number; published: number }; + tasks: { total: number; done: number }; + people: { total: number; completed: number }; + }): Record { + const pct = (num: number, den: number) => + den > 0 ? Math.round((num / den) * 100) : 0; + return { + AUTO_POLICIES: pct(scores.policies.published, scores.policies.total), + AUTO_TASKS: pct(scores.tasks.done, scores.tasks.total), + AUTO_PEOPLE: pct(scores.people.completed, scores.people.total), + }; + } + + private async reopenFromRegressedPhase( + timelineId: string, + regressedOrderIndex: number, + ): Promise { + await db.$transaction(async (tx) => { + const freshPhases = await tx.timelinePhase.findMany({ + where: { instanceId: timelineId }, + orderBy: { orderIndex: 'asc' }, + }); + + const affected = freshPhases.filter( + (p) => p.orderIndex >= regressedOrderIndex, + ); + for (let i = 0; i < affected.length; i++) { + const phase = affected[i]; + await tx.timelinePhase.update({ + where: { id: phase.id }, + data: { + status: + i === 0 + ? TimelinePhaseStatus.IN_PROGRESS + : TimelinePhaseStatus.PENDING, + completedAt: null, + completedById: null, + readyForReview: false, + readyForReviewAt: null, + regressedAt: null, + }, + }); + } + + // If the timeline was COMPLETED, regression means it's no longer + // complete — flip it back to ACTIVE to stay consistent with phases. + await tx.timelineInstance.updateMany({ + where: { id: timelineId, status: 'COMPLETED' }, + data: { status: 'ACTIVE', completedAt: null }, + }); + }); + } + + /** + * Auto-create timelines for any framework instances that don't have one yet. + * Uses smart backfill: infers timeline state from Trust status, compliance + * scores, and task completion data for existing orgs. + */ + private async ensureTimelinesExist(organizationId: string) { + const frameworkInstances = await db.frameworkInstance.findMany({ + where: { organizationId }, + include: { framework: true, timelineInstances: { select: { id: true } } }, + }); + + for (const fi of frameworkInstances) { + // Custom frameworks don't have a platform Framework record, so there's + // no template to backfill from — skip them. + if (!fi.frameworkId || !fi.framework) continue; + // Always call backfillTimeline — it's idempotent per-track and repairs + // partial state (e.g. SOC 2 with only Type 1 created, missing Type 2). + try { + await backfillTimeline({ + organizationId, + frameworkInstance: { + id: fi.id, + frameworkId: fi.frameworkId, + framework: fi.framework, + }, + }); + } catch { + // Non-blocking — don't fail the list if one timeline can't be created + } + } + } + + async findOne(id: string, organizationId: string) { + const instance = await db.timelineInstance.findUnique({ + where: { id, organizationId }, + include: { + phases: { + orderBy: { orderIndex: 'asc' }, + include: { completedBy: { select: { id: true, name: true, email: true } } }, + }, + frameworkInstance: { include: { framework: true } }, + template: { + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }, + }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + return instance; + } + + async markReadyForReview( + instanceId: string, + phaseId: string, + organizationId: string, + ) { + const instance = await db.timelineInstance.findUnique({ + where: { id: instanceId, organizationId }, + include: { + phases: true, + frameworkInstance: { include: { framework: true } }, + organization: { select: { id: true, name: true } }, + }, + }); + + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + + const phase = instance.phases.find((p) => p.id === phaseId); + if (!phase) { + throw new NotFoundException('Phase not found'); + } + + if (phase.status !== TimelinePhaseStatus.IN_PROGRESS) { + throw new BadRequestException( + 'Only in-progress phases can be marked ready for review', + ); + } + + // If already marked ready, return idempotently so retries / double-clicks + // don't re-ping Slack for the same transition. Caller checks + // `alreadyReady` to decide whether to fire the notification. + if (phase.readyForReview) { + return { + organization: instance.organization, + framework: instance.frameworkInstance.framework, + phase, + alreadyReady: true, + }; + } + + const updated = await db.timelinePhase.update({ + where: { id: phaseId }, + data: { readyForReview: true, readyForReviewAt: new Date() }, + }); + + return { + organization: instance.organization, + framework: instance.frameworkInstance.framework, + phase: updated, + alreadyReady: false, + }; + } + + // --------------------------------------------------------------------------- + // Instance creation + // --------------------------------------------------------------------------- + + async createFromTemplate({ + organizationId, + frameworkInstanceId, + cycleNumber, + trackKey = 'primary', + }: { + organizationId: string; + frameworkInstanceId: string; + cycleNumber: number; + trackKey?: string; + }) { + const frameworkInstance = await db.frameworkInstance.findUnique({ + where: { id: frameworkInstanceId, organizationId }, + include: { framework: true }, + }); + + if (!frameworkInstance) { + throw new NotFoundException('Framework instance not found'); + } + + // Timelines are only created for platform frameworks; custom frameworks + // don't have pre-built templates. + if (!frameworkInstance.frameworkId || !frameworkInstance.framework) { + return null; + } + + const template = await resolveTemplate( + frameworkInstance.frameworkId, + frameworkInstance.framework.name, + cycleNumber, + { trackKey }, + ); + + if (!template) { + return null; + } + + return createInstanceFromTemplate({ + organizationId, + frameworkInstanceId, + cycleNumber, + template, + }); + } + + // --------------------------------------------------------------------------- + // Lifecycle delegates — exposed for controllers + // --------------------------------------------------------------------------- + + async activate(id: string, organizationId: string, startDate: Date) { + return this.lifecycle.activate(id, organizationId, startDate); + } + + async pauseTimeline(id: string, organizationId: string) { + return this.lifecycle.pause(id, organizationId); + } + + async resumeTimeline(id: string, organizationId: string) { + return this.lifecycle.resume(id, organizationId); + } + + async completePhase( + instanceId: string, + phaseId: string, + organizationId: string, + userId?: string, + ) { + return this.lifecycle.completePhase( + instanceId, + phaseId, + organizationId, + userId, + ); + } + + async unlockTimeline( + id: string, + organizationId: string, + unlockedById: string, + unlockReason: string, + ) { + return this.lifecycle.unlock(id, organizationId, unlockedById, unlockReason); + } + + // --------------------------------------------------------------------------- + // Admin — next cycle, delete, reset, recreate + // --------------------------------------------------------------------------- + + async startNextCycle(id: string, organizationId: string) { + const current = await this.findOne(id, organizationId); + + if (current.status !== 'COMPLETED') { + throw new BadRequestException('Timeline must be completed to start the next cycle'); + } + + const nextCycleNumber = current.cycleNumber + 1; + const currentTrackKey = + current.trackKey ?? current.template?.trackKey ?? 'primary'; + + // Check if next cycle already exists + const existing = await db.timelineInstance.findFirst({ + where: { + frameworkInstanceId: current.frameworkInstanceId, + trackKey: currentTrackKey, + cycleNumber: nextCycleNumber, + }, + }); + + if (existing) { + throw new BadRequestException(`Cycle ${nextCycleNumber} already exists for this framework`); + } + + // Prefer explicit template progression when configured. + const nextTemplateKey = current.template?.nextTemplateKey ?? null; + const templateFrameworkId = current.template?.frameworkId ?? null; + + if (nextTemplateKey && templateFrameworkId) { + const progressedTemplate = await db.timelineTemplate.findUnique({ + where: { + frameworkId_templateKey: { + frameworkId: templateFrameworkId, + templateKey: nextTemplateKey, + }, + }, + include: { phases: { orderBy: { orderIndex: 'asc' } } }, + }); + + if (progressedTemplate) { + return createInstanceFromTemplate({ + organizationId, + frameworkInstanceId: current.frameworkInstanceId, + cycleNumber: nextCycleNumber, + template: progressedTemplate, + }); + } + } + + return this.createFromTemplate({ + organizationId, + frameworkInstanceId: current.frameworkInstanceId, + cycleNumber: nextCycleNumber, + trackKey: currentTrackKey, + }); + } + + async deleteInstance(id: string, organizationId: string) { + const instance = await db.timelineInstance.findFirst({ + where: { id, organizationId }, + }); + if (!instance) { + throw new NotFoundException('Timeline instance not found'); + } + await db.timelinePhase.deleteMany({ where: { instanceId: id } }); + await db.timelineInstance.delete({ where: { id } }); + return { deleted: true }; + } + + async resetInstance(id: string, organizationId: string) { + const instance = await this.findOne(id, organizationId); + await db.$transaction(async (tx) => { + for (const phase of instance.phases) { + await tx.timelinePhase.update({ + where: { id: phase.id }, + data: { + status: 'PENDING', + startDate: null, + endDate: null, + completedAt: null, + completedById: null, + readyForReview: false, + readyForReviewAt: null, + datesPinned: false, + regressedAt: null, + }, + }); + } + await tx.timelineInstance.update({ + where: { id }, + data: { + status: 'DRAFT', + startDate: null, + pausedAt: null, + lockedAt: null, + lockedById: null, + unlockedAt: null, + unlockedById: null, + unlockReason: null, + completedAt: null, + }, + }); + }); + return this.findOne(id, organizationId); + } + + async recreateAllForOrganization(organizationId: string) { + // Delete all existing timeline instances for this org + const existing = await db.timelineInstance.findMany({ + where: { organizationId }, + select: { id: true }, + }); + for (const inst of existing) { + await db.timelinePhase.deleteMany({ where: { instanceId: inst.id } }); + } + await db.timelineInstance.deleteMany({ where: { organizationId } }); + + // Re-run backfill with forceRefresh to update templates from latest code defaults + const frameworkInstances = await db.frameworkInstance.findMany({ + where: { organizationId }, + include: { framework: true, timelineInstances: { select: { id: true } } }, + }); + + for (const fi of frameworkInstances) { + // Skip custom frameworks — no template to backfill from. + if (!fi.frameworkId || !fi.framework) continue; + try { + await backfillTimeline({ + organizationId, + frameworkInstance: { + id: fi.id, + frameworkId: fi.frameworkId, + framework: fi.framework, + }, + forceRefresh: true, + }); + } catch { + // Non-blocking + } + } + + // After a full recreate, immediately reconcile AUTO phases against live + // metrics (bypassing regression grace) so the returned state is fresh. + await this.reconcileAutoPhasesForOrganization(organizationId, { + bypassRegressionGrace: true, + }); + + return this.findAllForOrganization(organizationId); + } +} diff --git a/apps/app/.gitignore b/apps/app/.gitignore index 3ea39de1e..445f28c26 100644 --- a/apps/app/.gitignore +++ b/apps/app/.gitignore @@ -50,9 +50,3 @@ next-env.d.ts # Sentry Config File .env.sentry-build-plugin -# Generated Prisma Client -prisma/generated -src/generated/ -# Model files are copied here by db:getschema — only schema.prisma is committed -prisma/schema/*.prisma -!prisma/schema/schema.prisma diff --git a/apps/app/package.json b/apps/app/package.json index 508d84aa6..2fd283045 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -186,15 +186,11 @@ "scripts": { "analyze-locale-usage": "bunx tsx src/locales/analyze-locale-usage.ts", "build": "next build", - "build:docker": "prisma generate --schema=prisma/schema && node ../../packages/db/scripts/fix-generated-extensions.js src/generated/prisma && next build", - "db:generate": "bun run db:getschema && prisma generate --schema=prisma/schema && node ../../packages/db/scripts/fix-generated-extensions.js src/generated/prisma", - "db:getschema": "find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;", + "build:docker": "next build", "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", "deploy:trigger-prod": "npx trigger.dev@4.4.3 deploy", - "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000\" \"NODE_OPTIONS='--no-deprecation' trigger dev\"", + "dev": "bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"NODE_OPTIONS='--no-deprecation' next dev --turbo -p 3000\" \"NODE_OPTIONS='--no-deprecation' trigger dev\"", "lint": "eslint . && prettier --check .", - "prebuild": "bun run db:generate", - "postinstall": "prisma generate --schema=./prisma/schema || exit 0", "start": "next start", "test": "vitest", "test:all": "./scripts/test-all.sh", diff --git a/apps/app/prisma.config.ts b/apps/app/prisma.config.ts deleted file mode 100644 index c0e1147ed..000000000 --- a/apps/app/prisma.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import "dotenv/config"; -import { defineConfig } from "prisma/config"; - -export default defineConfig({ - schema: "prisma/schema", - datasource: { - url: process.env.DATABASE_URL!, - }, -}); diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index 169de2353..21e833f75 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -1,4 +1,4 @@ -import { PrismaClient } from '../src/generated/prisma/client'; +import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; const globalForPrisma = global as unknown as { prisma: PrismaClient }; diff --git a/apps/app/prisma/index.ts b/apps/app/prisma/index.ts index ccdcea1df..b329db54e 100644 --- a/apps/app/prisma/index.ts +++ b/apps/app/prisma/index.ts @@ -1 +1 @@ -export * from '../src/generated/prisma/browser'; +export * from '@prisma/client'; diff --git a/apps/app/prisma/schema/schema.prisma b/apps/app/prisma/schema/schema.prisma deleted file mode 100644 index 45f4244cf..000000000 --- a/apps/app/prisma/schema/schema.prisma +++ /dev/null @@ -1,10 +0,0 @@ -generator client { - provider = "prisma-client" - output = "../../src/generated/prisma" - previewFeatures = ["postgresqlExtensions"] -} - -datasource db { - provider = "postgresql" - extensions = [pgcrypto] -} diff --git a/apps/app/prisma/server.ts b/apps/app/prisma/server.ts index 7969070f3..54d1c4b9c 100644 --- a/apps/app/prisma/server.ts +++ b/apps/app/prisma/server.ts @@ -1,2 +1,2 @@ -export * from '../src/generated/prisma/client'; +export * from '@prisma/client'; export { db } from './client'; diff --git a/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx b/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx index b30cdf22a..58cffeea8 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/components/AdminSidebar.tsx @@ -14,6 +14,7 @@ export function AdminSidebar({ orgId }: AdminSidebarProps) { const items = [ { id: 'organizations', label: 'Organizations', path: `/${orgId}/admin/organizations` }, { id: 'integrations', label: 'Integrations', path: `/${orgId}/admin/integrations` }, + { id: 'timeline-templates', label: 'Timeline Templates', path: `/${orgId}/admin/timeline-templates` }, ]; const isPathActive = (path: string) => pathname.startsWith(path); diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx index 6ab5aaa79..7389f61bd 100644 --- a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/AdminOrgTabs.tsx @@ -31,6 +31,8 @@ import { VendorsTab } from './VendorsTab'; import { ContextTab } from './ContextTab'; import { EvidenceTab } from './EvidenceTab'; import { PoliciesTab } from './PoliciesTab'; +import { TimelineTab } from './TimelineTab'; +import { FeatureFlagsTab } from './FeatureFlagsTab'; interface OrgMember { id: string; @@ -131,6 +133,8 @@ export function AdminOrgTabs({ Vendors Context Evidence + Timeline + Feature Flags } > @@ -177,6 +181,12 @@ export function AdminOrgTabs({ + + + + + + (null); + const [refreshing, setRefreshing] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + + const filteredFlags = useMemo(() => { + const q = searchTerm.trim().toLowerCase(); + if (!q) return flags; + return flags.filter( + (f) => + f.key.toLowerCase().includes(q) || + f.description?.toLowerCase().includes(q), + ); + }, [flags, searchTerm]); + + const handleToggle = async (flag: AdminOrgFeatureFlag, enabled: boolean) => { + setUpdatingKey(flag.key); + + // Snapshot previous state so we can roll back on error. + const previous = flags; + + // Optimistic update — don't revalidate (PostHog has write-propagation lag). + mutate( + (prev) => + (prev ?? []).map((f) => (f.key === flag.key ? { ...f, enabled } : f)), + { revalidate: false }, + ); + + try { + await setAdminOrgFeatureFlag({ orgId, flagKey: flag.key, enabled }); + toast.success(`"${flag.name}" ${enabled ? 'enabled' : 'disabled'} for this organization`); + // Trust the write. Skip revalidation — PostHog isFeatureEnabled may lag + // behind groupIdentify and temporarily return the old value. + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to update feature flag'); + // Roll back to the snapshot. + mutate(previous, { revalidate: false }); + } finally { + setUpdatingKey(null); + } + }; + + const handleRefresh = async () => { + setRefreshing(true); + try { + await mutate(); + } finally { + setRefreshing(false); + } + }; + + if (isLoading) { + return ( +
+ Loading feature flags… +
+ ); + } + + if (error) { + return ( + + Failed to load feature flags + + {error instanceof Error ? error.message : 'Unknown error'} + + + ); + } + + if (flags.length === 0) { + return ( + + No feature flags found + + Create a feature flag in PostHog and set POSTHOG_PERSONAL_API_KEY and{' '} + POSTHOG_PROJECT_ID on the API to manage it here. + + + ); + } + + return ( + +
+
+ + + + + setSearchTerm(e.target.value)} + /> + +
+ +
+ {filteredFlags.length === 0 ? ( + No flags match your search. + ) : ( + + {filteredFlags.map((flag) => ( + +
+ {!flag.active && Inactive} + handleToggle(flag, checked)} + /> +
+
+ ))} +
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineActivateForm.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineActivateForm.tsx new file mode 100644 index 000000000..5662c77ef --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineActivateForm.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/lib/api-client'; +import { Button } from '@trycompai/design-system'; +import { Play } from '@trycompai/design-system/icons'; +import { Input } from '@trycompai/ui/input'; + +interface TimelineActivateFormProps { + orgId: string; + timelineId: string; + onMutate: () => void; +} + +export function TimelineActivateForm({ + orgId, + timelineId, + onMutate, +}: TimelineActivateFormProps) { + const [startDate, setStartDate] = useState(''); + const [loading, setLoading] = useState(false); + + const handleActivate = async () => { + if (!startDate) { + toast.error('Please select a start date'); + return; + } + + setLoading(true); + try { + // startDate comes from as YYYY-MM-DD. Using + // `new Date(...)` would treat it as UTC midnight and shift the day + // in negative UTC offsets. Parse as local midnight so the user's + // chosen calendar day is preserved when serialized to ISO. + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(startDate); + const parsed = match + ? new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])) + : new Date(NaN); + if (Number.isNaN(parsed.getTime())) { + toast.error('Invalid start date'); + return; + } + const res = await api.post( + `/v1/admin/organizations/${orgId}/timelines/${timelineId}/activate`, + { startDate: parsed.toISOString() }, + ); + if (res.error) { + toast.error(res.error); + return; + } + toast.success('Timeline activated'); + onMutate(); + } finally { + setLoading(false); + } + }; + + return ( +
+ setStartDate(e.target.value)} + className="h-8 w-40 text-sm" + /> + +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineCard.tsx b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineCard.tsx new file mode 100644 index 000000000..a55d51ffd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/admin/organizations/[adminOrgId]/components/TimelineCard.tsx @@ -0,0 +1,528 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { api } from '@/lib/api-client'; +import type { AdminOrgTimeline } from '@/hooks/use-admin-timelines'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, + Badge, + Button, + Section, + Textarea, +} from '@trycompai/design-system'; +import { + Checkmark, + CircleDash, + InProgress, + Locked, + Pause, + Play, + Edit, + Reset, + TrashCan, + Unlocked, +} from '@trycompai/design-system/icons'; +import { TimelinePhaseBar } from '@/app/(app)/[orgId]/overview/components/TimelinePhaseBar'; +import { TimelineActivateForm } from './TimelineActivateForm'; +import { TimelinePhaseEditor } from './TimelinePhaseEditor'; + +const STATUS_BADGE: Record< + AdminOrgTimeline['status'], + { label: string; variant: 'default' | 'outline' | 'destructive' } +> = { + DRAFT: { label: 'Draft', variant: 'outline' }, + ACTIVE: { label: 'Active', variant: 'default' }, + PAUSED: { label: 'Paused', variant: 'destructive' }, + COMPLETED: { label: 'Completed', variant: 'default' }, +}; + +const PHASE_COMPLETION_LABEL: Record< + AdminOrgTimeline['phases'][number]['completionType'], + string +> = { + MANUAL: 'Manual', + AUTO_TASKS: 'Auto (Tasks)', + AUTO_POLICIES: 'Auto (Policies)', + AUTO_PEOPLE: 'Auto (People)', + AUTO_FINDINGS: 'Auto (Findings)', + AUTO_UPLOAD: 'Auto (Upload)', +}; + +function formatDate(date: string | null): string { + if (!date) return '--'; + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + +type PhaseEntry = + | { type: 'ungrouped'; phase: AdminOrgTimeline['phases'][number] } + | { type: 'group'; label: string; phases: AdminOrgTimeline['phases'] }; + +function buildPhaseEntries(phases: AdminOrgTimeline['phases']): PhaseEntry[] { + const entries: PhaseEntry[] = []; + const seen = new Set(); + for (const phase of phases) { + if (!phase.groupLabel) { + entries.push({ type: 'ungrouped', phase }); + continue; + } + if (seen.has(phase.groupLabel)) continue; + seen.add(phase.groupLabel); + entries.push({ + type: 'group', + label: phase.groupLabel, + phases: phases.filter((p) => p.groupLabel === phase.groupLabel), + }); + } + return entries; +} + +interface TimelineCardProps { + timeline: AdminOrgTimeline; + orgId: string; + onMutate: () => void; +} + +export function TimelineCard({ timeline, orgId, onMutate }: TimelineCardProps) { + const [actionLoading, setActionLoading] = useState(false); + const [editingPhaseId, setEditingPhaseId] = useState(null); + const badge = STATUS_BADGE[timeline.status]; + const frameworkName = + timeline.template?.name ?? + timeline.frameworkInstance?.framework.name ?? + 'Unknown Framework'; + const sortedPhases = [...timeline.phases].sort( + (a, b) => a.orderIndex - b.orderIndex, + ); + + const runAction = async ( + method: 'post' | 'delete', + path: string, + successMsg: string, + body?: unknown, + ) => { + setActionLoading(true); + const res = await ( + method === 'delete' + ? api.delete(path, undefined, body) + : api.post(path, body) + ); + setActionLoading(false); + if (res.error) { + toast.error(res.error); + return; + } + toast.success(successMsg); + onMutate(); + }; + + return ( +
+ {badge.label} + {timeline.lockedAt ? ( + + + Locked + + ) : null} + + runAction('post', `/v1/admin/organizations/${orgId}/timelines/${timeline.id}/pause`, 'Timeline paused') + } + onResume={() => + runAction('post', `/v1/admin/organizations/${orgId}/timelines/${timeline.id}/resume`, 'Timeline resumed') + } + onReset={() => + runAction('post', `/v1/admin/organizations/${orgId}/timelines/${timeline.id}/reset`, 'Timeline reset to draft') + } + onDelete={() => + runAction('delete', `/v1/admin/organizations/${orgId}/timelines/${timeline.id}`, 'Timeline deleted') + } + onStartNextCycle={() => + runAction('post', `/v1/admin/organizations/${orgId}/timelines/${timeline.id}/next-cycle`, 'Next cycle created as draft') + } + onUnlock={(unlockReason) => + runAction( + 'post', + `/v1/admin/organizations/${orgId}/timelines/${timeline.id}/unlock`, + 'Timeline unlocked', + { unlockReason }, + ) + } + onMutate={onMutate} + /> +
+ } + > +
+ +
+ +
+ {buildPhaseEntries(sortedPhases).map((entry) => { + if (entry.type === 'group') { + return ( +
+
+
+ {entry.label} + {entry.phases.map((phase) => ( + setEditingPhaseId(phase.id)} + /> + ))} +
+
+ ); + } + const phase = entry.phase; + return ( + setEditingPhaseId(phase.id)} + /> + ); + })} +
+ + {editingPhaseId && ( + setEditingPhaseId(null)} + orgId={orgId} + timelineId={timeline.id} + phase={sortedPhases.find((p) => p.id === editingPhaseId) ?? null} + onMutate={onMutate} + /> + )} + + ); +} + +function PhaseRow({ + phase, + editable, + onEdit, +}: { + phase: AdminOrgTimeline['phases'][number]; + editable: boolean; + onEdit: () => void; +}) { + const isCompleted = phase.status === 'COMPLETED'; + const isActive = phase.status === 'IN_PROGRESS'; + const borderClass = isCompleted + ? 'border-primary/30 bg-primary/5' + : isActive + ? 'border-primary/40 bg-primary/10' + : 'border-border bg-muted/20'; + + return ( +
+
+ +
+
+ {phase.name} + + {phase.durationWeeks}w · {formatDate(phase.startDate)} - {formatDate(phase.endDate)} + +
+ {phase.locksTimelineOnComplete ? ( + + + Lock + + ) : null} + + {PHASE_COMPLETION_LABEL[phase.completionType]} + + + {phase.status.replace('_', ' ')} + + {editable && ( + + )} +
+ ); +} + +function PhaseStatusIcon({ status }: { status: string }) { + if (status === 'COMPLETED') return ; + if (status === 'IN_PROGRESS') return ; + return ; +} + +function ConfirmButton({ + title, + description, + onConfirm, + loading, + variant = 'outline', + icon, + children, +}: { + title: string; + description: string; + onConfirm: () => void; + loading: boolean; + variant?: 'outline' | 'destructive'; + icon?: React.ReactNode; + children: React.ReactNode; +}) { + const [open, setOpen] = useState(false); + + return ( + + + {children} + + } + /> + + + {title} + {description} + + + Cancel + { + setOpen(false); + onConfirm(); + }} + > + Confirm + + + + + ); +} + +function TimelineActions({ + status, + lockedAt, + orgId, + timelineId, + loading, + onPause, + onResume, + onReset, + onDelete, + onStartNextCycle, + onUnlock, + onMutate, +}: { + status: AdminOrgTimeline['status']; + lockedAt: string | null; + orgId: string; + timelineId: string; + loading: boolean; + onPause: () => void; + onResume: () => void; + onReset: () => void; + onDelete: () => void; + onStartNextCycle: () => void; + onUnlock: (unlockReason: string) => void; + onMutate: () => void; +}) { + if (status === 'DRAFT') { + return ( +
+ + } + > + Delete + +
+ ); + } + + if (status === 'ACTIVE') { + return ( +
+ {lockedAt ? ( + + ) : null} + } + > + Pause + + } + > + Reset + +
+ ); + } + + if (status === 'PAUSED') { + return ( +
+ {lockedAt ? ( + + ) : null} + } + > + Resume + + } + > + Reset + +
+ ); + } + + if (status === 'COMPLETED') { + return ( +
+ } + > + Start Next Cycle + + } + > + Delete + +
+ ); + } + + return null; +} + +function UnlockDialogButton({ + onConfirm, + loading, +}: { + onConfirm: (unlockReason: string) => void; + loading: boolean; +}) { + const [open, setOpen] = useState(false); + const [unlockReason, setUnlockReason] = useState(''); + const trimmedReason = unlockReason.trim(); + + return ( + { + setOpen(nextOpen); + if (!nextOpen) setUnlockReason(''); + }} + > + } loading={loading}> + Unlock + + } + /> + + + Unlock Timeline + + Unlocking will re-enable automation checks. A reason is required for audit history. + + +
+ +