diff --git a/.gitignore b/.gitignore index 9cd387c21e..58946b17f2 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 8177432ca0..a9fdfa1865 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 c55d68cb37..4bf6aa80c4 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", @@ -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", @@ -102,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", @@ -167,7 +169,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/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 0000000000..65d0c8a72a --- /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 0000000000..ce2b1b0e98 --- /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 0000000000..c2d54c4a55 --- /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 0000000000..588eea17f7 --- /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 0000000000..192c5c6692 --- /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 0000000000..c2520805e5 --- /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 0000000000..942e155fe0 --- /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 3bc246e5ac..b6ada5eb93 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/devices/devices.service.ts b/apps/api/src/devices/devices.service.ts index fcfd5e4eef..cc43686d84 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/api/src/evidence-forms/evidence-forms.module.ts b/apps/api/src/evidence-forms/evidence-forms.module.ts index 333ffaa5d0..a2d69ad34a 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 670ee8d6ad..26b7752d91 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 53e6c6edae..b1f88934cf 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 975b20351e..642f8a49b4 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 6d70b5de6a..e5f1ff961c 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 0000000000..7cb00fbaa8 --- /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 9c233b1640..b629742787 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 0000000000..b26169104f --- /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 0000000000..fc604bb29e --- /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 b6d956e7fd..53068441fe 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 6947aa8524..f00ec0768e 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 ccf49286f7..46de4839c5 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 8a827f18cd..9bdb30f449 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 f3c9e31fd3..420c2c8760 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 5a55212ce6..3f6dcc0c9f 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 87c878e8aa..8d26064f9f 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 b6ce94971f..ea0a77b7d5 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 9f36b2cba9..e0ab4cc2e5 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 e4e742c3b2..14ff3901a6 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 8642a55bc4..42487bc8a7 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 9f3b2ad4be..ff4b6d2fad 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 888d2503e3..0693bf30cf 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 dd19c9dda6..ae4896ad14 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 0000000000..d794f8e245 --- /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 0000000000..572051a20c --- /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 0000000000..34ac24e112 --- /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 0000000000..93350b75fd --- /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 0000000000..e7e6299ad3 --- /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 0000000000..1f1250da5c --- /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 0000000000..7cbfc2262b --- /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 0000000000..01b2f8eedd --- /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 0000000000..1fbc963b2b --- /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 0000000000..04a9abbff3 --- /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 0000000000..752e40ce4f --- /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 0000000000..64704c9b5a --- /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 0000000000..e818ab9dbb --- /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 0000000000..f101ceaa22 --- /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 0000000000..0cefa3dc9f --- /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 0000000000..04a8c55fa7 --- /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 0000000000..8afcdc4308 --- /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 0000000000..e8c434a53b --- /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 0000000000..dec25f9cd7 --- /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 0000000000..ee8f5d1991 --- /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 0000000000..88b756d923 --- /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 0000000000..0edb0c7fe4 --- /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 0000000000..f12af67f85 --- /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 0000000000..b6bbe74a52 --- /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 0000000000..6edddfde6d --- /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 0000000000..3fd0349924 --- /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 0000000000..eb70ab885f --- /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 0000000000..3467a8f3a4 --- /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 3ea39de1ef..3d0979a1c7 100644 --- a/apps/app/.gitignore +++ b/apps/app/.gitignore @@ -52,6 +52,7 @@ next-env.d.ts # Generated Prisma Client prisma/generated +prisma/src/generated/ src/generated/ # Model files are copied here by db:getschema — only schema.prisma is committed prisma/schema/*.prisma diff --git a/apps/app/package.json b/apps/app/package.json index 508d84aa6e..a2a5f22020 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -191,7 +191,7 @@ "db:getschema": "find ../../packages/db/prisma/schema -name '*.prisma' ! -name 'schema.prisma' -exec cp {} prisma/schema/ \\;", "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", diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index 169de23539..21e833f75a 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 ccdcea1dfa..b329db54e3 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 index 45f4244cf6..c02b00f006 100644 --- a/apps/app/prisma/schema/schema.prisma +++ b/apps/app/prisma/schema/schema.prisma @@ -1,6 +1,6 @@ generator client { provider = "prisma-client" - output = "../../src/generated/prisma" + output = "../src/generated/prisma" previewFeatures = ["postgresqlExtensions"] } diff --git a/apps/app/prisma/server.ts b/apps/app/prisma/server.ts index 7969070f3c..54d1c4b9c9 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 b30cdf22af..58cffeea82 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 6ab5aaa79f..7389f61bd4 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 0000000000..5662c77ef7 --- /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 0000000000..a55d51ffd5 --- /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. + + +
+ +