diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index bf6d8b15fb..eff3454cf4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -55,6 +55,7 @@ import { AdminFeatureFlagsModule } from './admin-feature-flags/admin-feature-fla import { TimelinesModule } from './timelines/timelines.module'; import { BackgroundChecksModule } from './background-checks/background-checks.module'; import { BillingModule } from './billing/billing.module'; +import { OffboardingChecklistModule } from './offboarding-checklist/offboarding-checklist.module'; @Module({ imports: [ @@ -122,6 +123,7 @@ import { BillingModule } from './billing/billing.module'; AdminOrganizationsModule, AdminFeatureFlagsModule, TimelinesModule, + OffboardingChecklistModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/auth/acting-user.service.spec.ts b/apps/api/src/auth/acting-user.service.spec.ts new file mode 100644 index 0000000000..bf6762093a --- /dev/null +++ b/apps/api/src/auth/acting-user.service.spec.ts @@ -0,0 +1,208 @@ +// Mock @db before importing the service so the Prisma client doesn't try +// to connect at import time in this unit-test env. +const mockDb = { + member: { + findFirst: jest.fn(), + }, +}; + +jest.mock('@db', () => ({ db: mockDb })); + +import { ActingUserResolver } from './acting-user.service'; +import type { AuthenticatedRequest } from './types'; + +function makeReq(overrides: Partial = {}): AuthenticatedRequest { + return { + organizationId: 'org_1', + authType: 'session', + isApiKey: false, + isServiceToken: false, + isPlatformAdmin: false, + userRoles: null, + ...overrides, + } as unknown as AuthenticatedRequest; +} + +describe('ActingUserResolver', () => { + let resolver: ActingUserResolver; + + beforeEach(() => { + jest.clearAllMocks(); + resolver = new ActingUserResolver(); + }); + + describe('session caller (short-circuit)', () => { + it('returns req.userId without a DB query', async () => { + const req = makeReq({ + userId: 'usr_session_alice', + authType: 'session', + }); + + const result = await resolver.resolve(req, 'org_1'); + + expect(result).toEqual({ + userId: 'usr_session_alice', + source: 'session', + }); + // Critical regression guard — session auth must NEVER hit the DB + // for owner lookup. That would be a perf regression on every UI call. + expect(mockDb.member.findFirst).not.toHaveBeenCalled(); + }); + + it('does NOT include a callerLabel for session callers', async () => { + // Session actions don't need automation-marker text in the audit log. + const req = makeReq({ userId: 'usr_session_alice' }); + const result = await resolver.resolve(req, 'org_1'); + expect(result.callerLabel).toBeUndefined(); + }); + }); + + describe('service token acting on behalf of a specific user', () => { + it('returns the x-user-id userId set by HybridAuthGuard, source service-token-acting', async () => { + // HybridAuthGuard already validated the x-user-id header against + // Member and set req.userId. We classify it differently from session + // for telemetry but don't need to re-validate. + const req = makeReq({ + userId: 'usr_acting_bob', + authType: 'service', + isApiKey: false, + isServiceToken: true, + serviceName: 'Trigger.dev', + }); + + const result = await resolver.resolve(req, 'org_1'); + + expect(result).toEqual({ + userId: 'usr_acting_bob', + source: 'service-token-acting', + }); + expect(mockDb.member.findFirst).not.toHaveBeenCalled(); + }); + }); + + describe('API key caller (owner fallback)', () => { + it('resolves to the org owner and labels the caller for the audit log', async () => { + mockDb.member.findFirst.mockResolvedValueOnce({ + userId: 'usr_owner_carol', + }); + + const req = makeReq({ + userId: undefined, + authType: 'api-key', + isApiKey: true, + apiKeyId: 'apk_1', + apiKeyName: 'CI Pipeline', + }); + + const result = await resolver.resolve(req, 'org_1'); + + expect(result.userId).toBe('usr_owner_carol'); + expect(result.source).toBe('org-owner-fallback'); + expect(result.callerLabel).toBe('via API key "CI Pipeline"'); + }); + + it('scopes the owner lookup to the calling org (cross-tenant safety)', async () => { + mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' }); + const req = makeReq({ + userId: undefined, + authType: 'api-key', + isApiKey: true, + apiKeyName: 'X', + }); + + await resolver.resolve(req, 'org_target'); + + expect(mockDb.member.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + organizationId: 'org_target', + role: { contains: 'owner' }, + }), + }), + ); + }); + + it('picks the OLDEST owner deterministically (orderBy createdAt asc)', async () => { + // Determinism matters — re-running the same automation should always + // attribute to the same user, even if newer owners are added/removed. + mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_oldest' }); + const req = makeReq({ + userId: undefined, + isApiKey: true, + apiKeyName: 'X', + }); + + await resolver.resolve(req, 'org_1'); + + expect(mockDb.member.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + orderBy: { createdAt: 'asc' }, + }), + ); + }); + + it('returns null userId (not throw) when the org has no owner-role members', async () => { + // Soft failure — the controller surfaces a 400 with an actionable + // message ("ensure your org has an owner"). Throwing here would + // 500 instead, which is worse UX. + mockDb.member.findFirst.mockResolvedValueOnce(null); + const req = makeReq({ + userId: undefined, + isApiKey: true, + apiKeyName: 'X', + }); + + const result = await resolver.resolve(req, 'org_no_owner'); + + expect(result.userId).toBeNull(); + expect(result.source).toBe('org-owner-fallback'); + // callerLabel still populated so the eventual 400 message can mention + // which API key tried (helpful in customer support). + expect(result.callerLabel).toBe('via API key "X"'); + }); + + it('falls back to "via API key" when the key name is missing (defensive)', async () => { + mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' }); + const req = makeReq({ + userId: undefined, + isApiKey: true, + apiKeyName: undefined, + }); + + const result = await resolver.resolve(req, 'org_1'); + + expect(result.callerLabel).toBe('via API key'); + }); + }); + + describe('service token without x-user-id (owner fallback)', () => { + it('resolves to the org owner with a service-flavored caller label', async () => { + mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' }); + const req = makeReq({ + userId: undefined, + authType: 'service', + isServiceToken: true, + serviceName: 'Trigger.dev', + }); + + const result = await resolver.resolve(req, 'org_1'); + + expect(result.userId).toBe('usr_owner'); + expect(result.source).toBe('org-owner-fallback'); + expect(result.callerLabel).toBe('via service "Trigger.dev"'); + }); + + it('falls back to "via service token" when the service name is missing', async () => { + mockDb.member.findFirst.mockResolvedValueOnce({ userId: 'usr_owner' }); + const req = makeReq({ + userId: undefined, + isServiceToken: true, + serviceName: undefined, + }); + + const result = await resolver.resolve(req, 'org_1'); + + expect(result.callerLabel).toBe('via service token'); + }); + }); +}); diff --git a/apps/api/src/auth/acting-user.service.ts b/apps/api/src/auth/acting-user.service.ts new file mode 100644 index 0000000000..8224bb3c1d --- /dev/null +++ b/apps/api/src/auth/acting-user.service.ts @@ -0,0 +1,140 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { db } from '@db'; +import type { AuthenticatedRequest } from './types'; + +/** + * The auth flow that produced the userId we'll attribute a mutation to. + * + * - 'session' — req.userId set by better-auth from a session cookie / bearer token. + * - 'service-token-acting' — service token caller passed an `x-user-id` header + * which HybridAuthGuard validated against Member and set on req.userId. + * - 'org-owner-fallback' — API key (or service token without x-user-id) + * resolved to the org's oldest owner. This keeps mutations API-callable + * without forcing callers to manage user IDs themselves. + */ +export type ActingUserSource = + | 'session' + | 'service-token-acting' + | 'org-owner-fallback'; + +export interface ResolvedActingUser { + /** User ID to attribute the mutation to. Null only when no fallback was + * available (e.g. an org with zero owner-role members — caller should + * surface a 400 with an actionable message). */ + userId: string | null; + source: ActingUserSource; + /** Short label for audit log descriptions. Only set when source is + * 'org-owner-fallback' — session and explicit service-token acting + * don't need to call out automation in the audit trail. */ + callerLabel?: string; +} + +/** + * Resolves the user that a mutation should be attributed to, accepting any + * supported auth method. This is the single, shared way for write endpoints + * to answer "whose userId should I record on this audit log / row?". + * + * Rules: + * 1. Session callers — `req.userId` is already set by HybridAuthGuard. + * We return it without a DB query (zero overhead for the common UI path). + * 2. Service tokens calling on behalf of a specific user — HybridAuthGuard + * sets `req.userId` from the `x-user-id` header after Member validation. + * Same short-circuit as session. + * 3. API keys, or service tokens without `x-user-id` — no per-user identity + * exists. We attribute to the org's OLDEST owner (deterministic + stable + * across deletes of newer owners). This is consistent with how 19+ + * other places in the codebase already look up org owners + * (`Member.role.contains('owner')`). + * + * Returning null userId is a soft failure — callers must surface a 400 with + * the org-needs-an-owner message rather than 500-ing on a Prisma FK error. + */ +@Injectable() +export class ActingUserResolver { + private readonly logger = new Logger(ActingUserResolver.name); + + async resolve( + req: AuthenticatedRequest, + organizationId: string, + ): Promise { + // Path 1 + 2 — session caller, or service token acting on behalf of a + // specific user. HybridAuthGuard already set req.userId for both, so we + // just classify which one and short-circuit. No DB query. + if (req.userId) { + return { + userId: req.userId, + source: req.isServiceToken ? 'service-token-acting' : 'session', + }; + } + + // Path 3 — fall back to the org's owner. + const ownerUserId = await this.findOrgOwnerUserId(organizationId); + if (!ownerUserId) { + // No owner found. Don't invent one — the caller should reject the + // mutation with a clear message so the customer can fix the role + // assignment themselves. + this.logger.warn( + `No owner-role member found for org ${organizationId}; mutation cannot be attributed.`, + ); + return { + userId: null, + source: 'org-owner-fallback', + callerLabel: this.buildCallerLabel(req), + }; + } + + return { + userId: ownerUserId, + source: 'org-owner-fallback', + callerLabel: this.buildCallerLabel(req), + }; + } + + /** + * Find the oldest owner of an organization. Oldest is deterministic and + * stable: removing a recently-added owner doesn't change the attribution + * target, removing the oldest one just promotes the next one. Matches the + * pattern used elsewhere (e.g. tasks/task-notifier.service.ts). + * + * Member.role is a comma-separated string (e.g. "owner,admin"), so we use + * Prisma's `contains` filter — same query shape as the 19+ other owner + * lookups in this codebase. + */ + private async findOrgOwnerUserId( + organizationId: string, + ): Promise { + const owner = await db.member.findFirst({ + where: { + organizationId, + role: { contains: 'owner' }, + }, + orderBy: { createdAt: 'asc' }, + select: { userId: true }, + }); + return owner?.userId ?? null; + } + + /** + * Produces the short string that downstream audit-log descriptions + * prepend (e.g. `[via API key "CI Pipeline"]`). The caller decides where + * to put it; this helper just standardises the wording. + * + * Returns 'via API key' / 'via service token' / 'via API' as a graceful + * fallback so we always emit SOMETHING rather than drop attribution. + */ + private buildCallerLabel(req: AuthenticatedRequest): string { + if (req.isApiKey) { + return req.apiKeyName + ? `via API key "${req.apiKeyName}"` + : 'via API key'; + } + if (req.isServiceToken) { + return req.serviceName + ? `via service "${req.serviceName}"` + : 'via service token'; + } + // Should never reach here — Path 1/2 would have short-circuited — but + // we return a sane default rather than throw. + return 'via API'; + } +} diff --git a/apps/api/src/auth/api-key.service.ts b/apps/api/src/auth/api-key.service.ts index 8596207b8c..e78b2a6783 100644 --- a/apps/api/src/auth/api-key.service.ts +++ b/apps/api/src/auth/api-key.service.ts @@ -10,6 +10,13 @@ import { createHash, randomBytes } from 'node:crypto'; /** Result from validating an API key */ export interface ApiKeyValidationResult { + /** API key row primary key — exposed on the request so downstream + * attribution logic (audit logs, owner-fallback resolver) can reference + * the exact key used without an extra DB lookup. */ + apiKeyId: string; + /** Human-readable name set when the key was created (e.g. "CI Pipeline"). + * Surfaced in audit log descriptions for API-key-initiated mutations. */ + apiKeyName: string; organizationId: string; scopes: string[]; } @@ -187,6 +194,7 @@ export class ApiKeyService { }, select: { id: true, + name: true, key: true, salt: true, organizationId: true, @@ -214,6 +222,7 @@ export class ApiKeyService { }, select: { id: true, + name: true, key: true, salt: true, organizationId: true, @@ -234,6 +243,8 @@ export class ApiKeyService { data: { keyPrefix, lastUsedAt: new Date() }, }); return { + apiKeyId: legacyMatch.id, + apiKeyName: legacyMatch.name, organizationId: legacyMatch.organizationId, scopes: legacyMatch.scopes, }; @@ -258,6 +269,8 @@ export class ApiKeyService { ); return { + apiKeyId: matchingRecord.id, + apiKeyName: matchingRecord.name, organizationId: matchingRecord.organizationId, scopes: matchingRecord.scopes, }; diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts index d739406a1a..9efae356b7 100644 --- a/apps/api/src/auth/auth.module.ts +++ b/apps/api/src/auth/auth.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { AuthModule as BetterAuthModule } from '@thallesp/nestjs-better-auth'; import { auth } from './auth.server'; +import { ActingUserResolver } from './acting-user.service'; import { ApiKeyGuard } from './api-key.guard'; import { ApiKeyService } from './api-key.service'; import { AuthController } from './auth.controller'; @@ -24,12 +25,19 @@ import { PermissionGuard } from './permission.guard'; }), ], controllers: [AuthController], - providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, PermissionGuard], + providers: [ + ApiKeyService, + ApiKeyGuard, + HybridAuthGuard, + PermissionGuard, + ActingUserResolver, + ], exports: [ ApiKeyService, ApiKeyGuard, HybridAuthGuard, PermissionGuard, + ActingUserResolver, BetterAuthModule, ], }) diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts index 9b65b1defa..c97f2b43bf 100644 --- a/apps/api/src/auth/hybrid-auth.guard.ts +++ b/apps/api/src/auth/hybrid-auth.guard.ts @@ -72,6 +72,11 @@ export class HybridAuthGuard implements CanActivate { request.isServiceToken = false; request.isPlatformAdmin = false; request.apiKeyScopes = result.scopes; + // Surface the key's id + name on the request so downstream attribution + // (ActingUserResolver, audit logs) can record "via API key ''" + // without an extra DB lookup. + request.apiKeyId = result.apiKeyId; + request.apiKeyName = result.apiKeyName; // API keys are organization-scoped and are not tied to a specific user/member. request.userRoles = null; diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts index 2e0b604d7d..2e89f19102 100644 --- a/apps/api/src/auth/types.ts +++ b/apps/api/src/auth/types.ts @@ -15,6 +15,8 @@ export interface AuthenticatedRequest extends Request { memberId?: string; // Member ID for assignment filtering (only available for session auth) memberDepartment?: Departments; // Member department for visibility filtering (only available for session auth) apiKeyScopes?: string[]; // Scopes for API key auth (empty = legacy full access) + apiKeyId?: string; // ApiKey row id — only set for API key auth. Used by ActingUserResolver / audit log attribution. + apiKeyName?: string; // Human-readable API key name (e.g. "CI Pipeline") — only set for API key auth. impersonatedBy?: string; // User ID of the admin who initiated impersonation (only set during impersonation sessions) sessionId?: string; // Session ID (only set for session auth) sessionDeviceAgent?: boolean; // Whether the session is a device-agent session (only set for session auth) diff --git a/apps/api/src/cloud-security/ai-remediation.prompt.ts b/apps/api/src/cloud-security/ai-remediation.prompt.ts index d4bd3d1eaf..f179d32ef1 100644 --- a/apps/api/src/cloud-security/ai-remediation.prompt.ts +++ b/apps/api/src/cloud-security/ai-remediation.prompt.ts @@ -178,6 +178,20 @@ A human will ALWAYS review your plan before execution. Be precise and correct. - Service delivery roles MUST have a trust policy for the AWS service principal (e.g., cloudtrail.amazonaws.com, config.amazonaws.com). - Service-linked roles (GuardDuty, Config, Inspector, Macie): use CreateServiceLinkedRole — AWS manages them. +## SERVICE-LINKED ROLE AWSServiceName VALUES (MANDATORY) +When emitting iam:CreateServiceLinkedRoleCommand you MUST populate the +AWSServiceName param. Leaving it null or empty causes AWS to reject the +call with "Member must not be null". Use exactly these values: +- AWS Config → "config.amazonaws.com" +- GuardDuty → "guardduty.amazonaws.com" +- Inspector v2 → "inspector2.amazonaws.com" +- Macie → "macie.amazonaws.com" +- IAM Access Analyzer → "access-analyzer.amazonaws.com" +- Security Hub → "securityhub.amazonaws.com" +- Detective → "detective.amazonaws.com" +- AWS Backup → "backup.amazonaws.com" +NEVER omit AWSServiceName, leave it as null, or use a placeholder string. + ## NAMING CONVENTIONS FOR NEW RESOURCES (FOLLOW EXACTLY) - S3 bucket names MUST: be lowercase only, no underscores, 3-63 chars, globally unique - Format: compai-{purpose}-{accountId}-{region} (e.g., compai-cloudtrail-013388577167-us-east-1) diff --git a/apps/api/src/cloud-security/ai-remediation.service.spec.ts b/apps/api/src/cloud-security/ai-remediation.service.spec.ts index f4c821b3eb..aa0774e67f 100644 --- a/apps/api/src/cloud-security/ai-remediation.service.spec.ts +++ b/apps/api/src/cloud-security/ai-remediation.service.spec.ts @@ -8,7 +8,7 @@ jest.mock('ai', () => ({ generateObject: jest.fn(), })); -import type { FixPlan } from './ai-remediation.prompt'; +import type { AwsCommandStep, FixPlan } from './ai-remediation.prompt'; import { AiRemediationService } from './ai-remediation.service'; // `enrichEmptyState` isn't exported — exercise it through the service's @@ -69,12 +69,11 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => { }); }); - it('leaves both states empty when AI returns {}/{} for an update-style plan (no Create* commands)', async () => { - // Previously this test asserted the backstop fabricated - // `{ exists: false }` / `{ exists: true }` even for updates, which - // misrepresented the diff in the UI ("we'll create it" when the truth - // was "we'll update the existing one"). The backstop now only fires - // when at least one `Create*` command is present. + it('emits configured:false → configured:true with willChange for update/configure-style plans (Bug B fix)', async () => { + // Customers reported the Auto-Remediate dialog showed `{} → {}` for + // findings whose fix is a configure-only flow (Put*/Start*/Update*). + // The backstop now derives a meaningful diff from the actionable + // steps instead of leaving both states blank. generateObjectMock.mockResolvedValueOnce({ object: basePlan({ fixSteps: [ @@ -95,6 +94,75 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => { evidence: {}, }); + expect(plan.currentState).toEqual({ configured: false }); + expect(plan.proposedState).toEqual({ + configured: true, + willChange: ['iam:AccountPasswordPolicy'], + }); + }); + + it('emits configured:false → configured:true for the Config-recorder fix (Put + Start, no Create)', async () => { + // The exact plan shape that caused the customer-reported `{} → {}` + // bug on "AWS Config recorder not configured". No Create* steps; + // only Put*/Start*. Backstop now produces a meaningful diff. + generateObjectMock.mockResolvedValueOnce({ + object: basePlan({ + fixSteps: [ + { service: 'iam', command: 'CreateServiceLinkedRoleCommand', params: { AWSServiceName: 'config.amazonaws.com' }, purpose: 'Create SLR for AWS Config' }, + { service: 'config-service', command: 'PutConfigurationRecorderCommand', params: {}, purpose: 'Create recorder' }, + { service: 'config-service', command: 'PutDeliveryChannelCommand', params: {}, purpose: 'Configure delivery' }, + { service: 'config-service', command: 'StartConfigurationRecorderCommand', params: {}, purpose: 'Start recorder' }, + ], + }), + }); + + const service = new AiRemediationService(); + const plan = await service.generateFixPlan({ + title: 'AWS Config recorder not configured', + description: null, + severity: 'high', + resourceType: 'AwsAccount', + resourceId: 'account-level', + remediation: null, + findingKey: 'config-no-recorder', + evidence: {}, + }); + + // CreateServiceLinkedRoleCommand IS a Create — so the plan is still + // treated as create-from-scratch (the SLR step). The willCreate list + // surfaces only the resource being created (iam:ServiceLinkedRole), + // and the Put/Start configure steps drop into the diff via the + // create-from-scratch path. + expect(plan.currentState).toEqual({ exists: false }); + expect(plan.proposedState).toEqual({ + exists: true, + willCreate: ['iam:ServiceLinkedRole'], + }); + }); + + it('leaves the plan untouched when AI returns {}/{} but the plan has no actionable steps', async () => { + // Verify-only plans (only readSteps) should still be left alone — + // we never fabricate state when there's nothing to act on. + generateObjectMock.mockResolvedValueOnce({ + object: basePlan({ + readSteps: [ + { service: 's3', command: 'GetBucketVersioningCommand', params: {}, purpose: 'check' }, + ], + }), + }); + + const service = new AiRemediationService(); + const plan = await service.generateFixPlan({ + title: 'Read-only', + description: null, + severity: null, + resourceType: 'X', + resourceId: 'y', + remediation: null, + findingKey: 'fk-readonly', + evidence: {}, + }); + expect(plan.currentState).toEqual({}); expect(plan.proposedState).toEqual({}); }); @@ -123,6 +191,40 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => { expect(plan.proposedState).toEqual({ versioning: 'Enabled' }); }); + it('runs normalizeFixPlan after enrichEmptyState — SLR AWSServiceName is backfilled (Bug A fix)', async () => { + // The AI sometimes omits AWSServiceName on CreateServiceLinkedRoleCommand, + // which AWS rejects with "Member must not be null". The service must + // wire normalizeFixPlan after enrichEmptyState so the param is + // backfilled from cross-step context before the plan reaches the UI + // or the executor. + generateObjectMock.mockResolvedValueOnce({ + object: basePlan({ + currentState: { recorder: 'not configured' }, + proposedState: { recorder: 'configured' }, + fixSteps: [ + { service: 'iam', command: 'CreateServiceLinkedRoleCommand', params: {}, purpose: 'Create SLR for AWS Config' }, + { service: 'config-service', command: 'PutConfigurationRecorderCommand', params: { ConfigurationRecorder: {} }, purpose: 'Create recorder' }, + ], + }), + }); + + const service = new AiRemediationService(); + const plan = await service.generateFixPlan({ + title: 'AWS Config recorder not configured', + description: null, + severity: 'high', + resourceType: 'AwsAccount', + resourceId: 'account-level', + remediation: null, + findingKey: 'config-no-recorder', + evidence: {}, + }); + + expect(plan.fixSteps[0].params).toEqual({ + AWSServiceName: 'config.amazonaws.com', + }); + }); + it('leaves a plan alone when only one side is empty (legitimate verify-only case)', async () => { generateObjectMock.mockResolvedValueOnce({ object: basePlan({ @@ -147,3 +249,153 @@ describe('AiRemediationService.generateFixPlan empty-state backstop', () => { expect(plan.proposedState).toEqual({}); }); }); + +describe('AiRemediationService.refineStepFromError', () => { + const generateObjectMock = generateObject as unknown as jest.Mock; + + const findingContext = { + title: 'AWS Config recorder not configured', + description: 'Config recorder is missing', + severity: 'high', + resourceType: 'AwsAccount', + resourceId: 'account-level', + remediation: 'Create configuration recorder', + findingKey: 'config-no-recorder', + evidence: { awsAccountId: '123456789012', region: 'us-east-1' }, + }; + + function makeStep(overrides: Partial = {}): AwsCommandStep { + return { + service: overrides.service ?? 'iam', + command: overrides.command ?? 'CreateServiceLinkedRoleCommand', + params: overrides.params ?? {}, + purpose: overrides.purpose ?? 'create SLR', + }; + } + + beforeEach(() => { + generateObjectMock.mockReset(); + }); + + it('returns the refined step when AI proposes corrected params for the same command', async () => { + const refined: AwsCommandStep = { + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: 'config.amazonaws.com' }, + purpose: 'create SLR for AWS Config', + }; + generateObjectMock.mockResolvedValueOnce({ object: refined }); + + const result = await new AiRemediationService().refineStepFromError({ + step: makeStep(), + awsError: + "1 validation error detected: Value at 'aWSServiceName' failed to satisfy constraint: Member must not be null", + finding: findingContext, + planContext: { fixSteps: [], readSteps: [] }, + }); + + expect(result).toEqual(refined); + }); + + it('returns null when the AI swaps to a different service (defensive — refusing to retry a different API)', async () => { + generateObjectMock.mockResolvedValueOnce({ + object: { + service: 'config-service', // ← different from original 'iam' + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: 'config.amazonaws.com' }, + purpose: 'mismatched service', + }, + }); + + const result = await new AiRemediationService().refineStepFromError({ + step: makeStep({ service: 'iam' }), + awsError: 'Member must not be null', + finding: findingContext, + planContext: { fixSteps: [], readSteps: [] }, + }); + + expect(result).toBeNull(); + }); + + it('returns null when the AI swaps to a different command (defensive)', async () => { + generateObjectMock.mockResolvedValueOnce({ + object: { + service: 'iam', + command: 'CreateRoleCommand', // ← different from original + params: { RoleName: 'foo' }, + purpose: 'mismatched command', + }, + }); + + const result = await new AiRemediationService().refineStepFromError({ + step: makeStep({ command: 'CreateServiceLinkedRoleCommand' }), + awsError: 'Member must not be null', + finding: findingContext, + planContext: { fixSteps: [], readSteps: [] }, + }); + + expect(result).toBeNull(); + }); + + it('returns null when the AI call throws — caller surfaces the original error', async () => { + generateObjectMock.mockRejectedValueOnce(new Error('AI provider down')); + + const result = await new AiRemediationService().refineStepFromError({ + step: makeStep(), + awsError: 'Member must not be null', + finding: findingContext, + planContext: { fixSteps: [], readSteps: [] }, + }); + + expect(result).toBeNull(); + }); + + it('passes the failing step, AWS error, and finding context to the model in the prompt', async () => { + generateObjectMock.mockResolvedValueOnce({ + object: { + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: 'guardduty.amazonaws.com' }, + purpose: 'fixed', + }, + }); + + await new AiRemediationService().refineStepFromError({ + step: makeStep({ + command: 'CreateServiceLinkedRoleCommand', + params: {}, + purpose: 'create SLR for GuardDuty', + }), + awsError: + "1 validation error detected: Value at 'aWSServiceName' failed to satisfy constraint: Member must not be null", + finding: findingContext, + planContext: { + fixSteps: [ + { + service: 'guardduty', + command: 'CreateDetectorCommand', + params: { Enable: true }, + purpose: 'enable detector', + }, + ], + readSteps: [], + }, + }); + + const callArgs = generateObjectMock.mock.calls[0][0]; + // The system prompt should make the role clear. + expect(callArgs.system).toMatch(/repair/i); + expect(callArgs.system).toMatch(/SAME service/); + expect(callArgs.system).toMatch(/SAME command/); + // The user prompt should include the failing AWS error verbatim. + expect(callArgs.prompt).toContain( + "Value at 'aWSServiceName' failed to satisfy constraint", + ); + // ... and the failing command name. + expect(callArgs.prompt).toContain('CreateServiceLinkedRoleCommand'); + // ... and the neighbor step's service so the AI can use cross-step context. + expect(callArgs.prompt).toContain('guardduty'); + // Temperature is 0 for deterministic repair. + expect(callArgs.temperature).toBe(0); + }); +}); diff --git a/apps/api/src/cloud-security/ai-remediation.service.ts b/apps/api/src/cloud-security/ai-remediation.service.ts index be81aa9424..134695b829 100644 --- a/apps/api/src/cloud-security/ai-remediation.service.ts +++ b/apps/api/src/cloud-security/ai-remediation.service.ts @@ -6,6 +6,7 @@ import { type PermissionFix, type AwsCommandStep, fixPlanSchema, + awsCommandStepSchema, permissionFixSchema, completePermissionsSchema, SYSTEM_PROMPT, @@ -24,6 +25,7 @@ import { AZURE_SYSTEM_PROMPT, buildAzureFixPlanPrompt, } from './azure-ai-remediation.prompt'; +import { normalizeFixPlan } from './plan-normalizer'; const MODEL = anthropic('claude-opus-4-6'); const REMEDIATION_ROLE_NAME = 'CompAI-Remediator'; @@ -57,7 +59,7 @@ export class AiRemediationService { this.logger.log( `AI plan for ${finding.findingKey}: canAutoFix=${object.canAutoFix}, risk=${object.risk}`, ); - return enrichEmptyState(object); + return normalizeFixPlan(enrichEmptyState(object)); } catch (err) { this.logger.error( `AI plan failed: ${err instanceof Error ? err.message : String(err)}`, @@ -99,13 +101,13 @@ Generate the complete fix plan with EXACT values from the real AWS state.`, }); this.logger.log(`AI refined plan for ${params.finding.findingKey}`); - return enrichEmptyState(object); + return normalizeFixPlan(enrichEmptyState(object)); } catch (err) { this.logger.error( `AI refine failed: ${err instanceof Error ? err.message : String(err)}`, ); // Fall back to original plan - return enrichEmptyState(params.originalPlan); + return normalizeFixPlan(enrichEmptyState(params.originalPlan)); } } @@ -225,6 +227,97 @@ OVERESTIMATE. Better to have 5 extra permissions than to miss one.`, } } + /** + * Universal step-level repair. Called by the executor when AWS rejects + * a step with a validation-class error AND the rules-based fixer in + * `tryAutoFixValidationError` couldn't resolve it. + * + * The AI sees the failing step, AWS's exact error message, the + * surrounding plan, and the finding context, and returns a corrected + * step (same service + command, refined params). Returns null when the + * model declines to refine — the executor then surfaces AWS's error + * unchanged. + * + * This is the universal escape hatch for "AI omitted a required AWS + * param" bugs: no per-command map, no hardcoded principal table — the + * AI uses AWS's own validation message as ground truth. + */ + async refineStepFromError(params: { + step: AwsCommandStep; + awsError: string; + finding: FindingContext; + planContext: Pick; + }): Promise { + try { + const neighbors = [ + ...params.planContext.readSteps.map((s) => ({ role: 'read', ...s })), + ...params.planContext.fixSteps.map((s) => ({ role: 'fix', ...s })), + ].filter((s) => s.command !== params.step.command || s.purpose !== params.step.purpose); + + const { object } = await generateObject({ + model: MODEL, + schema: awsCommandStepSchema, + system: + 'You are repairing a single AWS auto-remediation step that the AWS SDK rejected with a validation error. Return a corrected step with the SAME service and SAME command — only the params should change. Use the AWS error message to identify exactly which field is wrong and why. Use neighbor steps and the finding context to infer the right value. If you cannot fix it without external information, return the original step unchanged.', + prompt: `FAILING STEP: +service: ${params.step.service} +command: ${params.step.command} +purpose: ${params.step.purpose} +params: ${JSON.stringify(params.step.params ?? {}, null, 2)} + +AWS SDK ERROR (verbatim): +${params.awsError} + +OTHER STEPS IN THE SAME PLAN (for context — DO NOT include their params in the output): +${JSON.stringify(neighbors, null, 2)} + +FINDING BEING FIXED: +title: ${params.finding.title} +description: ${params.finding.description ?? '(none)'} +resourceType: ${params.finding.resourceType} +resourceId: ${params.finding.resourceId} +remediation guidance: ${params.finding.remediation ?? '(none)'} +evidence: ${JSON.stringify(params.finding.evidence ?? {}, null, 2)} + +INSTRUCTIONS: +1. Read the AWS error carefully — it tells you which field is wrong. +2. If the error says "Member must not be null" or "must not be empty" for a field X, populate X with the correct value (from finding evidence, neighbor steps, or AWS conventions like service principals). +3. If the error says "failed to satisfy constraint" or "regular expression pattern", fix the value to match. +4. Keep the same service and command — do not switch to a different API. +5. Return a complete AwsCommandStep with all required schema fields.`, + temperature: 0, + }); + + this.logger.log( + `Step repair for ${params.step.command}: returned ${JSON.stringify( + object.params ?? {}, + ).slice(0, 200)}`, + ); + + // Defensive: if the AI swapped the service or command (against + // instructions), discard the refinement — the executor would + // reject it anyway and we don't want to retry with a different API. + if ( + object.service !== params.step.service || + object.command !== params.step.command + ) { + this.logger.warn( + `Step repair returned a different service/command — ` + + `expected ${params.step.service}:${params.step.command}, got ` + + `${object.service}:${object.command}. Discarding refinement.`, + ); + return null; + } + + return object; + } catch (err) { + this.logger.error( + `AI step repair failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return null; + } + } + // ─── GCP Methods ────────────────────────────────────────────────────── async generateGcpFixPlan(finding: FindingContext): Promise { @@ -445,14 +538,32 @@ Generate the complete fix plan with EXACT values from the real Azure state.`, } } +/** Action-style prefixes the AI uses for steps that change AWS state. */ +const ACTIONABLE_PREFIXES = [ + 'Create', + 'Put', + 'Update', + 'Modify', + 'Start', + 'Enable', + 'Attach', + 'Set', +] as const; + /** * Deterministic backstop: if the AI returns BOTH currentState and - * proposedState as empty (which is what was rendering as `{} → {}` in the - * Auto-Remediate dialog for "No CloudTrail trails configured" and similar - * create-from-scratch findings), fill in a basic `{ exists: false }` → - * `{ exists: true, willCreate: [...] }` derived from the plan's Create* - * fix steps. Guarantees the user sees a meaningful diff regardless of - * model behavior. + * proposedState as empty (rendering as `{} → {}` in the Auto-Remediate + * dialog), derive a meaningful diff from the plan's actionable fix steps. + * + * - When the plan contains `Create*` commands, emit + * `{ exists: false }` → `{ exists: true, willCreate: [...] }` so the UI + * keeps the existing create-from-scratch language for findings like + * "No CloudTrail trails configured". + * - When the plan contains only configure/enable-style commands + * (`PutConfigurationRecorderCommand`, `StartConfigurationRecorderCommand`, + * `EnableMacieCommand`, ...), emit + * `{ configured: false }` → `{ configured: true, willChange: [...] }` + * instead of claiming creation. * * Only kicks in when BOTH states are empty — verify-only plans that * legitimately have one side blank are untouched. @@ -463,26 +574,47 @@ function enrichEmptyState(plan: FixPlan): FixPlan { if (!currentEmpty || !proposedEmpty) return plan; const willCreate: string[] = []; + const willChange: string[] = []; for (const step of plan.fixSteps ?? []) { const command = typeof step?.command === 'string' ? step.command : ''; - if (!command.startsWith('Create')) continue; - const resource = command.replace(/Command$/, '').replace(/^Create/, ''); + const prefix = ACTIONABLE_PREFIXES.find((p) => command.startsWith(p)); + if (!prefix) continue; + const resource = command.replace(/Command$/, '').replace(/^[A-Z][a-z]+/, ''); const label = step.service ? `${step.service}:${resource}` : resource; - if (!willCreate.includes(label)) willCreate.push(label); + if (prefix === 'Create') { + if (!willCreate.includes(label)) willCreate.push(label); + } else if (!willChange.includes(label)) { + willChange.push(label); + } + } + + // Create-from-scratch plan: emit exists:false → exists:true so the UI + // keeps the existing "we'll create this" language for findings like + // "No CloudTrail trails configured". Non-Create steps in the same plan + // (e.g., StartLoggingCommand on the just-created trail) are bundled + // into the resource being created, so we don't double-list them. + if (willCreate.length > 0) { + return { + ...plan, + currentState: { exists: false }, + proposedState: { exists: true, willCreate }, + }; + } + + // Pure configure / enable / update flow: surface what will change + // without claiming creation. This is the Bug B fix — previously plans + // with only Put*/Start*/Update* steps left both states empty. + if (willChange.length > 0) { + return { + ...plan, + currentState: { configured: false }, + proposedState: { configured: true, willChange }, + }; } - // Only enrich when we have actual `Create*` commands — otherwise we'd - // fabricate "exists: false → exists: true" for updates/reads (e.g. - // UpdateAccountPasswordPolicy), which misrepresents the diff in the UI. - // For non-create remediations we leave the AI's (admittedly empty) output - // alone rather than inventing state. - if (willCreate.length === 0) return plan; - - return { - ...plan, - currentState: { exists: false }, - proposedState: { exists: true, willCreate }, - }; + // No actionable steps detected — leave the plan alone rather than + // fabricating state. + return plan; } function isEmptyState( diff --git a/apps/api/src/cloud-security/aws-command-executor.spec.ts b/apps/api/src/cloud-security/aws-command-executor.spec.ts new file mode 100644 index 0000000000..572b8ac334 --- /dev/null +++ b/apps/api/src/cloud-security/aws-command-executor.spec.ts @@ -0,0 +1,181 @@ +import type { AwsCommandStep } from './ai-remediation.prompt'; +import { + REQUIRED_PARAMS, + looksLikeValidationError, + validatePlanSteps, +} from './aws-command-executor'; + +function step(overrides: Partial): AwsCommandStep { + return { + service: overrides.service ?? 's3', + command: overrides.command ?? 'PutBucketVersioningCommand', + params: overrides.params ?? {}, + purpose: overrides.purpose ?? 'test step', + }; +} + +/** + * Focused tests for the REQUIRED_PARAMS branch added to validatePlanSteps. + * The non-null-param checks defend against AWS's confusing + * "Member must not be null" errors by failing fast with a clear message. + */ +describe('validatePlanSteps — REQUIRED_PARAMS', () => { + it('reports a clear error when CreateServiceLinkedRoleCommand is missing AWSServiceName', () => { + const errors = validatePlanSteps([ + step({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + ]); + expect(errors).toEqual( + expect.arrayContaining([ + expect.stringMatching( + /Step 1 \(CreateServiceLinkedRoleCommand\): Required param "AWSServiceName" is missing or empty/, + ), + ]), + ); + }); + + it('does NOT error when AWSServiceName is populated', () => { + const errors = validatePlanSteps([ + step({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: 'config.amazonaws.com' }, + }), + ]); + expect( + errors.filter((e) => e.includes('AWSServiceName')), + ).toHaveLength(0); + }); + + it.each(['', null, undefined])( + 'treats %p as missing for required-param checks', + (badValue) => { + const errors = validatePlanSteps([ + step({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: badValue }, + }), + ]); + expect( + errors.some((e) => + /Required param "AWSServiceName" is missing or empty/.test(e), + ), + ).toBe(true); + }, + ); + + it('reports both missing required params for PutBucketPolicyCommand', () => { + const errors = validatePlanSteps([ + step({ + service: 's3', + command: 'PutBucketPolicyCommand', + params: {}, + }), + ]); + expect( + errors.filter((e) => /Required param "Bucket"/.test(e)), + ).toHaveLength(1); + expect( + errors.filter((e) => /Required param "Policy"/.test(e)), + ).toHaveLength(1); + }); + + it('does NOT apply required-param checks to commands not in REQUIRED_PARAMS', () => { + // PutBucketVersioningCommand isn't in REQUIRED_PARAMS — should pass + // even with no params (the AWS SDK will surface its own errors then). + const errors = validatePlanSteps([ + step({ + service: 's3', + command: 'PutBucketVersioningCommand', + params: {}, + }), + ]); + // It might still error on other things (e.g., placeholder check), + // but it must NOT report a "Required param" error. + expect(errors.some((e) => /Required param /.test(e))).toBe(false); + }); + + it('uses the step index in the error message so customers know which step is broken', () => { + const errors = validatePlanSteps([ + step({ service: 's3', command: 'PutBucketVersioningCommand', params: { Bucket: 'b', VersioningConfiguration: { Status: 'Enabled' } } }), + step({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + ]); + expect( + errors.find((e) => + e.startsWith('Step 2 (CreateServiceLinkedRoleCommand)'), + ), + ).toBeDefined(); + }); + + it('exports a REQUIRED_PARAMS map that includes the SLR + Config-recorder bug-report commands', () => { + expect(REQUIRED_PARAMS.CreateServiceLinkedRoleCommand).toEqual([ + 'AWSServiceName', + ]); + expect(REQUIRED_PARAMS.PutConfigurationRecorderCommand).toContain( + 'ConfigurationRecorder', + ); + expect(REQUIRED_PARAMS.StartConfigurationRecorderCommand).toContain( + 'ConfigurationRecorderName', + ); + expect(REQUIRED_PARAMS.PutDeliveryChannelCommand).toContain( + 'DeliveryChannel', + ); + }); +}); + +describe('looksLikeValidationError', () => { + it.each([ + "1 validation error detected: Value at 'aWSServiceName' failed to satisfy constraint: Member must not be null", + 'ValidationException: The Bucket parameter is required', + 'InvalidParameterValue: Value (foo) for parameter X is invalid', + 'Member must not be null', + 'failed to satisfy constraint: Member must have length less than or equal to 64', + 'Missing required parameter Bucket', + 'is required', + 'must be a valid ARN', + ])('detects %p as a validation-class error', (msg) => { + expect(looksLikeValidationError(msg)).toBe(true); + }); + + it.each([ + 'AccessDeniedException: User is not authorized to perform iam:CreateRole', + 'ThrottlingException: Rate exceeded', + 'ResourceNotFoundException: detector not found', + 'NoSuchBucket: The specified bucket does not exist', + '', + ])('rejects %p as a non-validation error', (msg) => { + expect(looksLikeValidationError(msg)).toBe(false); + }); +}); + +describe('validatePlanSteps — pre-existing behavior preserved', () => { + it('still reports unknown services', () => { + const errors = validatePlanSteps([ + step({ service: 'not-a-real-service', command: 'WhateverCommand' }), + ]); + expect( + errors.find((e) => /Unknown service "not-a-real-service"/.test(e)), + ).toBeDefined(); + }); + + it('still reports placeholder values', () => { + const errors = validatePlanSteps([ + step({ + service: 's3', + command: 'PutBucketVersioningCommand', + params: { Bucket: '{{BUCKET_NAME}}' }, + }), + ]); + expect( + errors.find((e) => /Contains placeholder values/.test(e)), + ).toBeDefined(); + }); +}); diff --git a/apps/api/src/cloud-security/aws-command-executor.ts b/apps/api/src/cloud-security/aws-command-executor.ts index 8964cf58df..88d75bcd77 100644 --- a/apps/api/src/cloud-security/aws-command-executor.ts +++ b/apps/api/src/cloud-security/aws-command-executor.ts @@ -149,6 +149,24 @@ const JSON_STRING_PARAMS = new Set([ 'Definition', ]); +/** + * Commands where AWS rejects the call with a confusing + * "Member must not be null" error if a top-level param is missing. + * We surface a clear, actionable error BEFORE the SDK call so the + * remediation pipeline fails fast and the customer sees what's wrong. + * + * Keep this list narrow — only commands where we've seen the AI omit a + * required param in practice and the resulting AWS error is unhelpful. + */ +export const REQUIRED_PARAMS: Record = { + CreateServiceLinkedRoleCommand: ['AWSServiceName'], + PutConfigurationRecorderCommand: ['ConfigurationRecorder'], + PutDeliveryChannelCommand: ['DeliveryChannel'], + StartConfigurationRecorderCommand: ['ConfigurationRecorderName'], + PutBucketPolicyCommand: ['Bucket', 'Policy'], + CreateTrailCommand: ['Name', 'S3BucketName'], +}; + function normalizeArnPartition(value: string, partition: AwsPartition): string { if (partition === 'aws-us-gov') { return value.replace(/\barn:aws:/g, 'arn:aws-us-gov:'); @@ -372,6 +390,32 @@ function isValidationError(errName: string, errMsg: string): boolean { ); } +/** + * Message-only detector for the broader class of validation-style errors + * that AWS may surface after the rules-based retry inside + * `sendWithAutoRetry` has given up. Used by `executePlanSteps` to decide + * whether to invoke the AI step-repair callback. + * + * Universal by design — pattern-matches AWS's standard error wording + * rather than enumerating per-command error names. + */ +export function looksLikeValidationError(message: string): boolean { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes('validationexception') || + lower.includes('validation error') || + lower.includes('failed to satisfy constraint') || + lower.includes('member must not be null') || + lower.includes('invalidparametervalue') || + lower.includes('invalidparameter ') || + lower.includes('invalid parameter') || + lower.includes('must be a valid') || + lower.includes('is required') || + lower.includes('missing required') + ); +} + /** * Parse the AWS validation error, fix the offending param, return true if fixed. * AWS error format: "Value at 'fieldName' failed to satisfy constraint: ..." @@ -481,6 +525,21 @@ export function validatePlanSteps(steps: AwsCommandStep[]): string[] { `${prefix}: Contains placeholder values: ${placeholders.join(', ')}`, ); } + + // Check required top-level params for commands AWS rejects with + // cryptic "Member must not be null" errors. Fail fast with a clear + // message instead of letting the SDK return its uninformative one. + const required = REQUIRED_PARAMS[step.command]; + if (required) { + for (const key of required) { + const value = step.params?.[key]; + if (value === undefined || value === null || value === '') { + errors.push( + `${prefix}: Required param "${key}" is missing or empty`, + ); + } + } + } } return errors; @@ -496,6 +555,27 @@ export interface PlanExecutionResult { error?: { stepIndex: number; message: string; step: AwsCommandStep }; } +/** + * Optional per-step repair callback. Invoked at most ONCE per step when: + * 1. The SDK call failed with a validation-class error + * (`looksLikeValidationError`), AND + * 2. The existing rules-based `tryAutoFixValidationError` could not fix it. + * + * The callback is expected to return a refined `AwsCommandStep` to retry + * with, or `null` if it cannot repair the step. Returning the same step + * unchanged also counts as "cannot repair" (we won't loop forever). + * + * Universal contract — the callback decides HOW to refine (typically by + * asking an LLM with the failing step + AWS error + plan context). The + * executor only cares about the in/out shape, so this scales to any + * future AWS command without per-command changes here. + */ +export type StepRepairFn = (args: { + step: AwsCommandStep; + awsError: string; + stepIndex: number; +}) => Promise; + /** * Execute a single AWS SDK v3 command. * Uses static imports — no dynamic require, no version mismatches. @@ -607,22 +687,91 @@ export async function executePlanSteps(params: { region: string; isRollback?: boolean; autoRollbackSteps?: AwsCommandStep[]; + repairStep?: StepRepairFn; }): Promise { const results: StepResult[] = []; for (let i = 0; i < params.steps.length; i++) { - const step = params.steps[i]; - try { - const output = await executeAwsCommand({ - service: step.service, - command: step.command, - input: structuredClone(step.params), - credentials: params.credentials, - region: params.region, - isRollback: params.isRollback, - }); - results.push({ step, output }); - } catch (err) { + const originalStep = params.steps[i]; + let stepToRun = originalStep; + let repairAttempted = false; + let success = false; + let lastError: Error | null = null; + + // Inner attempt loop: 1 initial attempt + at most 1 AI repair retry. + // The AI repair fires only on validation-class errors AND only when + // a repair callback is provided. This keeps reads/rollbacks/no-AI + // call sites at the same single-attempt behavior they had before. + for (let attempt = 0; attempt < 2; attempt++) { + try { + const output = await executeAwsCommand({ + service: stepToRun.service, + command: stepToRun.command, + input: structuredClone(stepToRun.params), + credentials: params.credentials, + region: params.region, + isRollback: params.isRollback, + }); + results.push({ step: stepToRun, output }); + success = true; + lastError = null; + break; + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)); + const message = lastError.message; + + if ( + !repairAttempted && + params.repairStep && + looksLikeValidationError(message) + ) { + repairAttempted = true; + console.log( + `Step ${i + 1} (${originalStep.service}:${originalStep.command}) ` + + `failed with validation error — attempting AI step repair`, + ); + const refined = await params.repairStep({ + step: originalStep, + awsError: message, + stepIndex: i, + }); + if ( + refined && + JSON.stringify(refined.params ?? {}) !== + JSON.stringify(originalStep.params ?? {}) + ) { + console.log( + `AI returned refined step for ${originalStep.command} — retrying once`, + ); + stepToRun = refined; + continue; + } + console.log( + `AI repair returned no change for ${originalStep.command} — ` + + `surfacing original AWS error`, + ); + } + break; + } + } + + if (success) continue; + + // lastError must be set when !success — keep TS happy with a guard. + if (!lastError) { + return { + results, + error: { + stepIndex: i, + message: 'Unknown execution failure', + step: originalStep, + }, + }; + } + + const step = stepToRun; + const err: unknown = lastError; + { const message = err instanceof Error ? err.message : String(err); // If a prior step was a no-op (already exists / duplicate content), diff --git a/apps/api/src/cloud-security/aws-scan-mode.service.spec.ts b/apps/api/src/cloud-security/aws-scan-mode.service.spec.ts new file mode 100644 index 0000000000..157b8bfc8a --- /dev/null +++ b/apps/api/src/cloud-security/aws-scan-mode.service.spec.ts @@ -0,0 +1,131 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; + +// Mock @db before importing the service so the Prisma client doesn't try +// to connect at import time in this unit-test env. +const mockDb = { + integrationConnection: { + findFirst: jest.fn(), + update: jest.fn(), + }, +}; +jest.mock('@db', () => ({ db: mockDb })); + +// Mock the activity logger so we can assert on the description it receives. +const mockAuditLog = jest.fn().mockResolvedValue(undefined); +jest.mock('./cloud-security-audit', () => ({ + logCloudSecurityActivity: mockAuditLog, +})); + +import { CloudAwsScanModeService } from './aws-scan-mode.service'; + +function buildService() { + return new CloudAwsScanModeService(); +} + +function withAwsConnection(opts: { currentMode?: string } = {}) { + mockDb.integrationConnection.findFirst.mockResolvedValueOnce({ + id: 'icn_aws', + metadata: opts.currentMode ? { awsScanMode: opts.currentMode } : {}, + provider: { slug: 'aws' }, + }); +} + +describe('CloudAwsScanModeService.updateMode', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns 403 when the connection does not exist / belongs to a different org', async () => { + mockDb.integrationConnection.findFirst.mockResolvedValueOnce(null); + + await expect( + buildService().updateMode({ + connectionId: 'icn_missing', + organizationId: 'org_1', + userId: 'usr_1', + mode: 'security_hub', + }), + ).rejects.toThrow(ForbiddenException); + }); + + it('returns 400 when the connection is not AWS', async () => { + mockDb.integrationConnection.findFirst.mockResolvedValueOnce({ + id: 'icn_gcp', + metadata: {}, + provider: { slug: 'gcp' }, + }); + + await expect( + buildService().updateMode({ + connectionId: 'icn_gcp', + organizationId: 'org_1', + userId: 'usr_1', + mode: 'security_hub', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('is idempotent when the target mode equals the current mode', async () => { + withAwsConnection({ currentMode: 'security_hub' }); + + const result = await buildService().updateMode({ + connectionId: 'icn_aws', + organizationId: 'org_1', + userId: 'usr_1', + mode: 'security_hub', + }); + + expect(result).toEqual({ mode: 'security_hub' }); + expect(mockDb.integrationConnection.update).not.toHaveBeenCalled(); + expect(mockAuditLog).not.toHaveBeenCalled(); + }); + + it('writes the new mode + audit log on a successful switch (session call)', async () => { + withAwsConnection({ currentMode: 'comp_scanners' }); + + await buildService().updateMode({ + connectionId: 'icn_aws', + organizationId: 'org_1', + userId: 'usr_human', + mode: 'security_hub', + }); + + expect(mockDb.integrationConnection.update).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'icn_aws' }, + data: expect.objectContaining({ + metadata: expect.objectContaining({ awsScanMode: 'security_hub' }), + }), + }), + ); + const auditCall = mockAuditLog.mock.calls[0][0]; + expect(auditCall.action).toBe('scan_mode_changed'); + // No bracket prefix when callerLabel is undefined (session call). + expect(auditCall.description).toMatch(/^Switched AWS scan engine: /); + expect(auditCall.metadata.callerLabel).toBeNull(); + }); + + it('prepends callerLabel to the audit description when set (API key call)', async () => { + // Regression guard for the fix: when an API key triggers this mutation + // via ActingUserResolver's owner-fallback, the audit description must + // make the automation source visible — auditors otherwise see only the + // org owner's name and have no idea it was an automated change. + withAwsConnection({ currentMode: 'comp_scanners' }); + + await buildService().updateMode({ + connectionId: 'icn_aws', + organizationId: 'org_1', + userId: 'usr_owner', + mode: 'security_hub', + callerLabel: 'via API key "CI Pipeline"', + }); + + const auditCall = mockAuditLog.mock.calls[0][0]; + expect(auditCall.description).toMatch( + /^\[via API key "CI Pipeline"\] Switched AWS scan engine: /, + ); + expect(auditCall.metadata).toEqual( + expect.objectContaining({ callerLabel: 'via API key "CI Pipeline"' }), + ); + }); +}); diff --git a/apps/api/src/cloud-security/aws-scan-mode.service.ts b/apps/api/src/cloud-security/aws-scan-mode.service.ts index d8811eeadd..020a5e60d8 100644 --- a/apps/api/src/cloud-security/aws-scan-mode.service.ts +++ b/apps/api/src/cloud-security/aws-scan-mode.service.ts @@ -38,8 +38,13 @@ export class CloudAwsScanModeService { async updateMode(params: { connectionId: string; organizationId: string; + /** Session user, or org owner for API key / service token callers + * (resolved by ActingUserResolver in the controller). */ userId: string; mode: AwsScanMode; + /** Optional audit-log description prefix when userId came from + * owner-fallback (e.g. `via API key "CI Pipeline"`). */ + callerLabel?: string; }): Promise<{ mode: AwsScanMode }> { const connection = await db.integrationConnection.findFirst({ where: { @@ -84,15 +89,20 @@ export class CloudAwsScanModeService { }, }); + const description = params.callerLabel + ? `[${params.callerLabel}] Switched AWS scan engine: ${previousMode} → ${params.mode}` + : `Switched AWS scan engine: ${previousMode} → ${params.mode}`; + await logCloudSecurityActivity({ organizationId: params.organizationId, userId: params.userId, connectionId: connection.id, action: 'scan_mode_changed', - description: `Switched AWS scan engine: ${previousMode} → ${params.mode}`, + description, metadata: { previousMode, newMode: params.mode, + callerLabel: params.callerLabel ?? null, }, }); diff --git a/apps/api/src/cloud-security/cloud-security.controller.spec.ts b/apps/api/src/cloud-security/cloud-security.controller.spec.ts new file mode 100644 index 0000000000..a08dd6404b --- /dev/null +++ b/apps/api/src/cloud-security/cloud-security.controller.spec.ts @@ -0,0 +1,311 @@ +// Stub out the dependencies pulled in transitively by HybridAuthGuard so +// the controller can be imported in this unit-test env without booting +// Prisma or loading better-auth's ESM modules. We only test the +// controller's orchestration logic — the guards themselves are tested +// elsewhere. +jest.mock('@db', () => ({ db: {} })); +jest.mock('@trycompai/auth', () => ({ + statement: {}, + ac: { newRole: () => ({}) }, +})); +jest.mock('../auth/auth.server', () => ({ + auth: { api: { getSession: jest.fn() } }, +})); + +import { Test, TestingModule } from '@nestjs/testing'; +import { HttpException, HttpStatus } from '@nestjs/common'; + +import { CloudSecurityController } from './cloud-security.controller'; +import { CloudSecurityService } from './cloud-security.service'; +import { CloudSecurityQueryService } from './cloud-security-query.service'; +import { CloudSecurityLegacyService } from './cloud-security-legacy.service'; +import { CloudSecurityActivityService } from './cloud-security-activity.service'; +import { GCPSecurityService } from './providers/gcp-security.service'; +import { AzureSecurityService } from './providers/azure-security.service'; +import { CheckDefinitionService } from './check-definition.service'; +import { CloudExceptionService } from './exception.service'; +import { CloudHistoryService } from './history.service'; +import { CloudAwsScanModeService } from './aws-scan-mode.service'; +import { ActingUserResolver } from '../auth/acting-user.service'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import type { AuthenticatedRequest } from '../auth/types'; + +/** + * Controller-level tests for the 3 mutation endpoints that previously + * required session auth. They now accept API key + service token callers + * via the ActingUserResolver owner-fallback path. These tests lock in: + * + * 1. Session call → service called with req.userId, no callerLabel. + * 2. API key call (resolver returns org owner) → service called with the + * owner's userId + a callerLabel for the audit log description. + * 3. Org with no owner → 400 with the actionable error message. + * + * Guards are mocked to `canActivate: () => true` because they're tested + * elsewhere — these specs focus on the new orchestration logic added in + * this PR. + */ +describe('CloudSecurityController — API-key mutation support', () => { + let controller: CloudSecurityController; + let exceptionService: jest.Mocked; + let scanModeService: jest.Mocked; + let actingUser: jest.Mocked; + + const mockExceptionService = { + markAsException: jest.fn(), + revokeException: jest.fn(), + }; + const mockScanModeService = { + updateMode: jest.fn(), + }; + const mockActingUser = { + resolve: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [CloudSecurityController], + providers: [ + // Only mock the deps actually invoked by the methods under test. + // The rest are wired with empty stubs so Nest's DI can build the + // controller without complaining. + { provide: CloudSecurityService, useValue: {} }, + { provide: CloudSecurityQueryService, useValue: {} }, + { provide: CloudSecurityLegacyService, useValue: {} }, + { provide: CloudSecurityActivityService, useValue: {} }, + { provide: GCPSecurityService, useValue: {} }, + { provide: AzureSecurityService, useValue: {} }, + { provide: CheckDefinitionService, useValue: {} }, + { provide: CloudExceptionService, useValue: mockExceptionService }, + { provide: CloudHistoryService, useValue: {} }, + { provide: CloudAwsScanModeService, useValue: mockScanModeService }, + { provide: ActingUserResolver, useValue: mockActingUser }, + ], + }) + .overrideGuard(HybridAuthGuard) + .useValue(mockGuard) + .overrideGuard(PermissionGuard) + .useValue(mockGuard) + .compile(); + + controller = module.get(CloudSecurityController); + exceptionService = module.get(CloudExceptionService) as jest.Mocked; + scanModeService = module.get(CloudAwsScanModeService) as jest.Mocked; + actingUser = module.get(ActingUserResolver) as jest.Mocked; + + jest.clearAllMocks(); + }); + + function sessionReq(): AuthenticatedRequest { + return { + userId: 'usr_alice', + organizationId: 'org_1', + authType: 'session', + isApiKey: false, + isServiceToken: false, + } as unknown as AuthenticatedRequest; + } + + function apiKeyReq(): AuthenticatedRequest { + return { + userId: undefined, + organizationId: 'org_1', + authType: 'api-key', + isApiKey: true, + isServiceToken: false, + apiKeyId: 'apk_1', + apiKeyName: 'CI Pipeline', + } as unknown as AuthenticatedRequest; + } + + // ─── markFindingAsException ───────────────────────────────────────────── + + describe('markFindingAsException', () => { + const validBody = { + reason: 'Documented exception with twenty-plus non-whitespace characters here.', + reviewedBy: 'person@example.com', + }; + + it('passes req.userId through to the service for session callers (no callerLabel)', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: 'usr_alice', + source: 'session', + }); + exceptionService.markAsException.mockResolvedValueOnce({ id: 'fex_1' }); + + await controller.markFindingAsException( + 'icx_1', + validBody, + 'org_1', + sessionReq(), + ); + + expect(exceptionService.markAsException).toHaveBeenCalledWith( + expect.objectContaining({ + findingId: 'icx_1', + organizationId: 'org_1', + userId: 'usr_alice', + callerLabel: undefined, + }), + ); + }); + + it('uses the resolved owner + callerLabel for API key callers', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: 'usr_owner_carol', + source: 'org-owner-fallback', + callerLabel: 'via API key "CI Pipeline"', + }); + exceptionService.markAsException.mockResolvedValueOnce({ id: 'fex_2' }); + + await controller.markFindingAsException( + 'icx_1', + validBody, + 'org_1', + apiKeyReq(), + ); + + expect(actingUser.resolve).toHaveBeenCalledWith( + expect.objectContaining({ isApiKey: true }), + 'org_1', + ); + expect(exceptionService.markAsException).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'usr_owner_carol', + callerLabel: 'via API key "CI Pipeline"', + }), + ); + }); + + it('returns 400 with an actionable message when the org has no owner', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: null, + source: 'org-owner-fallback', + callerLabel: 'via API key "CI Pipeline"', + }); + + const error = await controller + .markFindingAsException('icx_1', validBody, 'org_1', apiKeyReq()) + .catch((e) => e); + + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect((error as HttpException).message).toMatch(/at least one user with the "owner" role/); + expect(exceptionService.markAsException).not.toHaveBeenCalled(); + }); + }); + + // ─── updateAwsScanMode ────────────────────────────────────────────────── + + describe('updateAwsScanMode', () => { + const validBody = { mode: 'security_hub' as const }; + + it('passes req.userId through to the service for session callers', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: 'usr_alice', + source: 'session', + }); + scanModeService.updateMode.mockResolvedValueOnce({ mode: 'security_hub' }); + + await controller.updateAwsScanMode('icn_aws', validBody, 'org_1', sessionReq()); + + expect(scanModeService.updateMode).toHaveBeenCalledWith( + expect.objectContaining({ + connectionId: 'icn_aws', + userId: 'usr_alice', + mode: 'security_hub', + callerLabel: undefined, + }), + ); + }); + + it('uses the resolved owner + callerLabel for API key callers', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: 'usr_owner', + source: 'org-owner-fallback', + callerLabel: 'via API key "CI Pipeline"', + }); + scanModeService.updateMode.mockResolvedValueOnce({ mode: 'security_hub' }); + + await controller.updateAwsScanMode('icn_aws', validBody, 'org_1', apiKeyReq()); + + expect(scanModeService.updateMode).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'usr_owner', + callerLabel: 'via API key "CI Pipeline"', + }), + ); + }); + + it('returns 400 when org has no owner', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: null, + source: 'org-owner-fallback', + }); + + const error = await controller + .updateAwsScanMode('icn_aws', validBody, 'org_1', apiKeyReq()) + .catch((e) => e); + + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(scanModeService.updateMode).not.toHaveBeenCalled(); + }); + }); + + // ─── revokeException ──────────────────────────────────────────────────── + + describe('revokeException', () => { + it('passes req.userId through to the service for session callers', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: 'usr_alice', + source: 'session', + }); + exceptionService.revokeException.mockResolvedValueOnce(undefined); + + await controller.revokeException('fex_1', 'org_1', sessionReq()); + + expect(exceptionService.revokeException).toHaveBeenCalledWith( + expect.objectContaining({ + exceptionId: 'fex_1', + userId: 'usr_alice', + callerLabel: undefined, + }), + ); + }); + + it('uses the resolved owner + callerLabel for API key callers', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: 'usr_owner', + source: 'org-owner-fallback', + callerLabel: 'via API key "CI Pipeline"', + }); + exceptionService.revokeException.mockResolvedValueOnce(undefined); + + await controller.revokeException('fex_1', 'org_1', apiKeyReq()); + + expect(exceptionService.revokeException).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'usr_owner', + callerLabel: 'via API key "CI Pipeline"', + }), + ); + }); + + it('returns 400 when org has no owner', async () => { + actingUser.resolve.mockResolvedValueOnce({ + userId: null, + source: 'org-owner-fallback', + }); + + const error = await controller + .revokeException('fex_1', 'org_1', apiKeyReq()) + .catch((e) => e); + + expect(error).toBeInstanceOf(HttpException); + expect((error as HttpException).getStatus()).toBe(HttpStatus.BAD_REQUEST); + expect(exceptionService.revokeException).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/cloud-security/cloud-security.controller.ts b/apps/api/src/cloud-security/cloud-security.controller.ts index a0286c4a0a..28ed2f1f0d 100644 --- a/apps/api/src/cloud-security/cloud-security.controller.ts +++ b/apps/api/src/cloud-security/cloud-security.controller.ts @@ -32,6 +32,8 @@ import { CloudAwsScanModeService } from './aws-scan-mode.service'; import { parseExceptionExpiry } from './exception-expiry.utils'; import { MarkExceptionDto } from './dto/mark-exception.dto'; import { UpdateAwsScanModeDto } from './dto/update-scan-mode.dto'; +import { ActingUserResolver } from '../auth/acting-user.service'; +import type { AuthenticatedRequest } from '../auth/types'; import { logCloudSecurityActivity } from './cloud-security-audit'; import { CloudSecurityActivityService } from './cloud-security-activity.service'; import { @@ -56,6 +58,7 @@ export class CloudSecurityController { private readonly exceptionService: CloudExceptionService, private readonly historyService: CloudHistoryService, private readonly scanModeService: CloudAwsScanModeService, + private readonly actingUser: ActingUserResolver, ) {} @Get('activity') @@ -114,26 +117,32 @@ export class CloudSecurityController { @ApiOperation({ summary: 'Mark a finding as an exception so it no longer appears in the active Scan Results list', + description: + 'Accepts session, API key, or service token auth. For API key / service token callers ' + + 'without an explicit user attribution, the action is attributed to the org\'s owner and ' + + 'the audit log description records the calling key/service name.', }) async markFindingAsException( @Param('findingId') findingId: string, @Body() body: MarkExceptionDto, @OrganizationId() organizationId: string, - @Req() req: { userId?: string }, + @Req() req: AuthenticatedRequest, ) { - if (!req.userId) { + const acting = await this.actingUser.resolve(req, organizationId); + if (!acting.userId) { throw new HttpException( - 'Marking an exception requires session authentication.', - HttpStatus.UNAUTHORIZED, + 'Cannot attribute this action — your organization must have at least one user with the "owner" role.', + HttpStatus.BAD_REQUEST, ); } const result = await this.exceptionService.markAsException({ findingId, organizationId, - userId: req.userId, + userId: acting.userId, reason: body.reason, reviewedBy: body.reviewedBy ?? null, expiresAt: parseExceptionExpiry(body.expiresAt), + callerLabel: acting.callerLabel, }); return { data: result }; } @@ -144,24 +153,29 @@ export class CloudSecurityController { @ApiOperation({ summary: 'Switch the AWS scan engine for a connection (Comp AI scanners ↔ Security Hub)', + description: + 'Accepts session, API key, or service token auth. For API key / service token callers ' + + 'without an explicit user attribution, the action is attributed to the org\'s owner.', }) async updateAwsScanMode( @Param('connectionId') connectionId: string, @Body() body: UpdateAwsScanModeDto, @OrganizationId() organizationId: string, - @Req() req: { userId?: string }, + @Req() req: AuthenticatedRequest, ) { - if (!req.userId) { + const acting = await this.actingUser.resolve(req, organizationId); + if (!acting.userId) { throw new HttpException( - 'Switching the scan engine requires session authentication.', - HttpStatus.UNAUTHORIZED, + 'Cannot attribute this action — your organization must have at least one user with the "owner" role.', + HttpStatus.BAD_REQUEST, ); } const result = await this.scanModeService.updateMode({ connectionId, organizationId, - userId: req.userId, + userId: acting.userId, mode: body.mode, + callerLabel: acting.callerLabel, }); return { data: result }; } @@ -169,22 +183,29 @@ export class CloudSecurityController { @Delete('exceptions/:exceptionId') @UseGuards(HybridAuthGuard, PermissionGuard) @RequirePermission('integration', 'update') - @ApiOperation({ summary: 'Revoke an exception, reopening the finding' }) + @ApiOperation({ + summary: 'Revoke an exception, reopening the finding', + description: + 'Accepts session, API key, or service token auth. For API key / service token callers ' + + 'without an explicit user attribution, the action is attributed to the org\'s owner.', + }) async revokeException( @Param('exceptionId') exceptionId: string, @OrganizationId() organizationId: string, - @Req() req: { userId?: string }, + @Req() req: AuthenticatedRequest, ) { - if (!req.userId) { + const acting = await this.actingUser.resolve(req, organizationId); + if (!acting.userId) { throw new HttpException( - 'Revoking an exception requires session authentication.', - HttpStatus.UNAUTHORIZED, + 'Cannot attribute this action — your organization must have at least one user with the "owner" role.', + HttpStatus.BAD_REQUEST, ); } await this.exceptionService.revokeException({ exceptionId, organizationId, - userId: req.userId, + userId: acting.userId, + callerLabel: acting.callerLabel, }); return { success: true }; } diff --git a/apps/api/src/cloud-security/exception.service.spec.ts b/apps/api/src/cloud-security/exception.service.spec.ts index 573503fd15..edfc252cbc 100644 --- a/apps/api/src/cloud-security/exception.service.spec.ts +++ b/apps/api/src/cloud-security/exception.service.spec.ts @@ -130,6 +130,56 @@ describe('CloudExceptionService.markAsException', () => { ); }); + it('prepends callerLabel to audit description when set (API key / service token attribution)', async () => { + // When the userId came from ActingUserResolver's owner-fallback path, + // the controller forwards a callerLabel so the audit log makes it + // clear this was automation, not a UI click. + withFinding({ + findingKey: 'iam-no-mfa-john', + resourceId: 'john', + connectionId: 'icn_aws', + }); + dbMock.findingException.upsert.mockResolvedValueOnce({ id: 'fex_new' }); + + await buildService().markAsException({ + findingId: 'icx_1', + organizationId: 'org_1', + userId: 'usr_owner', + reason: 'CI pipeline marking this finding under approved exception policy.', + callerLabel: 'via API key "CI Pipeline"', + }); + + const auditCall = auditLogMock.mock.calls[0][0]; + expect(auditCall.description).toMatch( + /^\[via API key "CI Pipeline"\] Marked finding /, + ); + expect(auditCall.metadata).toEqual( + expect.objectContaining({ callerLabel: 'via API key "CI Pipeline"' }), + ); + }); + + it('omits the [callerLabel] prefix when callerLabel is not provided (session calls)', async () => { + withFinding({ + findingKey: 'iam-no-mfa-john', + resourceId: 'john', + connectionId: 'icn_aws', + }); + dbMock.findingException.upsert.mockResolvedValueOnce({ id: 'fex_new' }); + + await buildService().markAsException({ + findingId: 'icx_1', + organizationId: 'org_1', + userId: 'usr_human', + reason: 'Documented exception with sufficient supporting rationale here.', + // no callerLabel — this is a session call + }); + + const auditCall = auditLogMock.mock.calls[0][0]; + // No bracket prefix — description begins directly with "Marked finding" + expect(auditCall.description).toMatch(/^Marked finding /); + expect(auditCall.metadata.callerLabel).toBeNull(); + }); + it('rejects findings that lack a stable check/resource identity', async () => { dbMock.integrationCheckResult.findFirst.mockResolvedValueOnce({ resourceId: null, diff --git a/apps/api/src/cloud-security/exception.service.ts b/apps/api/src/cloud-security/exception.service.ts index a89fb83bc7..25ab69163b 100644 --- a/apps/api/src/cloud-security/exception.service.ts +++ b/apps/api/src/cloud-security/exception.service.ts @@ -17,10 +17,17 @@ export const MIN_EXCEPTION_REASON_LENGTH = 20; export interface MarkExceptionInput { findingId: string; organizationId: string; + /** User to attribute the mutation to. For session callers this is the + * signed-in user; for API key / service token callers this is the org's + * owner (resolved by ActingUserResolver). */ userId: string; reason: string; reviewedBy?: string | null; expiresAt?: Date | null; + /** Optional audit-log description prefix when the userId came from + * owner-fallback (e.g. `via API key "CI Pipeline"`). Undefined for + * session callers so the description is unchanged. */ + callerLabel?: string; } @Injectable() @@ -86,18 +93,24 @@ export class CloudExceptionService { }); const exceptionId = upserted.id; + const reasonPreview = `${input.reason.slice(0, 80)}${input.reason.length > 80 ? '…' : ''}`; + const description = input.callerLabel + ? `[${input.callerLabel}] Marked finding ${lookup.checkId}:${lookup.resourceId} as exception — ${reasonPreview}` + : `Marked finding ${lookup.checkId}:${lookup.resourceId} as exception — ${reasonPreview}`; + await logCloudSecurityActivity({ organizationId: input.organizationId, userId: input.userId, connectionId: lookup.connectionId, action: 'exception_marked', - description: `Marked finding ${lookup.checkId}:${lookup.resourceId} as exception — ${input.reason.slice(0, 80)}${input.reason.length > 80 ? '…' : ''}`, + description, metadata: { findingId: input.findingId, exceptionId, checkId: lookup.checkId, resourceId: lookup.resourceId, expiresAt: input.expiresAt?.toISOString() ?? null, + callerLabel: input.callerLabel ?? null, }, }); @@ -108,6 +121,8 @@ export class CloudExceptionService { exceptionId: string; organizationId: string; userId: string; + /** Optional audit-log prefix for owner-fallback callers (see MarkExceptionInput). */ + callerLabel?: string; }): Promise { const existing = await db.findingException.findFirst({ where: { id: params.exceptionId, organizationId: params.organizationId }, @@ -122,16 +137,21 @@ export class CloudExceptionService { data: { revokedAt: new Date(), revokedById: params.userId }, }); + const description = params.callerLabel + ? `[${params.callerLabel}] Revoked exception on ${existing.checkId}:${existing.resourceId}.` + : `Revoked exception on ${existing.checkId}:${existing.resourceId}.`; + await logCloudSecurityActivity({ organizationId: params.organizationId, userId: params.userId, connectionId: existing.connectionId, action: 'exception_revoked', - description: `Revoked exception on ${existing.checkId}:${existing.resourceId}.`, + description, metadata: { exceptionId: params.exceptionId, checkId: existing.checkId, resourceId: existing.resourceId, + callerLabel: params.callerLabel ?? null, }, }); } diff --git a/apps/api/src/cloud-security/plan-normalizer.spec.ts b/apps/api/src/cloud-security/plan-normalizer.spec.ts new file mode 100644 index 0000000000..cc5b933cc6 --- /dev/null +++ b/apps/api/src/cloud-security/plan-normalizer.spec.ts @@ -0,0 +1,300 @@ +import type { AwsCommandStep, FixPlan } from './ai-remediation.prompt'; +import { + AWS_SERVICE_LINKED_ROLE_PRINCIPAL, + normalizeFixPlan, +} from './plan-normalizer'; + +function makeStep(overrides: Partial = {}): AwsCommandStep { + return { + service: overrides.service ?? 'iam', + command: overrides.command ?? 'CreateRoleCommand', + params: overrides.params ?? {}, + purpose: overrides.purpose ?? 'test step', + }; +} + +function makePlan( + opts: { + fixSteps?: AwsCommandStep[]; + rollbackSteps?: AwsCommandStep[]; + } = {}, +): FixPlan { + return { + canAutoFix: true, + risk: 'medium', + description: 'test plan', + currentState: {}, + proposedState: {}, + requiredPermissions: [], + readSteps: [], + fixSteps: opts.fixSteps ?? [], + rollbackSteps: opts.rollbackSteps ?? [], + rollbackSupported: true, + requiresAcknowledgment: false, + }; +} + +describe('normalizeFixPlan — CreateServiceLinkedRoleCommand backfill', () => { + it('is a no-op for a plan with no SLR steps', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ service: 's3', command: 'PutBucketVersioningCommand' }), + ], + }); + expect(normalizeFixPlan(plan)).toEqual(plan); + }); + + it('preserves AWSServiceName when already set to a non-empty string', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: 'config.amazonaws.com' }, + }), + makeStep({ + service: 'config-service', + command: 'PutConfigurationRecorderCommand', + }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[0].params).toEqual({ + AWSServiceName: 'config.amazonaws.com', + }); + }); + + describe('backfills the right principal for each known service', () => { + const cases: ReadonlyArray<{ + siblingService: string; + expectedPrincipal: string; + }> = [ + { + siblingService: 'config-service', + expectedPrincipal: 'config.amazonaws.com', + }, + { siblingService: 'config', expectedPrincipal: 'config.amazonaws.com' }, + { + siblingService: 'guardduty', + expectedPrincipal: 'guardduty.amazonaws.com', + }, + { + siblingService: 'inspector2', + expectedPrincipal: 'inspector2.amazonaws.com', + }, + { siblingService: 'macie2', expectedPrincipal: 'macie.amazonaws.com' }, + { + siblingService: 'accessanalyzer', + expectedPrincipal: 'access-analyzer.amazonaws.com', + }, + { + siblingService: 'securityhub', + expectedPrincipal: 'securityhub.amazonaws.com', + }, + ]; + + for (const { siblingService, expectedPrincipal } of cases) { + it(`infers "${expectedPrincipal}" when neighbor service is "${siblingService}"`, () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ + service: siblingService, + command: 'EnableSomethingCommand', + }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[0].params).toEqual({ + AWSServiceName: expectedPrincipal, + }); + }); + } + }); + + it('treats empty-string AWSServiceName as missing and backfills', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: '' }, + }), + makeStep({ service: 'guardduty', command: 'CreateDetectorCommand' }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[0].params).toEqual({ + AWSServiceName: 'guardduty.amazonaws.com', + }); + }); + + it('treats explicit null AWSServiceName as missing and backfills', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: { AWSServiceName: null }, + }), + makeStep({ + service: 'macie2', + command: 'EnableOrganizationAdminAccountCommand', + }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[0].params).toEqual({ + AWSServiceName: 'macie.amazonaws.com', + }); + }); + + it('leaves the step untouched when no neighbor has a known SLR principal', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ service: 'sts', command: 'AssumeRoleCommand' }), + makeStep({ service: 'kms', command: 'CreateKeyCommand' }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[0].params).toEqual({}); + }); + + it('skips IAM/STS siblings when searching for the principal', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ service: 'iam', command: 'AttachRolePolicyCommand' }), + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ + service: 'config-service', + command: 'PutConfigurationRecorderCommand', + }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[1].params).toEqual({ + AWSServiceName: 'config.amazonaws.com', + }); + }); + + it('backfills each SLR step independently via nearest-neighbor in a multi-SLR plan', () => { + // Layout: [SLR-A, guardduty, SLR-B, config] + // SLR-A (idx 0) nearest non-IAM = guardduty (offset 1). + // SLR-B (idx 2) nearest non-IAM = config (offset 1 to the right beats + // guardduty at offset 1 to the left because we check right first). + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ service: 'guardduty', command: 'CreateDetectorCommand' }), + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ + service: 'config-service', + command: 'PutConfigurationRecorderCommand', + }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.fixSteps[0].params).toEqual({ + AWSServiceName: 'guardduty.amazonaws.com', + }); + expect(result.fixSteps[2].params).toEqual({ + AWSServiceName: 'config.amazonaws.com', + }); + }); + + it('also normalizes rollback steps', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'config-service', + command: 'PutConfigurationRecorderCommand', + }), + ], + rollbackSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ + service: 'config-service', + command: 'DeleteConfigurationRecorderCommand', + }), + ], + }); + const result = normalizeFixPlan(plan); + expect(result.rollbackSteps[0].params).toEqual({ + AWSServiceName: 'config.amazonaws.com', + }); + }); + + it('is idempotent — running twice equals running once', () => { + const plan = makePlan({ + fixSteps: [ + makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }), + makeStep({ service: 'guardduty', command: 'CreateDetectorCommand' }), + ], + }); + const once = normalizeFixPlan(plan); + const twice = normalizeFixPlan(once); + expect(twice).toEqual(once); + }); + + it('does not mutate the input plan', () => { + const slrStep = makeStep({ + service: 'iam', + command: 'CreateServiceLinkedRoleCommand', + params: {}, + }); + const plan = makePlan({ + fixSteps: [ + slrStep, + makeStep({ service: 'guardduty', command: 'CreateDetectorCommand' }), + ], + }); + normalizeFixPlan(plan); + expect(slrStep.params).toEqual({}); + }); + + it('exports a non-empty AWS_SERVICE_LINKED_ROLE_PRINCIPAL map covering core services', () => { + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.config).toBe('config.amazonaws.com'); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.guardduty).toBe( + 'guardduty.amazonaws.com', + ); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.inspector2).toBe( + 'inspector2.amazonaws.com', + ); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.macie2).toBe('macie.amazonaws.com'); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.securityhub).toBe( + 'securityhub.amazonaws.com', + ); + expect(AWS_SERVICE_LINKED_ROLE_PRINCIPAL.accessanalyzer).toBe( + 'access-analyzer.amazonaws.com', + ); + }); +}); diff --git a/apps/api/src/cloud-security/plan-normalizer.ts b/apps/api/src/cloud-security/plan-normalizer.ts new file mode 100644 index 0000000000..286f12d02d --- /dev/null +++ b/apps/api/src/cloud-security/plan-normalizer.ts @@ -0,0 +1,95 @@ +import type { AwsCommandStep, FixPlan } from './ai-remediation.prompt'; + +/** + * Maps an AWS service prefix (as it appears in `AwsCommandStep.service`) + * to the `AWSServiceName` value AWS expects when creating a service-linked + * role (SLR). + * + * Background: when a fix plan needs an SLR, the AI sometimes generates a + * `CreateServiceLinkedRoleCommand` step without populating `AWSServiceName`. + * AWS rejects the call with a cryptic "Member must not be null" error. This + * map lets us backfill the right principal deterministically from + * cross-step context inside the same plan. + * + * Keys include common AI-emitted spellings (with/without hyphens, legacy + * names). Extend as new adapters add SLR-requiring services. + */ +export const AWS_SERVICE_LINKED_ROLE_PRINCIPAL: Record = { + 'config-service': 'config.amazonaws.com', + config: 'config.amazonaws.com', + guardduty: 'guardduty.amazonaws.com', + inspector2: 'inspector2.amazonaws.com', + inspector: 'inspector2.amazonaws.com', + macie2: 'macie.amazonaws.com', + macie: 'macie.amazonaws.com', + accessanalyzer: 'access-analyzer.amazonaws.com', + 'access-analyzer': 'access-analyzer.amazonaws.com', + securityhub: 'securityhub.amazonaws.com', + 'security-hub': 'securityhub.amazonaws.com', + detective: 'detective.amazonaws.com', + backup: 'backup.amazonaws.com', +}; + +const SLR_COMMAND = 'CreateServiceLinkedRoleCommand'; +const IAM_LIKE_SERVICES = new Set(['iam', 'sts']); + +/** + * Deterministic post-processing for an AI-generated fix plan. Runs after + * the model returns to backfill cross-step values the AI does not reliably + * emit. Today the only backfill is `AWSServiceName` on SLR steps; the + * function is intentionally extensible so future plan-shape fixes can live + * here too. + * + * Pure, idempotent, and a no-op when the plan is already well-formed. + */ +export function normalizeFixPlan(plan: FixPlan): FixPlan { + return { + ...plan, + fixSteps: backfillServiceLinkedRoleParams(plan.fixSteps), + rollbackSteps: backfillServiceLinkedRoleParams(plan.rollbackSteps), + }; +} + +function backfillServiceLinkedRoleParams( + steps: AwsCommandStep[], +): AwsCommandStep[] { + return steps.map((step, idx) => { + if (step.command !== SLR_COMMAND) return step; + const existing = step.params?.AWSServiceName; + if (typeof existing === 'string' && existing.length > 0) return step; + const inferred = inferServiceLinkedRolePrincipal(steps, idx); + if (!inferred) return step; + return { + ...step, + params: { ...(step.params ?? {}), AWSServiceName: inferred }, + }; + }); +} + +/** + * Search outward from `selfIndex` for the nearest non-IAM/STS step whose + * `service` prefix has a known SLR principal. The right-side neighbor is + * preferred at equal distance because the SLR step usually appears + * immediately before the service step that needs it. + * + * This nearest-neighbor strategy handles plans with multiple SLR steps + * targeting different services (e.g., Config + GuardDuty) — each SLR picks + * up its closest service-step rather than a global "first match" that + * would assign both to the same principal. + */ +function inferServiceLinkedRolePrincipal( + allSteps: AwsCommandStep[], + selfIndex: number, +): string | null { + const maxOffset = Math.max(selfIndex, allSteps.length - 1 - selfIndex); + for (let offset = 1; offset <= maxOffset; offset++) { + for (const candidateIdx of [selfIndex + offset, selfIndex - offset]) { + if (candidateIdx < 0 || candidateIdx >= allSteps.length) continue; + const sibling = allSteps[candidateIdx]; + if (IAM_LIKE_SERVICES.has(sibling.service)) continue; + const principal = AWS_SERVICE_LINKED_ROLE_PRINCIPAL[sibling.service]; + if (principal) return principal; + } + } + return null; +} diff --git a/apps/api/src/cloud-security/remediation.service.ts b/apps/api/src/cloud-security/remediation.service.ts index d53d2d869c..59b5c67aa4 100644 --- a/apps/api/src/cloud-security/remediation.service.ts +++ b/apps/api/src/cloud-security/remediation.service.ts @@ -533,13 +533,36 @@ export class RemediationService { throw new Error(`Invalid fix steps: ${fixErrors.join('; ')}`); } - // Phase 3: Execute the refined fix steps (now with REAL values) - // Pass rollback steps for automatic undo on partial failure + // Phase 3: Execute the refined fix steps (now with REAL values). + // Pass rollback steps for automatic undo on partial failure. + // Pass a repairStep callback so that when AWS rejects any step with + // a validation error the AI can self-repair the step once before + // we give up — universal fix for "AI omitted a required param" + // bugs that no per-command map can fully cover. const fixResult = await executePlanSteps({ steps: refinedPlan.fixSteps, credentials: remediationCreds, region, autoRollbackSteps: refinedPlan.rollbackSteps, + repairStep: async ({ step, awsError }) => + this.aiRemediationService.refineStepFromError({ + step, + awsError, + finding: { + title: finding.title ?? 'Unknown', + description: finding.description, + severity: finding.severity, + resourceType: finding.resourceType, + resourceId: finding.resourceId, + remediation: finding.remediation, + findingKey: evidence.findingKey as string, + evidence, + }, + planContext: { + fixSteps: refinedPlan.fixSteps, + readSteps: refinedPlan.readSteps, + }, + }), }); if (fixResult.error) { diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts index c893a27068..335630d26c 100644 --- a/apps/api/src/integration-platform/controllers/sync.controller.ts +++ b/apps/api/src/integration-platform/controllers/sync.controller.ts @@ -48,6 +48,7 @@ interface GoogleWorkspaceUser { isAdmin?: boolean; suspended?: boolean; orgUnitPath?: string; + creationTime?: string; } interface GoogleWorkspaceUsersResponse { @@ -381,9 +382,16 @@ export class SyncController { }); if (existingMember) { - // Never reactivate deactivated members — whether deactivated manually - // by an admin or by a previous sync, they should stay deactivated. - // Admins can reactivate manually if needed. + if (!existingMember.onboardDate && gwUser.creationTime) { + const parsed = new Date(gwUser.creationTime); + const onboardDate = isNaN(parsed.getTime()) ? undefined : parsed; + if (onboardDate) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate }, + }); + } + } results.skipped++; results.details.push({ email: normalizedEmail, @@ -396,12 +404,15 @@ export class SyncController { } // Create member - always as employee, admins can be promoted manually + const gwParsed = gwUser.creationTime ? new Date(gwUser.creationTime) : null; + const gwOnboardDate = gwParsed && !isNaN(gwParsed.getTime()) ? gwParsed : undefined; await db.member.create({ data: { organizationId, userId, role: 'employee', isActive: true, + ...(gwOnboardDate ? { onboardDate: gwOnboardDate } : {}), }, }); @@ -839,6 +850,16 @@ export class SyncController { }); if (existingMember) { + if (!existingMember.onboardDate && worker.start_date) { + const parsed = new Date(worker.start_date); + const onboardDate = isNaN(parsed.getTime()) ? undefined : parsed; + if (onboardDate) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate }, + }); + } + } if (existingMember.deactivated) { await db.member.update({ where: { id: existingMember.id }, @@ -859,12 +880,15 @@ export class SyncController { }); } } else { + const ripplingParsed = worker.start_date ? new Date(worker.start_date) : null; + const ripplingOnboardDate = ripplingParsed && !isNaN(ripplingParsed.getTime()) ? ripplingParsed : undefined; await db.member.create({ data: { organizationId, userId, role: 'employee', isActive: true, + ...(ripplingOnboardDate ? { onboardDate: ripplingOnboardDate } : {}), }, }); results.imported++; @@ -1333,7 +1357,16 @@ export class SyncController { }); if (existingMember) { - // If member was deactivated but is now active in JumpCloud, reactivate them + if (!existingMember.onboardDate && jcUser.created) { + const parsed = new Date(jcUser.created); + const onboardDate = isNaN(parsed.getTime()) ? undefined : parsed; + if (onboardDate) { + await db.member.update({ + where: { id: existingMember.id }, + data: { onboardDate }, + }); + } + } if (existingMember.deactivated) { await db.member.update({ where: { id: existingMember.id }, @@ -1359,12 +1392,15 @@ export class SyncController { } // Create member - always as employee, admins can be promoted manually + const jcParsed = jcUser.created ? new Date(jcUser.created) : null; + const jcOnboardDate = jcParsed && !isNaN(jcParsed.getTime()) ? jcParsed : undefined; await db.member.create({ data: { organizationId, userId, role: 'employee', isActive: true, + ...(jcOnboardDate ? { onboardDate: jcOnboardDate } : {}), }, }); diff --git a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts index 9ee3c0c22c..07b5fc4d0f 100644 --- a/apps/api/src/integration-platform/services/generic-employee-sync.service.ts +++ b/apps/api/src/integration-platform/services/generic-employee-sync.service.ts @@ -185,13 +185,19 @@ export class GenericEmployeeSyncService { ); } + const needsOnboardDate = + !existingMember.onboardDate && employee.startDate; + if (existingMember.deactivated && allowReactivation) { + const parsedStartDate = employee.startDate ? new Date(employee.startDate) : null; await db.member.update({ where: { id: existingMember.id }, data: { deactivated: false, isActive: true, + offboardDate: null, ...(needsHeal ? { role: healedRole } : {}), + ...(needsOnboardDate && parsedStartDate && !isNaN(parsedStartDate.getTime()) ? { onboardDate: parsedStartDate } : {}), }, }); results.reactivated++; @@ -200,10 +206,15 @@ export class GenericEmployeeSyncService { status: 'reactivated', }); } else { - if (needsHeal) { + const parsedStartDate = employee.startDate ? new Date(employee.startDate) : null; + const validStartDate = parsedStartDate && !isNaN(parsedStartDate.getTime()) ? parsedStartDate : null; + if (needsHeal || (needsOnboardDate && validStartDate)) { await db.member.update({ where: { id: existingMember.id }, - data: { role: healedRole }, + data: { + ...(needsHeal ? { role: healedRole } : {}), + ...(needsOnboardDate && validStartDate ? { onboardDate: validStartDate } : {}), + }, }); } results.skipped++; @@ -225,12 +236,14 @@ export class GenericEmployeeSyncService { `[GenericSync] Provider "${providerName}" sent unrecognized role "${employee.role}" for ${normalizedEmail}; falling back to "${sanitizedRole}"`, ); } + const newMemberStartDate = employee.startDate ? new Date(employee.startDate) : null; await db.member.create({ data: { organizationId, userId: existingUser.id, role: sanitizedRole, isActive: true, + ...(newMemberStartDate && !isNaN(newMemberStartDate.getTime()) ? { onboardDate: newMemberStartDate } : {}), }, }); @@ -294,7 +307,11 @@ export class GenericEmployeeSyncService { try { await db.member.update({ where: { id: member.id }, - data: { deactivated: true, isActive: false }, + data: { + deactivated: true, + isActive: false, + offboardDate: member.offboardDate ?? new Date(), + }, }); results.deactivated++; results.details.push({ diff --git a/apps/api/src/offboarding-checklist/access-revocation.service.ts b/apps/api/src/offboarding-checklist/access-revocation.service.ts new file mode 100644 index 0000000000..b0444ab41b --- /dev/null +++ b/apps/api/src/offboarding-checklist/access-revocation.service.ts @@ -0,0 +1,312 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { AttachmentEntityType, Prisma, db } from '@db'; +import { AttachmentsService } from '../attachments/attachments.service'; + +@Injectable() +export class AccessRevocationService { + private readonly logger = new Logger(AccessRevocationService.name); + + constructor(private readonly attachmentsService: AttachmentsService) {} + async getAccessRevocations(organizationId: string, memberId: string) { + const vendors = await db.vendor.findMany({ + where: { organizationId }, + select: { id: true, name: true, website: true, logoUrl: true }, + orderBy: { name: 'asc' }, + }); + + const revocations = await db.offboardingAccessRevocation.findMany({ + where: { organizationId, memberId }, + include: { + revokedBy: { select: { id: true, name: true, email: true } }, + }, + }); + + const revocationMap = new Map( + revocations.map((r) => [r.vendorId, r]), + ); + + const revocationIds = revocations.map((r) => r.id); + const allAttachments = + revocationIds.length > 0 + ? await db.attachment.findMany({ + where: { + organizationId, + entityId: { in: revocationIds }, + entityType: AttachmentEntityType.offboarding_checklist, + }, + orderBy: { createdAt: 'asc' }, + }) + : []; + + const attachmentsByRevocation = new Map(); + for (const attachment of allAttachments) { + const existing = attachmentsByRevocation.get(attachment.entityId) ?? []; + existing.push(attachment); + attachmentsByRevocation.set(attachment.entityId, existing); + } + + const vendorList = await Promise.all( + vendors.map(async (vendor) => { + const revocation = revocationMap.get(vendor.id); + const domain = vendor.website?.replace(/^https?:\/\//, '').replace(/\/.*$/, '') ?? null; + const rawAttachments = revocation + ? (attachmentsByRevocation.get(revocation.id) ?? []) + : []; + const evidence = await Promise.all( + rawAttachments.map(async (attachment) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.type, + downloadUrl: await this.attachmentsService.getPresignedDownloadUrl(attachment.url), + createdAt: attachment.createdAt, + })), + ); + return { + vendorId: vendor.id, + vendorName: vendor.name, + logoUrl: vendor.logoUrl ?? (domain ? `https://img.logo.dev/${domain}?token=pk_X-1ZO13GSgeOoUrIuJ6GMQ&size=64` : null), + revoked: !!revocation, + revokedAt: revocation?.revokedAt ?? null, + revokedBy: revocation?.revokedBy ?? null, + notes: revocation?.notes ?? null, + evidence, + }; + }), + ); + + return { + vendors: vendorList, + totalVendors: vendors.length, + revokedCount: revocations.length, + }; + } + + async revokeVendorAccess({ + organizationId, + memberId, + vendorId, + revokedById, + notes, + evidence, + }: { + organizationId: string; + memberId: string; + vendorId: string; + revokedById: string; + notes?: string; + evidence?: { fileName: string; fileType: string; fileData: string }; + }) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + }); + + if (!member) { + throw new NotFoundException('Member not found in this organization'); + } + + const vendor = await db.vendor.findFirst({ + where: { id: vendorId, organizationId }, + }); + + if (!vendor) { + throw new NotFoundException('Vendor not found in this organization'); + } + + const existing = await db.offboardingAccessRevocation.findUnique({ + where: { memberId_vendorId: { memberId, vendorId } }, + }); + + if (existing) { + throw new BadRequestException( + 'Vendor access has already been revoked for this member', + ); + } + + let revocation: Awaited>; + try { + revocation = await db.offboardingAccessRevocation.create({ + data: { + organizationId, + memberId, + vendorId, + revokedById, + notes, + }, + include: { + revokedBy: { select: { id: true, name: true, email: true } }, + }, + }); + } catch (err) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + throw new BadRequestException('Vendor access has already been revoked for this member'); + } + throw err; + } + + if (evidence) { + try { + await this.attachmentsService.uploadAttachment( + organizationId, + revocation.id, + AttachmentEntityType.offboarding_checklist, + evidence, + revokedById, + ); + } catch (err) { + await db.offboardingAccessRevocation.delete({ where: { id: revocation.id } }); + throw err; + } + } + + try { + await this.syncAccessRevocationCompletion( + organizationId, + memberId, + revokedById, + ); + } catch (err) { + this.logger.warn(`Failed to sync access revocation completion for member ${memberId}`, err); + } + + return revocation; + } + + async undoVendorRevocation({ + organizationId, + memberId, + vendorId, + }: { + organizationId: string; + memberId: string; + vendorId: string; + }) { + const revocation = await db.offboardingAccessRevocation.findFirst({ + where: { memberId, vendorId, organizationId }, + }); + + if (!revocation) { + throw new NotFoundException('Revocation record not found'); + } + + const attachments = await this.attachmentsService.getAttachments( + organizationId, + revocation.id, + AttachmentEntityType.offboarding_checklist, + ); + + for (const attachment of attachments) { + await this.attachmentsService.deleteAttachment(organizationId, attachment.id); + } + + await db.offboardingAccessRevocation.delete({ + where: { id: revocation.id }, + }); + + try { + await this.syncAccessRevocationCompletion(organizationId, memberId); + } catch (err) { + this.logger.warn(`Failed to sync access revocation completion for member ${memberId}`, err); + } + + return { success: true }; + } + + async revokeAllVendorAccess({ + organizationId, + memberId, + revokedById, + }: { + organizationId: string; + memberId: string; + revokedById: string; + }) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + }); + + if (!member) { + throw new NotFoundException('Member not found in this organization'); + } + + const vendors = await db.vendor.findMany({ + where: { organizationId }, + select: { id: true }, + }); + + const existing = await db.offboardingAccessRevocation.findMany({ + where: { organizationId, memberId }, + select: { vendorId: true }, + }); + + const existingSet = new Set(existing.map((r) => r.vendorId)); + const toCreate = vendors.filter((v) => !existingSet.has(v.id)); + + if (toCreate.length > 0) { + await db.offboardingAccessRevocation.createMany({ + data: toCreate.map((v) => ({ + organizationId, + memberId, + vendorId: v.id, + revokedById, + })), + skipDuplicates: true, + }); + } + + try { + await this.syncAccessRevocationCompletion(organizationId, memberId, revokedById); + } catch (err) { + this.logger.warn(`Failed to sync access revocation completion for member ${memberId}`, err); + } + + return { confirmed: toCreate.length }; + } + + private async syncAccessRevocationCompletion( + organizationId: string, + memberId: string, + completedById?: string, + ) { + const templateItem = await db.offboardingChecklistTemplate.findFirst({ + where: { organizationId, isAccessRevocation: true, isEnabled: true }, + }); + + if (!templateItem) { + return; + } + + const [totalVendors, revokedCount] = await Promise.all([ + db.vendor.count({ where: { organizationId } }), + db.offboardingAccessRevocation.count({ where: { organizationId, memberId } }), + ]); + + const allRevoked = totalVendors > 0 && revokedCount === totalVendors; + + const existingCompletion = + await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId: templateItem.id }, + }); + + if (allRevoked && !existingCompletion && completedById) { + await db.offboardingChecklistCompletion.create({ + data: { + organizationId, + memberId, + templateItemId: templateItem.id, + completedById, + }, + }); + } + + if (!allRevoked && existingCompletion) { + await db.offboardingChecklistCompletion.delete({ + where: { id: existingCompletion.id }, + }); + } + } +} diff --git a/apps/api/src/offboarding-checklist/default-checklist-items.ts b/apps/api/src/offboarding-checklist/default-checklist-items.ts new file mode 100644 index 0000000000..4676fb4678 --- /dev/null +++ b/apps/api/src/offboarding-checklist/default-checklist-items.ts @@ -0,0 +1,66 @@ +export const DEFAULT_OFFBOARDING_CHECKLIST_ITEMS = [ + { + title: 'Revoke system access', + description: + "Disable or remove the employee's access to all company systems, applications, and cloud services.", + evidenceRequired: true, + isAccessRevocation: true, + sortOrder: 1, + }, + { + title: 'Remove from identity provider', + description: + 'Remove the employee from your identity provider (e.g., Okta, Azure AD, Google Workspace).', + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 2, + }, + { + title: 'Retrieve company devices', + description: + 'Collect all company-owned hardware including laptops, phones, access badges, and security keys.', + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 3, + }, + { + title: 'Deactivate email and accounts', + description: + "Deactivate or redirect the employee's email account and remove from shared mailboxes and distribution lists.", + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 4, + }, + { + title: 'Revoke privileged access', + description: + 'Remove any elevated permissions, admin rights, SSH keys, API tokens, or shared credentials the employee had access to.', + evidenceRequired: true, + isAccessRevocation: false, + sortOrder: 5, + }, + { + title: 'Notify relevant teams', + description: + "Inform the employee's team, IT, HR, and any relevant stakeholders of the departure.", + evidenceRequired: false, + isAccessRevocation: false, + sortOrder: 6, + }, + { + title: 'Exit interview completed', + description: + 'Conduct an exit interview covering security reminders and NDA obligations.', + evidenceRequired: false, + isAccessRevocation: false, + sortOrder: 7, + }, + { + title: 'Update org chart and documentation', + description: + 'Remove the employee from the org chart, on-call rotations, and internal documentation.', + evidenceRequired: false, + isAccessRevocation: false, + sortOrder: 8, + }, +] as const; diff --git a/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts b/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts new file mode 100644 index 0000000000..017b7614a2 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/complete-checklist-item.dto.ts @@ -0,0 +1,30 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength, IsBase64 } from 'class-validator'; +import { IsMimeTypeField } from '../../utils/mime-type.validator'; + +export class CompleteChecklistItemDto { + @ApiProperty({ description: 'Optional notes', required: false }) + @IsOptional() + @IsString() + notes?: string; + + @ApiProperty({ description: 'Evidence file name', required: false }) + @IsOptional() + @IsString() + fileName?: string; + + @ApiProperty({ description: 'Evidence file MIME type', required: false }) + @IsOptional() + @IsMimeTypeField() + fileType?: string; + + @ApiProperty({ + description: 'Base64 encoded evidence file', + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(134_217_728) + @IsBase64() + fileData?: string; +} diff --git a/apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts b/apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts new file mode 100644 index 0000000000..17ddd30d65 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/create-template-item.dto.ts @@ -0,0 +1,25 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator'; + +export class CreateTemplateItemDto { + @ApiProperty({ + description: 'Checklist item title', + example: 'Collect access badges', + }) + @IsString() + @IsNotEmpty() + title: string; + + @ApiProperty({ description: 'Guidance text for the admin', required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ + description: 'Whether evidence upload is required', + required: false, + }) + @IsOptional() + @IsBoolean() + evidenceRequired?: boolean; +} diff --git a/apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts b/apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts new file mode 100644 index 0000000000..d246ec5d57 --- /dev/null +++ b/apps/api/src/offboarding-checklist/dto/update-template-item.dto.ts @@ -0,0 +1,29 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsOptional, IsBoolean, IsInt } from 'class-validator'; + +export class UpdateTemplateItemDto { + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + title?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsString() + description?: string; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + evidenceRequired?: boolean; + + @ApiProperty({ required: false }) + @IsOptional() + @IsInt() + sortOrder?: number; + + @ApiProperty({ required: false }) + @IsOptional() + @IsBoolean() + isEnabled?: boolean; +} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts new file mode 100644 index 0000000000..32d042499c --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.controller.ts @@ -0,0 +1,296 @@ +import { + BadRequestException, + Controller, + Get, + Post, + Patch, + Delete, + Body, + Param, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { db } from '@db'; +import type { Response } from 'express'; +import { AuthContext, OrganizationId } from '../auth/auth-context.decorator'; +import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; +import { PermissionGuard } from '../auth/permission.guard'; +import { RequirePermission } from '../auth/require-permission.decorator'; +import type { AuthContext as AuthContextType } from '../auth/types'; +import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; +import { OffboardingExportService } from './offboarding-export.service'; +import { CreateTemplateItemDto } from './dto/create-template-item.dto'; +import { UpdateTemplateItemDto } from './dto/update-template-item.dto'; +import { CompleteChecklistItemDto } from './dto/complete-checklist-item.dto'; + +@ApiTags('Offboarding Checklist') +@Controller({ path: 'offboarding-checklist', version: '1' }) +@UseGuards(HybridAuthGuard, PermissionGuard) +@ApiSecurity('apikey') +export class OffboardingChecklistController { + constructor( + private readonly offboardingChecklistService: OffboardingChecklistService, + private readonly offboardingExportService: OffboardingExportService, + ) {} + + private requireUserId(authContext: AuthContextType): string { + if (!authContext.userId) { + throw new BadRequestException('User context required'); + } + return authContext.userId; + } + + @Get('pending') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Get members with pending offboarding checklists' }) + async getPendingOffboardings( + @OrganizationId() organizationId: string, + ) { + return this.offboardingChecklistService.getPendingOffboardings( + organizationId, + ); + } + + @Get('template') + @RequirePermission('member', 'read') + async getTemplate(@OrganizationId() organizationId: string) { + return this.offboardingChecklistService.getTemplate(organizationId); + } + + @Post('template') + @RequirePermission('member', 'update') + async createTemplateItem( + @OrganizationId() organizationId: string, + @Body() dto: CreateTemplateItemDto, + ) { + return this.offboardingChecklistService.createTemplateItem( + organizationId, + dto, + ); + } + + @Patch('template/:id') + @RequirePermission('member', 'update') + async updateTemplateItem( + @OrganizationId() organizationId: string, + @Param('id') id: string, + @Body() dto: UpdateTemplateItemDto, + ) { + return this.offboardingChecklistService.updateTemplateItem( + organizationId, + id, + dto, + ); + } + + @Delete('template/:id') + @RequirePermission('member', 'update') + async deleteTemplateItem( + @OrganizationId() organizationId: string, + @Param('id') id: string, + ) { + return this.offboardingChecklistService.deleteTemplateItem( + organizationId, + id, + ); + } + + @Get('member/:memberId') + @RequirePermission('member', 'read') + async getMemberChecklist( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + ) { + return this.offboardingChecklistService.getMemberChecklist( + organizationId, + memberId, + ); + } + + @Get('export-all') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Export all offboarding evidence as a zip file' }) + async exportAllEvidence( + @OrganizationId() organizationId: string, + @Res() res: Response, + ) { + const org = await db.organization.findFirst({ + where: { id: organizationId }, + select: { name: true }, + }); + const safeOrgName = (org?.name ?? 'org').replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); + const date = new Date().toISOString().split('T')[0]; + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="${safeOrgName}-offboardings-${date}.zip"`, + }); + await this.offboardingExportService.exportAllOffboardings({ + organizationId, + output: res, + }); + } + + @Get('member/:memberId/export') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Export offboarding evidence as a zip file' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + async exportEvidence( + @Param('memberId') memberId: string, + @OrganizationId() organizationId: string, + @Res() res: Response, + ) { + const member = await db.member.findFirst({ + where: { id: memberId, organizationId }, + include: { user: { select: { name: true } } }, + }); + + const safeName = (member?.user.name ?? 'member').replace( + /[^a-zA-Z0-9]/g, + '-', + ); + const date = new Date().toISOString().split('T')[0]; + + res.set({ + 'Content-Type': 'application/zip', + 'Content-Disposition': `attachment; filename="offboarding-${safeName}-${date}.zip"`, + }); + + await this.offboardingExportService.exportMemberEvidence({ + organizationId, + memberId, + output: res, + }); + } + + @Post('member/:memberId/item/:templateItemId/complete') + @RequirePermission('member', 'update') + async completeItem( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + @Body() dto: CompleteChecklistItemDto, + ) { + return this.offboardingChecklistService.completeItem({ + organizationId, + memberId, + templateItemId, + completedById: this.requireUserId(authContext), + dto, + }); + } + + @Delete('member/:memberId/item/:templateItemId/complete') + @RequirePermission('member', 'update') + async uncompleteItem( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + ) { + return this.offboardingChecklistService.uncompleteItem({ + organizationId, + memberId, + templateItemId, + }); + } + + @Post('member/:memberId/item/:templateItemId/evidence') + @RequirePermission('member', 'update') + async uploadEvidence( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('memberId') memberId: string, + @Param('templateItemId') templateItemId: string, + @Body() uploadDto: UploadAttachmentDto, + ) { + return this.offboardingChecklistService.uploadEvidenceToCompletion({ + organizationId, + memberId, + templateItemId, + uploadDto, + userId: this.requireUserId(authContext), + }); + } + + @Get('member/:memberId/access-revocations') + @RequirePermission('member', 'read') + @ApiOperation({ + summary: 'Get vendor access revocation status for a member', + }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + async getAccessRevocations( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + ) { + return this.offboardingChecklistService.getAccessRevocations( + organizationId, + memberId, + ); + } + + @Post('member/:memberId/access-revocations/confirm-all') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Confirm all vendor access as revoked' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + async revokeAllVendorAccess( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @AuthContext() authContext: AuthContextType, + ) { + return this.offboardingChecklistService.revokeAllVendorAccess({ + organizationId, + memberId, + revokedById: this.requireUserId(authContext), + }); + } + + @Post('member/:memberId/access-revocations/:vendorId') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Mark vendor access as revoked' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + @ApiParam({ name: 'vendorId', description: 'Vendor ID' }) + async revokeVendorAccess( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @Param('vendorId') vendorId: string, + @AuthContext() authContext: AuthContextType, + @Body() body: { notes?: string; fileName?: string; fileType?: string; fileData?: string }, + ) { + const evidenceFields = [body?.fileName, body?.fileType, body?.fileData]; + const providedCount = evidenceFields.filter(Boolean).length; + if (providedCount > 0 && providedCount < 3) { + throw new BadRequestException('fileName, fileType, and fileData must all be provided together'); + } + const evidence = body?.fileName && body?.fileType && body?.fileData + ? { fileName: body.fileName, fileType: body.fileType, fileData: body.fileData } + : undefined; + return this.offboardingChecklistService.revokeVendorAccess({ + organizationId, + memberId, + vendorId, + revokedById: this.requireUserId(authContext), + notes: body?.notes, + evidence, + }); + } + + @Delete('member/:memberId/access-revocations/:vendorId') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Undo vendor access revocation' }) + @ApiParam({ name: 'memberId', description: 'Member ID' }) + @ApiParam({ name: 'vendorId', description: 'Vendor ID' }) + async undoVendorRevocation( + @OrganizationId() organizationId: string, + @Param('memberId') memberId: string, + @Param('vendorId') vendorId: string, + ) { + return this.offboardingChecklistService.undoVendorRevocation({ + organizationId, + memberId, + vendorId, + }); + } + +} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts new file mode 100644 index 0000000000..0d416ebbaf --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '../auth/auth.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; +import { AccessRevocationService } from './access-revocation.service'; +import { OffboardingChecklistController } from './offboarding-checklist.controller'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; +import { OffboardingExportService } from './offboarding-export.service'; + +@Module({ + imports: [AuthModule, AttachmentsModule], + controllers: [OffboardingChecklistController], + providers: [ + OffboardingChecklistService, + AccessRevocationService, + OffboardingExportService, + ], + exports: [OffboardingChecklistService], +}) +export class OffboardingChecklistModule {} diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts new file mode 100644 index 0000000000..48b6636152 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.spec.ts @@ -0,0 +1,563 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; + +const mockDb = { + offboardingChecklistTemplate: { + findMany: jest.fn(), + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + createMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + aggregate: jest.fn(), + }, + offboardingChecklistCompletion: { + findMany: jest.fn(), + findFirst: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + }, + offboardingAccessRevocation: { + findMany: jest.fn(), + findFirst: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + createMany: jest.fn(), + delete: jest.fn(), + count: jest.fn(), + }, + vendor: { + findMany: jest.fn(), + findFirst: jest.fn(), + count: jest.fn(), + }, + member: { + findFirst: jest.fn(), + findMany: jest.fn(), + }, + attachment: { + findMany: jest.fn(), + }, + $transaction: jest.fn((fn: (tx: typeof mockDb) => Promise) => fn(mockDb)), +}; + +jest.mock('@db', () => ({ + db: mockDb, + AttachmentEntityType: { + offboarding_checklist: 'offboarding_checklist', + }, +})); + +import { OffboardingChecklistService } from './offboarding-checklist.service'; +import { AccessRevocationService } from './access-revocation.service'; +import { DEFAULT_OFFBOARDING_CHECKLIST_ITEMS } from './default-checklist-items'; + +describe('OffboardingChecklistService', () => { + const mockAttachmentsService = { + getAttachments: jest.fn(), + uploadAttachment: jest.fn(), + deleteAttachment: jest.fn(), + getPresignedDownloadUrl: jest.fn().mockResolvedValue('https://signed-url.example.com'), + }; + + let service: OffboardingChecklistService; + let accessRevocationService: AccessRevocationService; + + beforeEach(() => { + jest.clearAllMocks(); + accessRevocationService = new AccessRevocationService(mockAttachmentsService as never); + service = new OffboardingChecklistService( + mockAttachmentsService as never, + accessRevocationService, + ); + }); + + describe('getTemplate', () => { + it('seeds defaults when none exist', async () => { + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(0); + mockDb.offboardingChecklistTemplate.createMany.mockResolvedValue({ + count: DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.length, + }); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue( + DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.map((item, i) => ({ + id: `oct_${i}`, + organizationId: 'org_1', + ...item, + isDefault: true, + isEnabled: true, + })), + ); + + const result = await service.getTemplate('org_1'); + + expect( + mockDb.offboardingChecklistTemplate.createMany, + ).toHaveBeenCalledWith({ + data: expect.arrayContaining([ + expect.objectContaining({ + organizationId: 'org_1', + title: 'Revoke system access', + isDefault: true, + isEnabled: true, + }), + ]), + }); + expect(result).toHaveLength(DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.length); + }); + + it('returns existing items without seeding', async () => { + const existingItems = [ + { + id: 'oct_1', + organizationId: 'org_1', + title: 'Custom item', + isDefault: false, + isEnabled: true, + sortOrder: 1, + }, + ]; + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(1); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue( + existingItems, + ); + + const result = await service.getTemplate('org_1'); + + expect( + mockDb.offboardingChecklistTemplate.createMany, + ).not.toHaveBeenCalled(); + expect(result).toEqual(existingItems); + }); + }); + + describe('getMemberChecklist', () => { + it('returns items with completion status', async () => { + mockDb.offboardingChecklistTemplate.count.mockResolvedValue(2); + mockDb.offboardingChecklistTemplate.findMany.mockResolvedValue([ + { + id: 'oct_1', + organizationId: 'org_1', + title: 'Item 1', + isEnabled: true, + sortOrder: 1, + }, + { + id: 'oct_2', + organizationId: 'org_1', + title: 'Item 2', + isEnabled: true, + sortOrder: 2, + }, + ]); + mockDb.offboardingChecklistCompletion.findMany.mockResolvedValue([ + { + id: 'occ_1', + templateItemId: 'oct_1', + memberId: 'mem_1', + completedById: 'usr_1', + completedBy: { id: 'usr_1', name: 'Test User' }, + }, + ]); + mockDb.attachment.findMany.mockResolvedValue([ + { id: 'att_1', name: 'evidence.pdf', url: 's3://bucket/key', entityId: 'occ_1' }, + ]); + + const result = await service.getMemberChecklist('org_1', 'mem_1'); + + expect(result.totalItems).toBe(2); + expect(result.completedItems).toBe(1); + expect(result.items[0].completed).toBe(true); + expect(result.items[0].evidence).toHaveLength(1); + expect(result.items[1].completed).toBe(false); + expect(result.items[1].evidence).toHaveLength(0); + }); + }); + + describe('completeItem', () => { + it('creates completion record', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isEnabled: true, + }); + mockDb.offboardingChecklistCompletion.create.mockResolvedValue({ + id: 'occ_1', + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + notes: 'Done', + }); + + const result = await service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + dto: { notes: 'Done' }, + }); + + expect(result.id).toBe('occ_1'); + expect( + mockDb.offboardingChecklistCompletion.create, + ).toHaveBeenCalledWith({ + data: { + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + notes: 'Done', + }, + }); + }); + + it('throws if already completed', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + + await expect( + service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + dto: {}, + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('throws if template item not found', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + await expect( + service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_invalid', + completedById: 'usr_1', + dto: {}, + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('uploads evidence when file data is provided', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isEnabled: true, + }); + mockDb.offboardingChecklistCompletion.create.mockResolvedValue({ + id: 'occ_1', + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + }); + mockAttachmentsService.uploadAttachment.mockResolvedValue({ + id: 'att_1', + }); + + await service.completeItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + completedById: 'usr_1', + dto: { + fileName: 'evidence.pdf', + fileType: 'application/pdf', + fileData: 'base64data', + }, + }); + + expect(mockAttachmentsService.uploadAttachment).toHaveBeenCalledWith( + 'org_1', + 'occ_1', + 'offboarding_checklist', + { + fileName: 'evidence.pdf', + fileData: 'base64data', + fileType: 'application/pdf', + }, + 'usr_1', + ); + }); + }); + + describe('uncompleteItem', () => { + it('deletes completion and associated evidence', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + mockAttachmentsService.getAttachments.mockResolvedValue([ + { id: 'att_1' }, + { id: 'att_2' }, + ]); + mockDb.offboardingChecklistCompletion.delete.mockResolvedValue({}); + + await service.uncompleteItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + }); + + expect(mockAttachmentsService.deleteAttachment).toHaveBeenCalledTimes(2); + expect( + mockDb.offboardingChecklistCompletion.delete, + ).toHaveBeenCalledWith({ where: { id: 'occ_1' } }); + }); + + it('throws if completion not found', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + + await expect( + service.uncompleteItem({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('deleteTemplateItem', () => { + it('soft-disables default items', async () => { + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_1', + organizationId: 'org_1', + isDefault: true, + }); + mockDb.offboardingChecklistTemplate.update.mockResolvedValue({ + id: 'oct_1', + isEnabled: false, + }); + + const result = await service.deleteTemplateItem('org_1', 'oct_1'); + + expect( + mockDb.offboardingChecklistTemplate.update, + ).toHaveBeenCalledWith({ + where: { id: 'oct_1' }, + data: { isEnabled: false }, + }); + expect( + mockDb.offboardingChecklistTemplate.delete, + ).not.toHaveBeenCalled(); + expect(result.isEnabled).toBe(false); + }); + + it('hard-deletes custom items', async () => { + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue({ + id: 'oct_2', + organizationId: 'org_1', + isDefault: false, + }); + mockDb.offboardingChecklistTemplate.delete.mockResolvedValue({ + id: 'oct_2', + }); + + await service.deleteTemplateItem('org_1', 'oct_2'); + + expect( + mockDb.offboardingChecklistTemplate.delete, + ).toHaveBeenCalledWith({ where: { id: 'oct_2' } }); + expect( + mockDb.offboardingChecklistTemplate.update, + ).not.toHaveBeenCalled(); + }); + + it('throws if item not found', async () => { + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + await expect( + service.deleteTemplateItem('org_1', 'oct_invalid'), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); + + describe('uploadEvidenceToCompletion', () => { + it('uploads evidence to a completed item', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue({ + id: 'occ_1', + }); + mockAttachmentsService.uploadAttachment.mockResolvedValue({ + id: 'att_1', + }); + + const result = await service.uploadEvidenceToCompletion({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + uploadDto: { + fileName: 'screenshot.png', + fileType: 'image/png', + fileData: 'base64data', + }, + userId: 'usr_1', + }); + + expect(mockAttachmentsService.uploadAttachment).toHaveBeenCalledWith( + 'org_1', + 'occ_1', + 'offboarding_checklist', + { + fileName: 'screenshot.png', + fileType: 'image/png', + fileData: 'base64data', + }, + 'usr_1', + ); + expect(result.id).toBe('att_1'); + }); + + it('throws if item not yet completed', async () => { + mockDb.offboardingChecklistCompletion.findFirst.mockResolvedValue(null); + + await expect( + service.uploadEvidenceToCompletion({ + organizationId: 'org_1', + memberId: 'mem_1', + templateItemId: 'oct_1', + uploadDto: { + fileName: 'screenshot.png', + fileType: 'image/png', + fileData: 'base64data', + }, + userId: 'usr_1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('getAccessRevocations', () => { + it('returns vendor list with revocation status', async () => { + mockDb.vendor.findMany.mockResolvedValue([ + { id: 'vnd_1', name: 'Slack', website: null, logoUrl: null }, + { id: 'vnd_2', name: 'AWS', website: null, logoUrl: null }, + ]); + mockDb.offboardingAccessRevocation.findMany.mockResolvedValue([ + { + id: 'oar_1', + vendorId: 'vnd_1', + revokedBy: { id: 'usr_1', name: 'Jane', email: 'jane@test.com' }, + revokedAt: new Date(), + notes: null, + }, + ]); + mockDb.attachment.findMany.mockResolvedValue([]); + + const result = await service.getAccessRevocations('org_1', 'mem_1'); + + expect(result.totalVendors).toBe(2); + expect(result.revokedCount).toBe(1); + expect(result.vendors[0].revoked).toBe(true); + expect(result.vendors[1].revoked).toBe(false); + }); + + it('returns empty when no vendors exist', async () => { + mockDb.vendor.findMany.mockResolvedValue([]); + mockDb.offboardingAccessRevocation.findMany.mockResolvedValue([]); + mockDb.attachment.findMany.mockResolvedValue([]); + + const result = await service.getAccessRevocations('org_1', 'mem_1'); + + expect(result.totalVendors).toBe(0); + expect(result.revokedCount).toBe(0); + expect(result.vendors).toHaveLength(0); + }); + }); + + describe('revokeVendorAccess', () => { + it('creates revocation record', async () => { + mockDb.member.findFirst.mockResolvedValue({ id: 'mem_1', organizationId: 'org_1' }); + mockDb.vendor.findFirst.mockResolvedValue({ id: 'vnd_1' }); + mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue(null); + mockDb.offboardingAccessRevocation.create.mockResolvedValue({ + id: 'oar_1', + vendorId: 'vnd_1', + revokedBy: { id: 'usr_1', name: 'Jane', email: 'jane@test.com' }, + }); + // syncAccessRevocationCompletion mocks + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + const result = await service.revokeVendorAccess({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + revokedById: 'usr_1', + }); + + expect(mockDb.offboardingAccessRevocation.create).toHaveBeenCalled(); + expect(result.id).toBe('oar_1'); + }); + + it('throws if vendor not found', async () => { + mockDb.member.findFirst.mockResolvedValue({ id: 'mem_1', organizationId: 'org_1' }); + mockDb.vendor.findFirst.mockResolvedValue(null); + + await expect( + service.revokeVendorAccess({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_invalid', + revokedById: 'usr_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('throws if already revoked', async () => { + mockDb.member.findFirst.mockResolvedValue({ id: 'mem_1', organizationId: 'org_1' }); + mockDb.vendor.findFirst.mockResolvedValue({ id: 'vnd_1' }); + mockDb.offboardingAccessRevocation.findUnique.mockResolvedValue({ + id: 'oar_1', + }); + + await expect( + service.revokeVendorAccess({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + revokedById: 'usr_1', + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('undoVendorRevocation', () => { + it('deletes revocation record', async () => { + mockDb.offboardingAccessRevocation.findFirst.mockResolvedValue({ + id: 'oar_1', + }); + mockDb.offboardingAccessRevocation.delete.mockResolvedValue({}); + mockAttachmentsService.getAttachments.mockResolvedValue([]); + // syncAccessRevocationCompletion mocks + mockDb.offboardingChecklistTemplate.findFirst.mockResolvedValue(null); + + const result = await service.undoVendorRevocation({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + }); + + expect( + mockDb.offboardingAccessRevocation.delete, + ).toHaveBeenCalledWith({ where: { id: 'oar_1' } }); + expect(result.success).toBe(true); + }); + + it('throws if revocation not found', async () => { + mockDb.offboardingAccessRevocation.findFirst.mockResolvedValue(null); + + await expect( + service.undoVendorRevocation({ + organizationId: 'org_1', + memberId: 'mem_1', + vendorId: 'vnd_1', + }), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts new file mode 100644 index 0000000000..09a4378478 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-checklist.service.ts @@ -0,0 +1,431 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { AttachmentEntityType, db } from '@db'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { AccessRevocationService } from './access-revocation.service'; +import { DEFAULT_OFFBOARDING_CHECKLIST_ITEMS } from './default-checklist-items'; + +interface CompleteChecklistItemDto { + notes?: string; + fileName?: string; + fileType?: string; + fileData?: string; +} + +interface UploadEvidenceDto { + fileName: string; + fileType: string; + fileData: string; + description?: string; +} + +@Injectable() +export class OffboardingChecklistService { + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly accessRevocationService: AccessRevocationService, + ) {} + + async getTemplate(organizationId: string) { + await this.seedDefaultsIfNeeded(organizationId); + + return db.offboardingChecklistTemplate.findMany({ + where: { organizationId }, + orderBy: { sortOrder: 'asc' }, + }); + } + + async createTemplateItem( + organizationId: string, + dto: { + title: string; + description?: string; + evidenceRequired?: boolean; + }, + ) { + const maxSortOrder = await db.offboardingChecklistTemplate.aggregate({ + where: { organizationId }, + _max: { sortOrder: true }, + }); + + return db.offboardingChecklistTemplate.create({ + data: { + organizationId, + title: dto.title, + description: dto.description, + evidenceRequired: dto.evidenceRequired ?? false, + sortOrder: (maxSortOrder._max.sortOrder ?? 0) + 1, + isDefault: false, + isEnabled: true, + }, + }); + } + + async updateTemplateItem( + organizationId: string, + templateItemId: string, + dto: { + title?: string; + description?: string; + evidenceRequired?: boolean; + sortOrder?: number; + isEnabled?: boolean; + }, + ) { + const item = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId }, + }); + + if (!item) { + throw new NotFoundException('Template item not found'); + } + + return db.offboardingChecklistTemplate.update({ + where: { id: templateItemId }, + data: dto, + }); + } + + async deleteTemplateItem(organizationId: string, templateItemId: string) { + const item = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId }, + }); + + if (!item) { + throw new NotFoundException('Template item not found'); + } + + if (item.isDefault) { + return db.offboardingChecklistTemplate.update({ + where: { id: templateItemId }, + data: { isEnabled: false }, + }); + } + + return db.offboardingChecklistTemplate.delete({ + where: { id: templateItemId }, + }); + } + + async getMemberChecklist(organizationId: string, memberId: string) { + await this.seedDefaultsIfNeeded(organizationId); + + const templateItems = await db.offboardingChecklistTemplate.findMany({ + where: { organizationId, isEnabled: true }, + orderBy: { sortOrder: 'asc' }, + }); + + const completions = await db.offboardingChecklistCompletion.findMany({ + where: { organizationId, memberId }, + include: { completedBy: { select: { id: true, name: true } } }, + }); + + const completionMap = new Map( + completions.map((c) => [c.templateItemId, c]), + ); + + const completionIds = completions.map((c) => c.id); + + const allAttachments = + completionIds.length > 0 + ? await db.attachment.findMany({ + where: { + organizationId, + entityId: { in: completionIds }, + entityType: AttachmentEntityType.offboarding_checklist, + }, + orderBy: { createdAt: 'asc' }, + }) + : []; + + const attachmentsByCompletion = new Map(); + for (const attachment of allAttachments) { + const existing = attachmentsByCompletion.get(attachment.entityId) ?? []; + existing.push(attachment); + attachmentsByCompletion.set(attachment.entityId, existing); + } + + const items = await Promise.all( + templateItems.map(async (template) => { + const completion = completionMap.get(template.id); + const rawAttachments = completion + ? (attachmentsByCompletion.get(completion.id) ?? []) + : []; + + const evidence = await Promise.all( + rawAttachments.map(async (attachment) => ({ + id: attachment.id, + name: attachment.name, + type: attachment.type, + downloadUrl: await this.attachmentsService.getPresignedDownloadUrl(attachment.url), + createdAt: attachment.createdAt, + })), + ); + + return { + ...template, + templateItemId: template.id, + completed: !!completion, + completion: completion ?? null, + evidence, + }; + }), + ); + + return { + items, + totalItems: items.length, + completedItems: items.filter((i) => i.completed).length, + }; + } + + async completeItem({ + organizationId, + memberId, + templateItemId, + completedById, + dto, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + completedById: string; + dto: CompleteChecklistItemDto; + }) { + const existing = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (existing) { + throw new BadRequestException('Item is already completed'); + } + + const template = await db.offboardingChecklistTemplate.findFirst({ + where: { id: templateItemId, organizationId, isEnabled: true }, + }); + + if (!template) { + throw new NotFoundException('Template item not found'); + } + + if (template.evidenceRequired && (!dto.fileData || !dto.fileName || !dto.fileType)) { + throw new BadRequestException('Evidence is required to complete this item'); + } + + const completion = await db.offboardingChecklistCompletion.create({ + data: { + organizationId, + memberId, + templateItemId, + completedById, + notes: dto.notes, + }, + }); + + if (dto.fileName && dto.fileData && dto.fileType) { + try { + await this.attachmentsService.uploadAttachment( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + { + fileName: dto.fileName, + fileData: dto.fileData, + fileType: dto.fileType, + }, + completedById, + ); + } catch (err) { + await db.offboardingChecklistCompletion.delete({ + where: { id: completion.id }, + }); + throw err; + } + } + + return completion; + } + + async uncompleteItem({ + organizationId, + memberId, + templateItemId, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + }) { + const completion = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (!completion) { + throw new NotFoundException('Completion not found'); + } + + const attachments = await this.attachmentsService.getAttachments( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + ); + + for (const attachment of attachments) { + await this.attachmentsService.deleteAttachment( + organizationId, + attachment.id, + ); + } + + await db.offboardingChecklistCompletion.delete({ + where: { id: completion.id }, + }); + } + + async uploadEvidenceToCompletion({ + organizationId, + memberId, + templateItemId, + uploadDto, + userId, + }: { + organizationId: string; + memberId: string; + templateItemId: string; + uploadDto: UploadEvidenceDto; + userId: string; + }) { + const completion = await db.offboardingChecklistCompletion.findFirst({ + where: { organizationId, memberId, templateItemId }, + }); + + if (!completion) { + throw new BadRequestException( + 'Item must be completed before uploading evidence', + ); + } + + return this.attachmentsService.uploadAttachment( + organizationId, + completion.id, + AttachmentEntityType.offboarding_checklist, + uploadDto, + userId, + ); + } + + async getAccessRevocations(organizationId: string, memberId: string) { + return this.accessRevocationService.getAccessRevocations( + organizationId, + memberId, + ); + } + + async revokeVendorAccess(params: { + organizationId: string; + memberId: string; + vendorId: string; + revokedById: string; + notes?: string; + evidence?: { fileName: string; fileType: string; fileData: string }; + }) { + return this.accessRevocationService.revokeVendorAccess(params); + } + + async undoVendorRevocation(params: { + organizationId: string; + memberId: string; + vendorId: string; + }) { + return this.accessRevocationService.undoVendorRevocation(params); + } + + async revokeAllVendorAccess(params: { + organizationId: string; + memberId: string; + revokedById: string; + }) { + return this.accessRevocationService.revokeAllVendorAccess(params); + } + + async getPendingOffboardings(organizationId: string) { + await this.seedDefaultsIfNeeded(organizationId); + + const totalEnabled = await db.offboardingChecklistTemplate.count({ + where: { organizationId, isEnabled: true }, + }); + + const offboardedMembers = await db.member.findMany({ + where: { + organizationId, + offboardDate: { not: null }, + deactivated: true, + }, + include: { + user: { select: { id: true, name: true, email: true, image: true } }, + offboardingChecklistCompletions: { + where: { + templateItem: { organizationId, isEnabled: true }, + }, + select: { id: true }, + }, + }, + orderBy: { offboardDate: 'desc' }, + }); + + return { + members: offboardedMembers + .filter((m) => m.offboardingChecklistCompletions.length < totalEnabled) + .map((m) => ({ + memberId: m.id, + name: m.user.name, + email: m.user.email, + image: m.user.image, + offboardDate: m.offboardDate, + completedItems: m.offboardingChecklistCompletions.length, + totalItems: totalEnabled, + })), + }; + } + + private async seedDefaultsIfNeeded(organizationId: string) { + const count = await db.offboardingChecklistTemplate.count({ + where: { organizationId }, + }); + + if (count > 0) { + return; + } + + try { + await db.$transaction(async (tx) => { + const recheck = await tx.offboardingChecklistTemplate.count({ + where: { organizationId }, + }); + + if (recheck > 0) { + return; + } + + await tx.offboardingChecklistTemplate.createMany({ + data: DEFAULT_OFFBOARDING_CHECKLIST_ITEMS.map((item) => ({ + organizationId, + title: item.title, + description: item.description, + evidenceRequired: item.evidenceRequired, + isAccessRevocation: item.isAccessRevocation, + sortOrder: item.sortOrder, + isDefault: true, + isEnabled: true, + })), + }); + }); + } catch (err) { + const isPrismaConflict = + err instanceof Error && 'code' in err && (err as { code: string }).code === 'P2002'; + if (!isPrismaConflict) throw err; + } + } +} diff --git a/apps/api/src/offboarding-checklist/offboarding-export.service.spec.ts b/apps/api/src/offboarding-checklist/offboarding-export.service.spec.ts new file mode 100644 index 0000000000..ec342aa62b --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-export.service.spec.ts @@ -0,0 +1,250 @@ +import { PassThrough } from 'stream'; + +const mockDb = { + attachment: { + findFirst: jest.fn(), + }, +}; + +jest.mock('@db', () => ({ + db: mockDb, + AttachmentEntityType: { + offboarding_checklist: 'offboarding_checklist', + }, +})); + +jest.mock('archiver', () => { + const mockArchive = { + pipe: jest.fn(), + append: jest.fn(), + finalize: jest.fn().mockResolvedValue(undefined), + }; + return jest.fn(() => mockArchive); +}); + +import archiver from 'archiver'; +import { OffboardingExportService } from './offboarding-export.service'; + +describe('OffboardingExportService', () => { + const mockAttachmentsService = { + getObjectBuffer: jest.fn(), + }; + + const mockAccessRevocationService = { + getAccessRevocations: jest.fn(), + }; + + const mockChecklistService = { + getMemberChecklist: jest.fn(), + }; + + let service: OffboardingExportService; + let mockArchive: ReturnType; + + beforeEach(() => { + jest.clearAllMocks(); + service = new OffboardingExportService( + mockAttachmentsService as never, + mockAccessRevocationService as never, + mockChecklistService as never, + ); + mockArchive = (archiver as unknown as jest.Mock)(); + }); + + it('creates a zip with summary CSV and vendor revocations CSV', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [ + { + title: 'Revoke system access', + completed: true, + completion: { + completedBy: { name: 'Jane Doe' }, + completedAt: new Date('2026-05-01'), + }, + evidence: [], + }, + { + title: 'Recover devices', + completed: false, + completion: null, + evidence: [], + }, + ], + totalItems: 2, + completedItems: 1, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [ + { + vendorName: 'Slack', + revokedBy: { name: 'Jane Doe' }, + revokedAt: new Date('2026-05-01'), + evidence: [], + }, + ], + totalVendors: 1, + revokedCount: 1, + }); + + const output = new PassThrough(); + + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + expect(archiver).toHaveBeenCalledWith('zip', { zlib: { level: 9 } }); + expect(mockArchive.pipe).toHaveBeenCalledWith(output); + expect(mockArchive.finalize).toHaveBeenCalled(); + + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + expect(appendCalls).toHaveLength(2); + + // Summary CSV + const summaryCsv = appendCalls[0][0] as string; + expect(summaryCsv).toContain('Item,Status,Completed By'); + expect(summaryCsv).toContain('"Revoke system access",Complete,"Jane Doe"'); + expect(summaryCsv).toContain('"Recover devices",Pending,""'); + expect(appendCalls[0][1]).toEqual({ name: 'summary.csv' }); + + // Vendor revocations CSV + const vendorCsv = appendCalls[1][0] as string; + expect(vendorCsv).toContain('Vendor,Confirmed By,Date,Has Evidence'); + expect(vendorCsv).toContain('"Slack","Jane Doe",2026-05-01,No'); + expect(appendCalls[1][1]).toEqual({ + name: 'vendor-access-revocations/vendor-access-revocations.csv', + }); + }); + + it('includes vendor evidence files from S3', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [], + totalItems: 0, + completedItems: 0, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [ + { + vendorName: 'AWS', + revokedBy: { name: 'Jane' }, + revokedAt: new Date('2026-05-01'), + evidence: [{ id: 'att_1', name: 'aws-disable.png' }], + }, + ], + totalVendors: 1, + revokedCount: 1, + }); + + mockDb.attachment.findFirst.mockResolvedValue({ + id: 'att_1', + url: 'org_1/attachments/offboarding/aws-disable.png', + }); + mockAttachmentsService.getObjectBuffer.mockResolvedValue( + Buffer.from('fake-png-data'), + ); + + const output = new PassThrough(); + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + const evidenceCall = appendCalls.find( + (c: unknown[]) => + (c[1] as { name: string }).name === + 'vendor-access-revocations/evidence/aws-disable.png', + ); + expect(evidenceCall).toBeDefined(); + expect(Buffer.isBuffer(evidenceCall[0])).toBe(true); + }); + + it('includes checklist item evidence in numbered folders', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [ + { + title: 'Recover devices', + completed: true, + completion: { + completedBy: { name: 'Jane' }, + completedAt: new Date(), + }, + evidence: [{ id: 'att_2', name: 'device-receipt.pdf' }], + }, + ], + totalItems: 1, + completedItems: 1, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [], + totalVendors: 0, + revokedCount: 0, + }); + + mockDb.attachment.findFirst.mockResolvedValue({ + id: 'att_2', + url: 'org_1/attachments/offboarding/device-receipt.pdf', + }); + mockAttachmentsService.getObjectBuffer.mockResolvedValue( + Buffer.from('fake-pdf-data'), + ); + + const output = new PassThrough(); + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + const evidenceCall = appendCalls.find( + (c: unknown[]) => + (c[1] as { name: string }).name === + 'checklist-items/01-recover-devices/device-receipt.pdf', + ); + expect(evidenceCall).toBeDefined(); + }); + + it('skips evidence files that fail to download', async () => { + mockChecklistService.getMemberChecklist.mockResolvedValue({ + items: [ + { + title: 'Task with broken evidence', + completed: true, + completion: { + completedBy: { name: 'Jane' }, + completedAt: new Date(), + }, + evidence: [{ id: 'att_bad', name: 'missing.pdf' }], + }, + ], + totalItems: 1, + completedItems: 1, + }); + + mockAccessRevocationService.getAccessRevocations.mockResolvedValue({ + vendors: [], + totalVendors: 0, + revokedCount: 0, + }); + + mockDb.attachment.findFirst.mockResolvedValue(null); + + const output = new PassThrough(); + await service.exportMemberEvidence({ + organizationId: 'org_1', + memberId: 'mem_1', + output, + }); + + // Should have only 2 appends: summary CSV + vendor CSV (no evidence files) + const appendCalls = (mockArchive.append as jest.Mock).mock.calls; + expect(appendCalls).toHaveLength(2); + expect(mockArchive.finalize).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/offboarding-checklist/offboarding-export.service.ts b/apps/api/src/offboarding-checklist/offboarding-export.service.ts new file mode 100644 index 0000000000..034f100c88 --- /dev/null +++ b/apps/api/src/offboarding-checklist/offboarding-export.service.ts @@ -0,0 +1,236 @@ +import { Injectable } from '@nestjs/common'; +import { db } from '@db'; +import archiver from 'archiver'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { AccessRevocationService } from './access-revocation.service'; +import { OffboardingChecklistService } from './offboarding-checklist.service'; + +type ChecklistItems = Awaited< + ReturnType +>['items']; + +type VendorList = Awaited< + ReturnType +>['vendors']; + +@Injectable() +export class OffboardingExportService { + constructor( + private readonly attachmentsService: AttachmentsService, + private readonly accessRevocationService: AccessRevocationService, + private readonly offboardingChecklistService: OffboardingChecklistService, + ) {} + + async exportMemberEvidence({ + organizationId, + memberId, + output, + }: { + organizationId: string; + memberId: string; + output: NodeJS.WritableStream; + }) { + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err) => { + archive.abort(); + if ('destroy' in output && typeof (output as { destroy?: unknown }).destroy === 'function') { + (output as { destroy: (err: Error) => void }).destroy(err); + } + }); + archive.pipe(output); + + const checklist = + await this.offboardingChecklistService.getMemberChecklist( + organizationId, + memberId, + ); + const revocations = + await this.accessRevocationService.getAccessRevocations( + organizationId, + memberId, + ); + + this.appendSummaryCsv(archive, checklist.items); + this.appendVendorRevocationsCsv(archive, revocations.vendors); + await this.appendVendorEvidence( + archive, + organizationId, + revocations.vendors, + ); + await this.appendChecklistEvidence( + archive, + organizationId, + checklist.items, + ); + + await archive.finalize(); + } + + private appendSummaryCsv( + archive: archiver.Archiver, + items: ChecklistItems, + prefix = '', + ) { + const rows = [ + 'Item,Status,Completed By,Completed Date,Evidence Count', + ...items.map((item) => { + const status = item.completed ? 'Complete' : 'Pending'; + const completedBy = item.completion?.completedBy?.name ?? ''; + const completedDate = item.completion?.completedAt + ? new Date(item.completion.completedAt).toISOString().split('T')[0] + : ''; + return `"${escapeCsvField(item.title)}",${status},"${escapeCsvField(completedBy)}",${completedDate},${item.evidence.length}`; + }), + ]; + archive.append(rows.join('\n'), { name: `${prefix}summary.csv` }); + } + + private appendVendorRevocationsCsv( + archive: archiver.Archiver, + vendors: VendorList, + prefix = '', + ) { + const rows = [ + 'Vendor,Confirmed By,Date,Has Evidence', + ...vendors.map((v) => { + const confirmedBy = v.revokedBy?.name ?? ''; + const date = v.revokedAt + ? new Date(v.revokedAt).toISOString().split('T')[0] + : ''; + const hasEvidence = (v.evidence?.length ?? 0) > 0 ? 'Yes' : 'No'; + return `"${escapeCsvField(v.vendorName)}","${escapeCsvField(confirmedBy)}",${date},${hasEvidence}`; + }), + ]; + archive.append(rows.join('\n'), { + name: `${prefix}vendor-access-revocations/vendor-access-revocations.csv`, + }); + } + + private async appendVendorEvidence( + archive: archiver.Archiver, + organizationId: string, + vendors: VendorList, + prefix = '', + ) { + for (const vendor of vendors) { + if (!vendor.evidence || vendor.evidence.length === 0) continue; + for (const file of vendor.evidence) { + const buffer = await this.getAttachmentBuffer(organizationId, file.id); + if (!buffer) continue; + const safeName = sanitizeFileName(file.name); + archive.append(buffer, { + name: `${prefix}vendor-access-revocations/evidence/${file.id}-${safeName}`, + }); + } + } + } + + private async appendChecklistEvidence( + archive: archiver.Archiver, + organizationId: string, + items: ChecklistItems, + prefix = '', + ) { + for (let i = 0; i < items.length; i++) { + const item = items[i]; + if (item.evidence.length === 0) continue; + const folderNum = String(i + 1).padStart(2, '0'); + const folderName = item.title + .replace(/[^a-zA-Z0-9 ]/g, '') + .replace(/\s+/g, '-') + .toLowerCase(); + for (const file of item.evidence) { + const buffer = await this.getAttachmentBuffer(organizationId, file.id); + if (!buffer) continue; + const safeName = sanitizeFileName(file.name); + archive.append(buffer, { + name: `${prefix}checklist-items/${folderNum}-${folderName}/${file.id}-${safeName}`, + }); + } + } + } + + async exportAllOffboardings({ + organizationId, + output, + }: { + organizationId: string; + output: NodeJS.WritableStream; + }) { + const archive = archiver('zip', { zlib: { level: 9 } }); + archive.on('error', (err) => { + archive.abort(); + if ('destroy' in output && typeof (output as { destroy?: unknown }).destroy === 'function') { + (output as { destroy: (err: Error) => void }).destroy(err); + } + }); + archive.pipe(output); + + const BATCH_SIZE = 50; + let cursor: string | undefined; + + while (true) { + const batch = await db.member.findMany({ + where: { organizationId, offboardDate: { not: null }, deactivated: true }, + include: { user: { select: { name: true, email: true } } }, + orderBy: [{ offboardDate: 'desc' }, { id: 'asc' }], + take: BATCH_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + + for (const member of batch) { + const safeName = (member.user.name ?? 'member') + .replace(/[^a-zA-Z0-9 ]/g, '') + .replace(/\s+/g, '-') + .toLowerCase(); + const prefix = `offboarded-employees/${safeName}-${member.id}/`; + + const checklist = await this.offboardingChecklistService.getMemberChecklist( + organizationId, + member.id, + ); + const revocations = await this.accessRevocationService.getAccessRevocations( + organizationId, + member.id, + ); + + this.appendSummaryCsv(archive, checklist.items, prefix); + this.appendVendorRevocationsCsv(archive, revocations.vendors, prefix); + await this.appendVendorEvidence(archive, organizationId, revocations.vendors, prefix); + await this.appendChecklistEvidence(archive, organizationId, checklist.items, prefix); + } + + if (batch.length < BATCH_SIZE) break; + cursor = batch[batch.length - 1]!.id; + } + + await archive.finalize(); + } + + private async getAttachmentBuffer( + organizationId: string, + attachmentId: string, + ): Promise { + try { + const attachment = await db.attachment.findFirst({ + where: { id: attachmentId, organizationId }, + }); + if (!attachment) return null; + return await this.attachmentsService.getObjectBuffer(attachment.url); + } catch { + return null; + } + } +} + +function sanitizeFileName(name: string): string { + return name.replace(/.*[/\\]/, '').replace(/[/\\]/g, '_') || 'file'; +} + +function escapeCsvField(value: string): string { + const escaped = value.replace(/"/g, '""'); + if (/^[=+\-@\t\r]/.test(escaped)) { + return `'${escaped}`; + } + return escaped; +} diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts index 8987919ea8..268bf7101a 100644 --- a/apps/api/src/people/dto/update-people.dto.ts +++ b/apps/api/src/people/dto/update-people.dto.ts @@ -77,4 +77,22 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) { @IsString() @MaxLength(2000) backgroundCheckExemptJustification?: string; + + @ApiProperty({ + description: 'Employee onboard date', + example: '2026-01-15T00:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString() + onboardDate?: string | null; + + @ApiProperty({ + description: 'Employee offboard date', + example: '2026-04-30T00:00:00.000Z', + required: false, + }) + @IsOptional() + @IsDateString() + offboardDate?: string | null; } diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index e93c62580d..b1751ad936 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -1,6 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; +import { AttachmentsService } from '../attachments/attachments.service'; import type { AuthContext } from '../auth/types'; import { HybridAuthGuard } from '../auth/hybrid-auth.guard'; import { PermissionGuard } from '../auth/permission.guard'; @@ -83,6 +84,12 @@ describe('PeopleController', () => { inviteMembers: jest.fn(), }; + const mockAttachmentsService = { + getAttachments: jest.fn(), + uploadAttachment: jest.fn(), + deleteAttachment: jest.fn(), + }; + const mockGuard = { canActivate: jest.fn().mockReturnValue(true) }; const mockAuthContext: AuthContext = { @@ -101,6 +108,7 @@ describe('PeopleController', () => { providers: [ { provide: PeopleService, useValue: mockPeopleService }, { provide: PeopleInviteService, useValue: mockPeopleInviteService }, + { provide: AttachmentsService, useValue: mockAttachmentsService }, ], }) .overrideGuard(HybridAuthGuard) @@ -136,6 +144,7 @@ describe('PeopleController', () => { expect(peopleService.findAllByOrganization).toHaveBeenCalledWith( 'org_123', false, + undefined, ); }); @@ -147,6 +156,7 @@ describe('PeopleController', () => { expect(peopleService.findAllByOrganization).toHaveBeenCalledWith( 'org_123', true, + undefined, ); }); diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts index e0ee6eee3b..c6a42263b8 100644 --- a/apps/api/src/people/people.controller.ts +++ b/apps/api/src/people/people.controller.ts @@ -36,6 +36,9 @@ import { BulkCreatePeopleDto } from './dto/bulk-create-people.dto'; import { InvitePeopleDto } from './dto/invite-people.dto'; import { PeopleResponseDto, UserResponseDto } from './dto/people-responses.dto'; import { UpdateEmailPreferencesDto } from './dto/update-email-preferences.dto'; +import { AttachmentsService } from '../attachments/attachments.service'; +import { UploadAttachmentDto } from '../attachments/upload-attachment.dto'; +import { AttachmentEntityType } from '@db'; import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; import { GET_ALL_PEOPLE_RESPONSES } from './schemas/get-all-people.responses'; @@ -58,8 +61,18 @@ export class PeopleController { constructor( private readonly peopleService: PeopleService, private readonly peopleInviteService: PeopleInviteService, + private readonly attachmentsService: AttachmentsService, ) {} + private resolveEventType(eventType: string): AttachmentEntityType { + if (eventType === 'onboard') return AttachmentEntityType.employment_onboard; + if (eventType === 'offboard') + return AttachmentEntityType.employment_offboard; + throw new BadRequestException( + `Invalid event type "${eventType}". Must be "onboard" or "offboard".`, + ); + } + @Post('invite') @RequirePermission('member', 'create') @ApiOperation({ summary: 'Invite members to the organization' }) @@ -99,10 +112,33 @@ export class PeopleController { @OrganizationId() organizationId: string, @AuthContext() authContext: AuthContextType, @Query('includeDeactivated') includeDeactivated?: string, + @Query('onboardAfter') onboardAfter?: string, + @Query('onboardBefore') onboardBefore?: string, + @Query('offboardAfter') offboardAfter?: string, + @Query('offboardBefore') offboardBefore?: string, ) { + const parseDateParam = (param: string | undefined, name: string): Date | undefined => { + if (!param) return undefined; + const date = new Date(param); + if (isNaN(date.getTime())) { + throw new BadRequestException(`Invalid date value for "${name}": ${param}`); + } + return date; + }; + + const filters = { + ...(onboardAfter ? { onboardAfter: parseDateParam(onboardAfter, 'onboardAfter') } : {}), + ...(onboardBefore ? { onboardBefore: parseDateParam(onboardBefore, 'onboardBefore') } : {}), + ...(offboardAfter ? { offboardAfter: parseDateParam(offboardAfter, 'offboardAfter') } : {}), + ...(offboardBefore ? { offboardBefore: parseDateParam(offboardBefore, 'offboardBefore') } : {}), + }; + + const hasFilters = Object.keys(filters).length > 0; + const people = await this.peopleService.findAllByOrganization( organizationId, includeDeactivated === 'true', + hasFilters ? filters : undefined, ); return { @@ -529,6 +565,77 @@ export class PeopleController { }; } + @Get(':id/employment-evidence/:eventType') + @RequirePermission('member', 'read') + @ApiOperation({ summary: 'Get employment evidence attachments' }) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam({ name: 'eventType', enum: ['onboard', 'offboard'] }) + async getEmploymentEvidence( + @Param('id') memberId: string, + @Param('eventType') eventType: string, + @OrganizationId() organizationId: string, + ) { + const entityType = this.resolveEventType(eventType); + await this.peopleService.findById(memberId, organizationId); + return this.attachmentsService.getAttachments( + organizationId, + memberId, + entityType, + ); + } + + @Post(':id/employment-evidence/:eventType') + @RequirePermission('member', 'update') + @ApiOperation({ summary: 'Upload employment evidence' }) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam({ name: 'eventType', enum: ['onboard', 'offboard'] }) + async uploadEmploymentEvidence( + @Param('id') memberId: string, + @Param('eventType') eventType: string, + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() uploadDto: UploadAttachmentDto, + ) { + if (!authContext.userId) { + throw new BadRequestException('User context required for this operation'); + } + const entityType = this.resolveEventType(eventType); + await this.peopleService.findById(memberId, organizationId); + return this.attachmentsService.uploadAttachment( + organizationId, + memberId, + entityType, + uploadDto, + authContext.userId, + ); + } + + @Delete(':id/employment-evidence/:eventType/:attachmentId') + @RequirePermission('member', 'delete') + @ApiOperation({ summary: 'Delete employment evidence' }) + @ApiParam(PEOPLE_PARAMS.memberId) + @ApiParam({ name: 'eventType', enum: ['onboard', 'offboard'] }) + @ApiParam({ name: 'attachmentId', description: 'Attachment ID' }) + async deleteEmploymentEvidence( + @Param('id') memberId: string, + @Param('eventType') eventType: string, + @Param('attachmentId') attachmentId: string, + @OrganizationId() organizationId: string, + ) { + const entityType = this.resolveEventType(eventType); + await this.peopleService.findById(memberId, organizationId); + const attachments = await this.attachmentsService.getAttachments( + organizationId, + memberId, + entityType, + ); + if (!attachments.some((a) => a.id === attachmentId)) { + throw new BadRequestException('Attachment not found for this member and event type'); + } + await this.attachmentsService.deleteAttachment(organizationId, attachmentId); + return { success: true }; + } + @Get('me/email-preferences') @ApiOperation({ summary: 'Get current user email notification preferences' }) async getEmailPreferences( diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts index 8d26064f9f..9590239747 100644 --- a/apps/api/src/people/people.module.ts +++ b/apps/api/src/people/people.module.ts @@ -1,5 +1,6 @@ import { Module } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; +import { AttachmentsModule } from '../attachments/attachments.module'; import { TimelinesModule } from '../timelines/timelines.module'; import { FleetService } from '../lib/fleet.service'; import { PeopleController } from './people.controller'; @@ -7,7 +8,7 @@ import { PeopleService } from './people.service'; import { PeopleInviteService } from './people-invite.service'; @Module({ - imports: [AuthModule, TimelinesModule], + imports: [AuthModule, AttachmentsModule, TimelinesModule], controllers: [PeopleController], providers: [PeopleService, PeopleInviteService, FleetService], exports: [PeopleService], diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts index 09e712a772..3be40dd442 100644 --- a/apps/api/src/people/people.service.ts +++ b/apps/api/src/people/people.service.ts @@ -36,12 +36,19 @@ export class PeopleService { async findAllByOrganization( organizationId: string, includeDeactivated?: boolean, + filters?: { + onboardAfter?: Date; + onboardBefore?: Date; + offboardAfter?: Date; + offboardBefore?: Date; + }, ): Promise { try { await MemberValidator.validateOrganization(organizationId); const members = await MemberQueries.findAllByOrganization( organizationId, includeDeactivated, + filters, ); this.logger.log( @@ -395,7 +402,11 @@ export class PeopleService { await db.member.update({ where: { id: memberId, organizationId }, - data: { deactivated: true, isActive: false }, + data: { + deactivated: true, + isActive: false, + offboardDate: member.offboardDate ?? new Date(), + }, }); // Direct DB session deletion is correct here — the API server IS the auth server, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index da7ecc92fa..d9f70cd356 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -23,6 +23,8 @@ export class MemberQueries { backgroundCheckExempt: true, backgroundCheckExemptReason: true, backgroundCheckExemptJustification: true, + onboardDate: true, + offboardDate: true, fleetDmLabelId: true, user: { select: { @@ -54,11 +56,33 @@ export class MemberQueries { static async findAllByOrganization( organizationId: string, includeDeactivated = false, + filters?: { + onboardAfter?: Date; + onboardBefore?: Date; + offboardAfter?: Date; + offboardBefore?: Date; + }, ): Promise { return db.member.findMany({ where: { organizationId, ...(includeDeactivated ? {} : { deactivated: false }), + ...(filters?.onboardAfter || filters?.onboardBefore + ? { + onboardDate: { + ...(filters.onboardAfter ? { gte: filters.onboardAfter } : {}), + ...(filters.onboardBefore ? { lte: filters.onboardBefore } : {}), + }, + } + : {}), + ...(filters?.offboardAfter || filters?.offboardBefore + ? { + offboardDate: { + ...(filters.offboardAfter ? { gte: filters.offboardAfter } : {}), + ...(filters.offboardBefore ? { lte: filters.offboardBefore } : {}), + }, + } + : {}), }, select: this.MEMBER_SELECT, orderBy: { createdAt: 'desc' }, @@ -112,7 +136,7 @@ export class MemberQueries { updateData: UpdatePeopleDto, ): Promise { // Separate user-level fields from member-level fields - const { name, email, createdAt, ...memberFields } = updateData; + const { name, email, createdAt, onboardDate, offboardDate, ...memberFields } = updateData; // Prepare member update data const updatePayload: any = { ...memberFields }; @@ -122,6 +146,13 @@ export class MemberQueries { updatePayload.createdAt = new Date(createdAt); } + if (onboardDate !== undefined) { + updatePayload.onboardDate = onboardDate ? new Date(onboardDate) : null; + } + if (offboardDate !== undefined) { + updatePayload.offboardDate = offboardDate ? new Date(offboardDate) : null; + } + // Handle fleetDmLabelId: convert undefined to null for database if ( memberFields.fleetDmLabelId === undefined && diff --git a/apps/api/src/tasks/evidence-export/evidence-data-loader.ts b/apps/api/src/tasks/evidence-export/evidence-data-loader.ts new file mode 100644 index 0000000000..6d0da40c56 --- /dev/null +++ b/apps/api/src/tasks/evidence-export/evidence-data-loader.ts @@ -0,0 +1,405 @@ +import { NotFoundException } from '@nestjs/common'; +import { db, AttachmentEntityType } from '@db'; +import type { + TaskEvidenceSummary, + NormalizedAutomation, + NormalizedEvidenceRun, +} from './evidence-export.types'; +import { + type AppAutomationRun, + type CustomAutomationRun, + normalizeAppAutomationRun, + normalizeCustomAutomationRun, + normalizeStatus, +} from './evidence-normalizer'; + +const RUN_BATCH_SIZE = 50; + +interface AppRunHeader { + checkId: string; + checkName: string; + status: string; + failedCount: number; + createdAt: Date; + connection: { provider: { slug: string; name: string } | null }; +} + +interface CustomRunHeader { + status: string; + evaluationStatus: string | null; + createdAt: Date; + evidenceAutomation: { id: string; name: string }; +} + +function groupAppRunHeaders(runs: AppRunHeader[]): NormalizedAutomation[] { + const grouped = new Map(); + + for (const run of runs) { + const key = run.checkId; + const status = normalizeStatus(run.status); + + if (!grouped.has(key)) { + grouped.set(key, { + id: run.checkId, + name: run.checkName, + type: 'app_automation', + integrationName: run.connection.provider?.name, + checkId: run.checkId, + runs: [], + totalRuns: 0, + successfulRuns: 0, + failedRuns: 0, + latestRunAt: null, + }); + } + + const automation = grouped.get(key)!; + automation.totalRuns++; + + if (status === 'success' && run.failedCount === 0) { + automation.successfulRuns++; + } else if (status === 'failed' || run.failedCount > 0) { + automation.failedRuns++; + } + + if (!automation.latestRunAt || run.createdAt > automation.latestRunAt) { + automation.latestRunAt = run.createdAt; + } + } + + return Array.from(grouped.values()); +} + +function groupCustomRunHeaders( + runs: CustomRunHeader[], +): NormalizedAutomation[] { + const grouped = new Map(); + + for (const run of runs) { + const key = run.evidenceAutomation.id; + const status = normalizeStatus(run.status); + + if (!grouped.has(key)) { + grouped.set(key, { + id: run.evidenceAutomation.id, + name: run.evidenceAutomation.name, + type: 'custom_automation', + runs: [], + totalRuns: 0, + successfulRuns: 0, + failedRuns: 0, + latestRunAt: null, + }); + } + + const automation = grouped.get(key)!; + automation.totalRuns++; + + if (run.evaluationStatus === 'pass') { + automation.successfulRuns++; + } else if (run.evaluationStatus === 'fail' || status === 'failed') { + automation.failedRuns++; + } + + if (!automation.latestRunAt || run.createdAt > automation.latestRunAt) { + automation.latestRunAt = run.createdAt; + } + } + + return Array.from(grouped.values()); +} + +// Lightweight metadata query — no run results or logs loaded. +export async function getAutomationHeaders({ + organizationId, + taskId, +}: { + organizationId: string; + taskId: string; +}): Promise { + const task = await db.task.findFirst({ + where: { id: taskId, organizationId }, + include: { organization: { select: { name: true } } }, + }); + + if (!task) { + throw new NotFoundException('Task not found'); + } + + const HEADER_BATCH = 500; + const appRuns: AppRunHeader[] = []; + let appCursor: string | undefined; + while (true) { + const batch = await db.integrationCheckRun.findMany({ + where: { taskId }, + select: { + id: true, + checkId: true, + checkName: true, + status: true, + failedCount: true, + createdAt: true, + connection: { + select: { provider: { select: { slug: true, name: true } } }, + }, + }, + orderBy: { createdAt: 'desc' }, + take: HEADER_BATCH, + ...(appCursor ? { cursor: { id: appCursor }, skip: 1 } : {}), + }); + appRuns.push(...batch); + if (batch.length < HEADER_BATCH) break; + appCursor = batch[batch.length - 1]!.id; + } + + const customRuns: CustomRunHeader[] = []; + let customCursor: string | undefined; + while (true) { + const batch = await db.evidenceAutomationRun.findMany({ + where: { + evidenceAutomation: { taskId }, + version: { not: null }, + }, + select: { + id: true, + status: true, + evaluationStatus: true, + createdAt: true, + evidenceAutomation: { select: { id: true, name: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: HEADER_BATCH, + ...(customCursor ? { cursor: { id: customCursor }, skip: 1 } : {}), + }); + customRuns.push(...batch); + if (batch.length < HEADER_BATCH) break; + customCursor = batch[batch.length - 1]!.id; + } + + return { + taskId: task.id, + taskTitle: task.title, + organizationId, + organizationName: task.organization.name, + automations: [ + ...groupAppRunHeaders(appRuns), + ...groupCustomRunHeaders(customRuns), + ], + exportedAt: new Date(), + }; +} + +// Loads one automation's runs in batches to bound both JSON.parse and heap pressure. +export async function loadFullAutomation({ + taskId, + header, +}: { + taskId: string; + header: NormalizedAutomation; +}): Promise { + if (header.type === 'app_automation' && header.checkId) { + return loadAppAutomationRuns(taskId, header); + } + return loadCustomAutomationRuns(taskId, header); +} + +async function loadAppAutomationRuns( + taskId: string, + header: NormalizedAutomation, +): Promise { + const runs: NormalizedEvidenceRun[] = []; + let cursor: { id: string } | undefined; + + for (;;) { + const batch = await db.integrationCheckRun.findMany({ + where: { taskId, checkId: header.checkId }, + include: { + results: true, + connection: { include: { provider: true } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: RUN_BATCH_SIZE, + ...(cursor ? { cursor, skip: 1 } : {}), + }); + + if (batch.length === 0) break; + + for (const run of batch) { + runs.push(normalizeAppAutomationRun(toAppAutomationRun(run))); + } + + if (batch.length < RUN_BATCH_SIZE) break; + cursor = { id: batch[batch.length - 1].id }; + } + + return { ...header, runs }; +} + +async function loadCustomAutomationRuns( + taskId: string, + header: NormalizedAutomation, +): Promise { + const runs: NormalizedEvidenceRun[] = []; + let cursor: { id: string } | undefined; + + for (;;) { + const batch = await db.evidenceAutomationRun.findMany({ + where: { + evidenceAutomation: { id: header.id, taskId }, + version: { not: null }, + }, + include: { + evidenceAutomation: { select: { id: true, name: true } }, + }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: RUN_BATCH_SIZE, + ...(cursor ? { cursor, skip: 1 } : {}), + }); + + if (batch.length === 0) break; + + for (const run of batch) { + runs.push(normalizeCustomAutomationRun(toCustomAutomationRun(run))); + } + + if (batch.length < RUN_BATCH_SIZE) break; + cursor = { id: batch[batch.length - 1].id }; + } + + return { ...header, runs }; +} + +// Prisma result → normalizer interface mappers (single source of truth). +function toAppAutomationRun(run: { + id: string; + checkId: string; + checkName: string; + status: string; + startedAt: Date | null; + completedAt: Date | null; + durationMs: number | null; + totalChecked: number; + passedCount: number; + failedCount: number; + errorMessage: string | null; + logs: unknown; + createdAt: Date; + connection: { provider: { slug: string; name: string } | null }; + results: Array<{ + id: string; + passed: boolean; + resourceType: string; + resourceId: string; + title: string; + description: string | null; + severity: string | null; + remediation: string | null; + evidence: unknown; + collectedAt: Date; + }>; +}): AppAutomationRun { + return { + id: run.id, + checkId: run.checkId, + checkName: run.checkName, + status: run.status, + startedAt: run.startedAt, + completedAt: run.completedAt, + durationMs: run.durationMs, + totalChecked: run.totalChecked, + passedCount: run.passedCount, + failedCount: run.failedCount, + errorMessage: run.errorMessage, + logs: run.logs, + createdAt: run.createdAt, + connection: { + provider: run.connection.provider + ? { + slug: run.connection.provider.slug, + name: run.connection.provider.name, + } + : undefined, + }, + results: run.results.map((r) => ({ + id: r.id, + passed: r.passed, + resourceType: r.resourceType, + resourceId: r.resourceId, + title: r.title, + description: r.description, + severity: r.severity, + remediation: r.remediation, + evidence: r.evidence, + collectedAt: r.collectedAt, + })), + }; +} + +function toCustomAutomationRun(run: { + id: string; + status: string; + startedAt: Date | null; + completedAt: Date | null; + runDuration: number | null; + success: boolean | null; + error: string | null; + logs: unknown; + output: unknown; + evaluationStatus: string | null; + evaluationReason: string | null; + createdAt: Date; + evidenceAutomation: { id: string; name: string }; +}): CustomAutomationRun { + return { + id: run.id, + status: run.status, + startedAt: run.startedAt, + completedAt: run.completedAt, + runDuration: run.runDuration, + success: run.success, + error: run.error, + logs: run.logs, + output: run.output, + evaluationStatus: run.evaluationStatus, + evaluationReason: run.evaluationReason, + createdAt: run.createdAt, + evidenceAutomation: { + id: run.evidenceAutomation.id, + name: run.evidenceAutomation.name, + }, + }; +} + +export async function findTasksWithEvidence( + organizationId: string, +): Promise { + const [tasksWithRuns, taskAttachments] = await Promise.all([ + db.task.findMany({ + where: { + organizationId, + OR: [ + { integrationCheckRuns: { some: {} } }, + { + evidenceAutomations: { + some: { runs: { some: { version: { not: null } } } }, + }, + }, + ], + }, + select: { id: true }, + }), + db.attachment.findMany({ + where: { + organizationId, + entityType: AttachmentEntityType.task, + }, + select: { entityId: true }, + distinct: ['entityId'], + }), + ]); + + const ids = new Set(); + for (const t of tasksWithRuns) ids.add(t.id); + for (const a of taskAttachments) ids.add(a.entityId); + return Array.from(ids); +} diff --git a/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts b/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts index 22bcf409d2..ab6de156d8 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.service.spec.ts @@ -473,6 +473,101 @@ describe('EvidenceExportService — streaming ZIPs', () => { ); }); + it('loads each automation individually instead of all runs at once (OOM fix)', async () => { + const appRuns = [ + { + id: 'icr_1', + checkId: 'mfa-check', + checkName: 'MFA Enabled', + status: 'success', + startedAt: new Date('2024-01-15'), + completedAt: new Date('2024-01-15'), + durationMs: 5000, + totalChecked: 1, + passedCount: 1, + failedCount: 0, + errorMessage: null, + logs: { entries: ['checked MFA'] }, + createdAt: new Date('2024-01-15'), + connection: { provider: { slug: 'gws', name: 'Google Workspace' } }, + results: [ + { + id: 'r1', + passed: true, + resourceType: 'user', + resourceId: 'u1', + title: 'MFA active', + description: null, + severity: null, + remediation: null, + evidence: { mfa: true }, + collectedAt: new Date(), + }, + ], + }, + { + id: 'icr_2', + checkId: 'access-review', + checkName: 'Access Review', + status: 'success', + startedAt: new Date('2024-01-16'), + completedAt: new Date('2024-01-16'), + durationMs: 3000, + totalChecked: 2, + passedCount: 2, + failedCount: 0, + errorMessage: null, + logs: null, + createdAt: new Date('2024-01-16'), + connection: { provider: { slug: 'gws', name: 'Google Workspace' } }, + results: [], + }, + ]; + + mockDb.task.findFirst.mockResolvedValue(taskRow); + mockDb.attachment.findMany.mockResolvedValue([]); + mockDb.evidenceAutomationRun.findMany.mockResolvedValue([]); + mockDb.integrationCheckRun.findMany.mockImplementation( + (args: { where: { checkId?: string } }) => { + if (args.where.checkId) { + return Promise.resolve( + appRuns.filter((r) => r.checkId === args.where.checkId), + ); + } + return Promise.resolve(appRuns); + }, + ); + + const { archive } = await service.streamTaskEvidenceZip( + 'org_1', + 'tsk_123', + ); + const mock = archive as unknown as MockArchive; + await mock.finalized; + + const paths = mock.appendCalls.map((c) => c.options.name); + + // Both automations get their own subfolder and PDF + expect(paths.filter((p) => /\/evidence\.pdf$/.test(p))).toHaveLength(2); + expect(paths.some((p) => p.includes('/app-mfa-enabled-'))).toBe(true); + expect(paths.some((p) => p.includes('/app-access-review-'))).toBe(true); + + // Verify per-automation loading: findMany is called with individual + // checkId filters (not a single bulk load of all results). + const findManyCalls = mockDb.integrationCheckRun.findMany.mock.calls; + const perAutomationCalls = findManyCalls.filter( + (call: unknown[]) => + (call[0] as { where: { checkId?: string } }).where.checkId, + ); + expect(perAutomationCalls).toHaveLength(2); + const loadedCheckIds = perAutomationCalls.map( + (call: unknown[]) => + (call[0] as { where: { checkId: string } }).where.checkId, + ); + expect(loadedCheckIds).toContain('mfa-check'); + expect(loadedCheckIds).toContain('access-review'); + }); + it('produces a summary-only ZIP when task has neither automations nor attachments', async () => { primeTaskQueries({ attachments: [], appRuns: [], customRuns: [] }); diff --git a/apps/api/src/tasks/evidence-export/evidence-export.service.ts b/apps/api/src/tasks/evidence-export/evidence-export.service.ts index 2e98851eb1..cc77678d57 100644 --- a/apps/api/src/tasks/evidence-export/evidence-export.service.ts +++ b/apps/api/src/tasks/evidence-export/evidence-export.service.ts @@ -1,6 +1,5 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { db } from '@db'; -import { AttachmentEntityType } from '@db'; import archiver, { type Archiver } from 'archiver'; import { format } from 'date-fns'; import { configure as configureStringify } from 'safe-stable-stringify'; @@ -10,21 +9,24 @@ import type { EvidenceZipStream, NormalizedAutomation, } from './evidence-export.types'; -import { buildTaskEvidenceSummary } from './evidence-normalizer'; import { generateAutomationPDF, generateTaskSummaryPDF, sanitizeFilename, } from './evidence-pdf-generator'; -import { redactSensitiveData } from './evidence-redaction'; +import { buildAutomationJson } from './evidence-json-builder'; import { appendAttachmentToArchive, createFilenameTracker, getTaskAttachments, type TaskAttachment, } from './evidence-attachment-streamer'; +import { + getAutomationHeaders, + loadFullAutomation, + findTasksWithEvidence, +} from './evidence-data-loader'; -// Configure safe stringify to handle BigInt, circular refs, etc. const safeStringify = configureStringify({ bigint: true, circularValue: '[Circular]', @@ -34,151 +36,49 @@ const safeStringify = configureStringify({ @Injectable() export class EvidenceExportService { private readonly logger = new Logger(EvidenceExportService.name); - - /** - * Get task evidence summary with all automation runs - */ + // Used by the JSON summary endpoint — loads automations sequentially via the data loader. async getTaskEvidenceSummary( organizationId: string, taskId: string, ): Promise { - this.logger.log('Building task evidence summary', { - organizationId, - taskId, - }); - const task = await db.task.findFirst({ - where: { - id: taskId, - organizationId, - }, - include: { - organization: { - select: { name: true }, - }, - }, - }); + const headers = await getAutomationHeaders({ organizationId, taskId }); - if (!task) { - throw new NotFoundException('Task not found'); + const automations: NormalizedAutomation[] = []; + for (const header of headers.automations) { + automations.push(await loadFullAutomation({ taskId, header })); } - const appAutomationRuns = await db.integrationCheckRun.findMany({ - where: { taskId }, - include: { - results: true, - connection: { - include: { - provider: true, - }, - }, - }, - orderBy: { createdAt: 'desc' }, - }); - - // Only include published custom automation runs (version !== null). - const customAutomationRuns = await db.evidenceAutomationRun.findMany({ - where: { - evidenceAutomation: { taskId }, - version: { not: null }, - }, - include: { - evidenceAutomation: { - select: { id: true, name: true }, - }, - }, - orderBy: { createdAt: 'desc' }, - }); - - const summary = buildTaskEvidenceSummary({ - taskId: task.id, - taskTitle: task.title, - organizationId, - organizationName: task.organization.name, - appAutomationRuns: appAutomationRuns.map((run) => ({ - id: run.id, - checkId: run.checkId, - checkName: run.checkName, - status: run.status, - startedAt: run.startedAt, - completedAt: run.completedAt, - durationMs: run.durationMs, - totalChecked: run.totalChecked, - passedCount: run.passedCount, - failedCount: run.failedCount, - errorMessage: run.errorMessage, - logs: run.logs, - createdAt: run.createdAt, - connection: { - provider: run.connection.provider - ? { - slug: run.connection.provider.slug, - name: run.connection.provider.name, - } - : undefined, - }, - results: run.results.map((r) => ({ - id: r.id, - passed: r.passed, - resourceType: r.resourceType, - resourceId: r.resourceId, - title: r.title, - description: r.description, - severity: r.severity, - remediation: r.remediation, - evidence: r.evidence, - collectedAt: r.collectedAt, - })), - })), - customAutomationRuns: customAutomationRuns.map((run) => ({ - id: run.id, - status: run.status, - startedAt: run.startedAt, - completedAt: run.completedAt, - runDuration: run.runDuration, - success: run.success, - error: run.error, - logs: run.logs, - output: run.output, - evaluationStatus: run.evaluationStatus, - evaluationReason: run.evaluationReason, - createdAt: run.createdAt, - evidenceAutomation: { - id: run.evidenceAutomation.id, - name: run.evidenceAutomation.name, - }, - })), - }); - this.logger.log('Task evidence summary built', { organizationId, taskId, - appRuns: appAutomationRuns.length, - customRuns: customAutomationRuns.length, - automations: summary.automations.length, + automations: automations.length, }); - return summary; + return { ...headers, automations }; } - /** - * Export a single automation's evidence as PDF (small, buffered — no stream). - */ async exportAutomationPDF( organizationId: string, taskId: string, automationId: string, ): Promise { - const summary = await this.getTaskEvidenceSummary(organizationId, taskId); - - const automation = summary.automations.find((a) => a.id === automationId); + const headers = await getAutomationHeaders({ organizationId, taskId }); - if (!automation) { + const automationHeader = headers.automations.find( + (a) => a.id === automationId, + ); + if (!automationHeader) { throw new NotFoundException('Automation not found'); } + const automation = await loadFullAutomation({ + taskId, + header: automationHeader, + }); + const pdfBuffer = generateAutomationPDF(automation, { - organizationName: summary.organizationName, - taskTitle: summary.taskTitle, + organizationName: headers.organizationName, + taskTitle: headers.taskTitle, }); this.logger.log('Automation evidence PDF generated', { @@ -189,20 +89,11 @@ export class EvidenceExportService { pdfBytes: pdfBuffer.length, }); - const filename = `${sanitizeFilename(summary.organizationName)}_${sanitizeFilename(automation.name)}_evidence_${format(new Date(), 'yyyy-MM-dd')}.pdf`; + const filename = `${sanitizeFilename(headers.organizationName)}_${sanitizeFilename(automation.name)}_evidence_${format(new Date(), 'yyyy-MM-dd')}.pdf`; - return { - fileBuffer: pdfBuffer, - mimeType: 'application/pdf', - filename, - }; + return { fileBuffer: pdfBuffer, mimeType: 'application/pdf', filename }; } - /** - * Stream all evidence for a task as a ZIP. - * Returns a live archiver — caller pipes it to an HTTP response. S3 bodies - * stream through so peak memory ≈ the largest single file. - */ async streamTaskEvidenceZip( organizationId: string, taskId: string, @@ -220,13 +111,7 @@ export class EvidenceExportService { const folderName = `${sanitizeFilename(task.organization.name)}_${sanitizeFilename(task.title)}_evidence`; const filename = `${folderName}_${format(new Date(), 'yyyy-MM-dd')}.zip`; - const archive = archiver('zip', { zlib: { level: 6 } }); - archive.on('warning', (err) => { - this.logger.warn(`Archive warning (task ${taskId}): ${err.message}`); - }); - archive.on('error', (err) => { - this.logger.error(`Archive error (task ${taskId}): ${err.message}`); - }); + const archive = this.createArchive(`task ${taskId}`); void this.populateTaskArchive({ archive, @@ -246,6 +131,62 @@ export class EvidenceExportService { return { archive, filename }; } + async streamOrganizationEvidenceZip( + organizationId: string, + options: { includeRawJson?: boolean } = {}, + ): Promise { + const organization = await db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }); + + if (!organization) { + throw new NotFoundException('Organization not found'); + } + + const taskIds = await findTasksWithEvidence(organizationId); + if (taskIds.length === 0) { + throw new NotFoundException( + 'No tasks with evidence or attachments found', + ); + } + + const orgFolder = sanitizeFilename(organization.name); + const exportDate = format(new Date(), 'yyyy-MM-dd'); + const filename = `${orgFolder}_all-evidence_${exportDate}.zip`; + + const archive = this.createArchive(`org ${organizationId}`); + + void this.populateOrganizationArchive({ + archive, + organizationId, + organizationName: organization.name, + orgFolder, + taskIds, + options, + }).catch((err) => { + this.logger.error( + `Failed to populate org ZIP for ${organizationId}: ${ + err instanceof Error ? err.stack : String(err) + }`, + ); + archive.abort(); + }); + + return { archive, filename }; + } + + private createArchive(label: string): Archiver { + const archive = archiver('zip', { zlib: { level: 6 } }); + archive.on('warning', (err) => { + this.logger.warn(`Archive warning (${label}): ${err.message}`); + }); + archive.on('error', (err) => { + this.logger.error(`Archive error (${label}): ${err.message}`); + }); + return archive; + } + private async populateTaskArchive(params: { archive: Archiver; organizationId: string; @@ -255,14 +196,14 @@ export class EvidenceExportService { }): Promise { const { archive, organizationId, taskId, folderName, options } = params; - const [summary, attachments] = await Promise.all([ - this.getTaskEvidenceSummary(organizationId, taskId), + const [headers, attachments] = await Promise.all([ + getAutomationHeaders({ organizationId, taskId }), getTaskAttachments(organizationId, taskId), ]); await this.appendTaskContents({ archive, - summary, + headers, attachments, folderName, options, @@ -274,19 +215,16 @@ export class EvidenceExportService { this.logger.log('Task evidence ZIP streamed', { organizationId, taskId, - automations: summary.automations.length, + automations: headers.automations.length, attachments: attachments.length, includeRawJson: !!options.includeRawJson, }); } - /** - * Append a task's contents (summary PDF, attachments, automation PDFs/JSON) - * to the archive under `folderName`. Shared by per-task and org-wide exports. - */ + // Loads each automation's runs individually so peak memory ≈ one automation, not all combined. private async appendTaskContents(params: { archive: Archiver; - summary: TaskEvidenceSummary; + headers: TaskEvidenceSummary; attachments: TaskAttachment[]; folderName: string; options: { includeRawJson?: boolean }; @@ -294,14 +232,14 @@ export class EvidenceExportService { }): Promise { const { archive, - summary, + headers, attachments, folderName, options, perAutomationSubfolders, } = params; - const summaryPdf = generateTaskSummaryPDF(summary, { + const summaryPdf = generateTaskSummaryPDF(headers, { attachmentsCount: attachments.length, }); archive.append(summaryPdf, { name: `${folderName}/00-summary.pdf` }); @@ -318,151 +256,68 @@ export class EvidenceExportService { } } - for (const automation of summary.automations) { - const typePrefix = - automation.type === 'app_automation' ? 'app' : 'custom'; - const automationName = sanitizeFilename(automation.name); - const idSuffix = automation.id.slice(-8); - - const pdfBuffer = generateAutomationPDF(automation, { - organizationName: summary.organizationName, - taskTitle: summary.taskTitle, + for (const automationHeader of headers.automations) { + const automation = await loadFullAutomation({ + taskId: headers.taskId, + header: automationHeader, }); - if (perAutomationSubfolders) { - const sub = `${folderName}/${typePrefix}-${automationName}-${idSuffix}`; - archive.append(pdfBuffer, { name: `${sub}/evidence.pdf` }); - if (options.includeRawJson) { - archive.append( - Buffer.from( - this.buildAutomationJson(summary, automation), - 'utf-8', - ), - { name: `${sub}/evidence.json` }, - ); - } - } else { - archive.append(pdfBuffer, { - name: `${folderName}/${typePrefix}-${automationName}-${idSuffix}.pdf`, - }); - if (options.includeRawJson) { - archive.append( - Buffer.from( - this.buildAutomationJson(summary, automation), - 'utf-8', - ), - { - name: `${folderName}/${typePrefix}-${automationName}-${idSuffix}.json`, - }, - ); - } - } + this.appendAutomationToArchive({ + archive, + headers, + automation, + folderName, + options, + perAutomationSubfolders, + }); } } - private buildAutomationJson( - summary: TaskEvidenceSummary, - automation: NormalizedAutomation, - ): string { - return ( - safeStringify( - redactSensitiveData({ - automation: { - id: automation.id, - name: automation.name, - type: automation.type, - integrationName: automation.integrationName, - totalRuns: automation.totalRuns, - successfulRuns: automation.successfulRuns, - failedRuns: automation.failedRuns, - latestRunAt: automation.latestRunAt, - }, - runs: automation.runs.map((run) => ({ - id: run.id, - status: run.status, - startedAt: run.startedAt, - completedAt: run.completedAt, - durationMs: run.durationMs, - totalChecked: run.totalChecked, - passedCount: run.passedCount, - failedCount: run.failedCount, - evaluationStatus: run.evaluationStatus, - evaluationReason: run.evaluationReason, - logs: run.logs, - output: run.output, - error: run.error, - results: run.results, - createdAt: run.createdAt, - })), - exportedAt: summary.exportedAt, - }), - null, - 2, - ) ?? '{}' - ); - } - - /** - * Stream all evidence across an organization as a ZIP. - * - * Pre-flight checks (organization exists, has content) run synchronously - * before the archive is created so failures turn into proper HTTP errors - * instead of mid-stream aborts with headers already sent. - */ - async streamOrganizationEvidenceZip( - organizationId: string, - options: { includeRawJson?: boolean } = {}, - ): Promise { - const organization = await db.organization.findUnique({ - where: { id: organizationId }, - select: { name: true }, - }); - - if (!organization) { - throw new NotFoundException('Organization not found'); - } - - // Pre-flight: error synchronously if nothing to export. - const taskIds = await this.findTasksWithEvidence(organizationId); - if (taskIds.length === 0) { - throw new NotFoundException( - 'No tasks with evidence or attachments found', - ); - } + private appendAutomationToArchive(params: { + archive: Archiver; + headers: TaskEvidenceSummary; + automation: NormalizedAutomation; + folderName: string; + options: { includeRawJson?: boolean }; + perAutomationSubfolders: boolean; + }): void { + const { + archive, + headers, + automation, + folderName, + options, + perAutomationSubfolders, + } = params; - const orgFolder = sanitizeFilename(organization.name); - const exportDate = format(new Date(), 'yyyy-MM-dd'); - const filename = `${orgFolder}_all-evidence_${exportDate}.zip`; + const typePrefix = + automation.type === 'app_automation' ? 'app' : 'custom'; + const automationName = sanitizeFilename(automation.name); + const idSuffix = automation.id.slice(-8); - const archive = archiver('zip', { zlib: { level: 6 } }); - archive.on('warning', (err) => { - this.logger.warn( - `Archive warning (org ${organizationId}): ${err.message}`, - ); - }); - archive.on('error', (err) => { - this.logger.error( - `Archive error (org ${organizationId}): ${err.message}`, - ); + const pdfBuffer = generateAutomationPDF(automation, { + organizationName: headers.organizationName, + taskTitle: headers.taskTitle, }); - void this.populateOrganizationArchive({ - archive, - organizationId, - organizationName: organization.name, - orgFolder, - taskIds, - options, - }).catch((err) => { - this.logger.error( - `Failed to populate org ZIP for ${organizationId}: ${ - err instanceof Error ? err.stack : String(err) - }`, + const basePath = perAutomationSubfolders + ? `${folderName}/${typePrefix}-${automationName}-${idSuffix}` + : folderName; + const pdfName = perAutomationSubfolders + ? `${basePath}/evidence.pdf` + : `${basePath}/${typePrefix}-${automationName}-${idSuffix}.pdf`; + + archive.append(pdfBuffer, { name: pdfName }); + + if (options.includeRawJson) { + const jsonName = perAutomationSubfolders + ? `${basePath}/evidence.json` + : `${basePath}/${typePrefix}-${automationName}-${idSuffix}.json`; + archive.append( + Buffer.from(buildAutomationJson(headers, automation), 'utf-8'), + { name: jsonName }, ); - archive.abort(); - }); - - return { archive, filename }; + } } private async populateOrganizationArchive(params: { @@ -482,8 +337,6 @@ export class EvidenceExportService { options, } = params; - // Stream tasks one at a time. Only keep lightweight manifest metadata in - // memory (task title + counts) — never hold all summaries simultaneously. const manifestEntries: Array<{ id: string; title: string; @@ -494,32 +347,31 @@ export class EvidenceExportService { for (const taskId of taskIds) { try { - const [summary, attachments] = await Promise.all([ - this.getTaskEvidenceSummary(organizationId, taskId), + const [headers, attachments] = await Promise.all([ + getAutomationHeaders({ organizationId, taskId }), getTaskAttachments(organizationId, taskId), ]); - if (summary.automations.length === 0 && attachments.length === 0) { + if (headers.automations.length === 0 && attachments.length === 0) { continue; } - const taskIdSuffix = summary.taskId.slice(-8); - const taskFolder = `${orgFolder}/${sanitizeFilename(summary.taskTitle)}-${taskIdSuffix}`; + const taskIdSuffix = headers.taskId.slice(-8); + const taskFolder = `${orgFolder}/${sanitizeFilename(headers.taskTitle)}-${taskIdSuffix}`; await this.appendTaskContents({ archive, - summary, + headers, attachments, folderName: taskFolder, options, - // Org-wide keeps automation files flat (existing convention). perAutomationSubfolders: false, }); manifestEntries.push({ - id: summary.taskId, - title: summary.taskTitle, - automations: summary.automations.length, + id: headers.taskId, + title: headers.taskTitle, + automations: headers.automations.length, attachments: attachments.length, }); totalAttachments += attachments.length; @@ -534,9 +386,6 @@ export class EvidenceExportService { manifestEntries.sort((a, b) => a.title.localeCompare(b.title)); - // Manifest written last — archiver doesn't care about append order for the - // final ZIP structure, and this lets us include final counts without a - // separate aggregation pass. const manifest = { organization: organizationName, organizationId, @@ -559,42 +408,4 @@ export class EvidenceExportService { includeRawJson: !!options.includeRawJson, }); } - - /** - * Find task IDs that have either automation runs or task attachments. - * Union of two cheap queries — avoids scanning every task for the org. - */ - private async findTasksWithEvidence( - organizationId: string, - ): Promise { - const [tasksWithRuns, taskAttachments] = await Promise.all([ - db.task.findMany({ - where: { - organizationId, - OR: [ - { integrationCheckRuns: { some: {} } }, - { - evidenceAutomations: { - some: { runs: { some: { version: { not: null } } } }, - }, - }, - ], - }, - select: { id: true }, - }), - db.attachment.findMany({ - where: { - organizationId, - entityType: AttachmentEntityType.task, - }, - select: { entityId: true }, - distinct: ['entityId'], - }), - ]); - - const ids = new Set(); - for (const t of tasksWithRuns) ids.add(t.id); - for (const a of taskAttachments) ids.add(a.entityId); - return Array.from(ids); - } } diff --git a/apps/api/src/tasks/evidence-export/evidence-json-builder.ts b/apps/api/src/tasks/evidence-export/evidence-json-builder.ts new file mode 100644 index 0000000000..1f3c317d5f --- /dev/null +++ b/apps/api/src/tasks/evidence-export/evidence-json-builder.ts @@ -0,0 +1,40 @@ +import { configure as configureStringify } from 'safe-stable-stringify'; +import { redactSensitiveData } from './evidence-redaction'; +import type { + TaskEvidenceSummary, + NormalizedAutomation, +} from './evidence-export.types'; + +const safeStringify = configureStringify({ + bigint: true, + circularValue: '[Circular]', + deterministic: false, +}); + +export function buildAutomationJson( + summary: TaskEvidenceSummary, + automation: NormalizedAutomation, +): string { + return ( + safeStringify( + redactSensitiveData({ + automation: { + id: automation.id, + name: automation.name, + type: automation.type, + integrationName: automation.integrationName, + totalRuns: automation.totalRuns, + successfulRuns: automation.successfulRuns, + failedRuns: automation.failedRuns, + latestRunAt: automation.latestRunAt, + }, + runs: automation.runs.map( + ({ type, automationName, automationId, ...run }) => run, + ), + exportedAt: summary.exportedAt, + }), + null, + 2, + ) ?? '{}' + ); +} diff --git a/apps/api/src/tasks/evidence-export/evidence-normalizer.ts b/apps/api/src/tasks/evidence-export/evidence-normalizer.ts index fb25031123..0d8bd1eeee 100644 --- a/apps/api/src/tasks/evidence-export/evidence-normalizer.ts +++ b/apps/api/src/tasks/evidence-export/evidence-normalizer.ts @@ -10,7 +10,7 @@ import type { TaskEvidenceSummary, } from './evidence-export.types'; -interface AppAutomationRun { +export interface AppAutomationRun { id: string; checkId: string; checkName: string; @@ -44,7 +44,7 @@ interface AppAutomationRun { }>; } -interface CustomAutomationRun { +export interface CustomAutomationRun { id: string; status: string; startedAt: Date | null; @@ -160,7 +160,7 @@ function normalizeResult(result: { /** * Normalize status to unified format */ -function normalizeStatus( +export function normalizeStatus( status: string, ): 'success' | 'failed' | 'running' | 'pending' | 'cancelled' { switch (status.toLowerCase()) { diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx new file mode 100644 index 0000000000..88adb2dfe7 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkControls.tsx @@ -0,0 +1,289 @@ +'use client'; + +import type { StatusType } from '@/components/status-indicator'; +import { + type EvidenceSubmissionInfo, + getControlProgressPercent, + getControlStatus, + getRequirementArtifactCounts, +} from '@/lib/control-compliance'; +import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; +import type { Control, FrameworkEditorRequirement, Task } from '@db'; +import { + Badge, + Heading, + InputGroup, + InputGroupAddon, + InputGroupInput, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Launch, Search } from '@trycompai/design-system/icons'; +import Link from 'next/link'; +import { useParams, useRouter } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +function getStatusBadge(status: StatusType): { + label: string; + variant: 'default' | 'secondary' | 'destructive'; +} { + switch (status) { + case 'completed': + return { label: 'Satisfied', variant: 'default' }; + case 'in_progress': + return { label: 'In Progress', variant: 'secondary' }; + case 'not_relevant': + return { label: 'Not Relevant', variant: 'secondary' }; + default: + return { label: 'Not Started', variant: 'destructive' }; + } +} + +interface ControlItem { + control: FrameworkInstanceWithControls['controls'][number]; + requirements: Array<{ id: string; name: string; identifier: string }>; +} + +export function FrameworkControls({ + frameworkInstanceWithControls, + requirementDefinitions, + tasks, + evidenceSubmissions = [], +}: { + frameworkInstanceWithControls: FrameworkInstanceWithControls; + requirementDefinitions: FrameworkEditorRequirement[]; + tasks: (Task & { controls: Control[] })[]; + evidenceSubmissions?: EvidenceSubmissionInfo[]; +}) { + const { orgId, frameworkInstanceId } = useParams<{ + orgId: string; + frameworkInstanceId: string; + }>(); + const router = useRouter(); + const [searchTerm, setSearchTerm] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + + const requirementMap = useMemo(() => { + const map = new Map(); + for (const req of requirementDefinitions) { + map.set(req.id, { id: req.id, name: req.name, identifier: req.identifier ?? '' }); + } + return map; + }, [requirementDefinitions]); + + const items: ControlItem[] = useMemo(() => { + return frameworkInstanceWithControls.controls.map((control) => { + const requirements = (control.requirementsMapped ?? []) + .map((rm) => (rm.requirementId ? requirementMap.get(rm.requirementId) : undefined)) + .filter((r): r is { id: string; name: string; identifier: string } => r != null); + + return { control, requirements }; + }); + }, [frameworkInstanceWithControls.controls, requirementMap]); + + const filteredItems = useMemo(() => { + if (!searchTerm.trim()) return items; + const searchLower = searchTerm.toLowerCase(); + return items.filter( + (item) => + item.control.name.toLowerCase().includes(searchLower) || + item.control.description?.toLowerCase().includes(searchLower) || + item.requirements.some( + (r) => + r.name.toLowerCase().includes(searchLower) || + r.identifier.toLowerCase().includes(searchLower), + ), + ); + }, [items, searchTerm]); + + const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize)); + const paginatedItems = useMemo( + () => filteredItems.slice((page - 1) * pageSize, page * pageSize), + [filteredItems, page, pageSize], + ); + + useEffect(() => { + if (page > pageCount) setPage(1); + }, [page, pageCount]); + + const getControlHref = (controlId: string) => + `/${orgId}/frameworks/${frameworkInstanceId}/controls/${controlId}`; + + const handleRowClick = (controlId: string) => { + router.push(getControlHref(controlId)); + }; + + return ( +
+ Controls ({filteredItems.length}) +
+ + + + + ) => setSearchTerm(e.target.value)} + /> + +
+ { + setPageSize(size); + setPage(1); + }, + }} + > + + + Name + Requirement + Compliance + Status + Policies + Tasks + Documents + + + + {paginatedItems.length === 0 ? ( + + + + No controls found. + + + + ) : ( + paginatedItems.map(({ control, requirements }) => { + const policies = control.policies ?? []; + const documentTypes = control.controlDocumentTypes ?? []; + const counts = getRequirementArtifactCounts([control], tasks, evidenceSubmissions); + const status = getControlStatus( + policies, + tasks, + control.id, + documentTypes, + evidenceSubmissions, + ); + const badge = getStatusBadge(status); + const compliancePercent = getControlProgressPercent( + policies, + tasks, + control.id, + documentTypes, + evidenceSubmissions, + ); + + return ( + handleRowClick(control.id)} + style={{ cursor: 'pointer' }} + > + + e.stopPropagation()} + className="group flex items-center gap-2" + > + + {control.name} + + + + + + + + +
+
+
+
+
+ + {compliancePercent}% + +
+
+ + + {badge.label} + + +
+ + {counts.policies.completed}/{counts.policies.total} + +
+
+ +
+ + {counts.tasks.completed}/{counts.tasks.total} + +
+
+ +
+ + {counts.documents.completed}/{counts.documents.total} + +
+
+ + ); + }) + )} + +
+
+ ); +} + +function RequirementCell({ + requirements, +}: { + requirements: Array<{ id: string; name: string; identifier: string }>; +}) { + if (requirements.length === 0) { + return ( + + — + + ); + } + + const label = requirements + .map((r) => r.identifier || r.name) + .join(', '); + + return ( + + {label} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx index 2542cdef17..31b19ed28b 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/[frameworkInstanceId]/components/FrameworkDetailContent.tsx @@ -26,6 +26,7 @@ import Link from 'next/link'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useCallback, useState } from 'react'; import { AddCustomRequirementSheet } from './AddCustomRequirementSheet'; +import { FrameworkControls } from './FrameworkControls'; import { FrameworkDeleteDialog } from './FrameworkDeleteDialog'; import { FrameworkProgress } from './FrameworkProgress'; import { FrameworkRequirements } from './FrameworkRequirements'; @@ -41,7 +42,7 @@ interface FrameworkDetailContentProps { initialUpdateStatus?: FrameworkUpdateStatus; } -const DEFAULT_TAB = 'requirements'; +const DEFAULT_TAB = 'controls'; export function FrameworkDetailContent({ orgId, @@ -80,6 +81,7 @@ export function FrameworkDetailContent({ const tabParam = searchParams.get('tab'); const validTabsList: string[] = []; if (complianceTimelineEnabled) validTabsList.push('progress'); + validTabsList.push('controls'); validTabsList.push('requirements'); validTabsList.push('history'); const validTabs = new Set(validTabsList); @@ -102,6 +104,7 @@ export function FrameworkDetailContent({ tasks, evidenceSubmissions, ); + const controlsCount = frameworkInstanceWithControls.controls.length; const requirementsCount = requirementDefinitions.length; const canDeleteFramework = hasPermission('framework', 'delete'); @@ -154,6 +157,9 @@ export function FrameworkDetailContent({ Progress {compliancePct}% )} + + Controls {controlsCount} + Requirements {requirementsCount} @@ -187,6 +193,15 @@ export function FrameworkDetailContent({ )} + + + + (); + const { data, error } = useApiSWR( + '/v1/offboarding-checklist/pending', + ); + const members = data?.data?.members ?? []; + const [dismissed, setDismissed] = useState(false); + + if (error || dismissed || members.length === 0) return null; + + const link = members.length === 1 + ? `/${params.orgId}/people/${members[0].memberId}?tab=offboarding` + : `/${params.orgId}/people`; + + return ( +
+
+ + + + {members.length} employee{members.length !== 1 ? 's' : ''} + {' '} + require{members.length === 1 ? 's' : ''} offboarding completion + +
+
+ + View details + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx index b5e1adcb57..1cdd083538 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/Overview.tsx @@ -4,6 +4,7 @@ import { FrameworkEditorFramework, Policy, Task } from '@db'; import type { FrameworkInstanceWithControls } from '@/lib/types/framework'; import { ComplianceOverview } from './ComplianceOverview'; import { FrameworksOverview } from './FrameworksOverview'; +import { OffboardingBanner } from './OffboardingBanner'; import { ToDoOverview } from './ToDoOverview'; import { FrameworkInstanceWithComplianceScore } from './types'; @@ -69,40 +70,43 @@ export const Overview = ({ }); return ( -
- - - +
+ +
+ + + +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx index 2710326d8b..02413cab70 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.test.tsx @@ -74,6 +74,11 @@ vi.mock('lucide-react', () => ({ NotebookText: () => , Play: () => , Upload: () => , + UserMinus: () => , +})); + +vi.mock('@/hooks/use-api-swr', () => ({ + useApiSWR: () => ({ data: { data: { members: [] } }, isLoading: false }), })); import { ToDoOverview } from './ToDoOverview'; @@ -220,13 +225,16 @@ describe('ToDoOverview', () => { ).not.toBeInTheDocument(); }); - it('renders tab triggers for policies and tasks', () => { + it('renders tab triggers for policies, tasks, and offboarding', () => { setMockPermissions(ADMIN_PERMISSIONS); render(); expect(screen.getByTestId('tab-trigger-policies')).toBeInTheDocument(); expect(screen.getByTestId('tab-trigger-tasks')).toBeInTheDocument(); + expect( + screen.getByTestId('tab-trigger-offboarding'), + ).toBeInTheDocument(); }); }); }); diff --git a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx index 22f77e6dcc..ccb6621184 100644 --- a/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/overview/components/ToDoOverview.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useApiSWR } from '@/hooks/use-api-swr'; import { Button } from '@trycompai/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card'; import { ScrollArea } from '@trycompai/ui/scroll-area'; @@ -13,14 +14,28 @@ import { NotebookText, Play, Upload, + UserMinus, } from 'lucide-react'; import { usePermissions } from '@/hooks/use-permissions'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; import { ConfirmActionDialog } from './ConfirmActionDialog'; +interface PendingOffboardingMember { + memberId: string; + name: string; + email: string; + offboardDate: string; + completedItems: number; + totalItems: number; +} + +interface PendingOffboardingResponse { + members: PendingOffboardingMember[]; +} + export function ToDoOverview({ totalPolicies, totalTasks, @@ -47,6 +62,22 @@ export function ToDoOverview({ const { hasPermission } = usePermissions(); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [activeTab, setActiveTab] = useState( + unpublishedPolicies.length === 0 ? 'tasks' : 'policies', + ); + + const { + data: pendingData, + isLoading: isPendingLoading, + error: pendingError, + } = useApiSWR('/v1/offboarding-checklist/pending'); + const pendingOffboardings = pendingData?.data?.members ?? []; + + useEffect(() => { + if (!isPendingLoading && pendingOffboardings.length > 0) { + setActiveTab('offboarding'); + } + }, [isPendingLoading, pendingOffboardings.length]); const isOnboardingInProgress = !!onboardingTriggerJobId; @@ -113,13 +144,8 @@ export function ToDoOverview({
- - + + Tasks ({remainingTasks}) + + + Offboarding ({pendingOffboardings.length}) + @@ -246,6 +279,66 @@ export function ToDoOverview({ )} + + + {isPendingLoading ? ( +
+ + Loading offboardings... + +
+ ) : pendingError ? ( +
+ + Failed to load offboardings + +
+ ) : pendingOffboardings.length === 0 ? ( +
+ + + No pending offboardings + +
+ ) : ( +
+ +
+ {pendingOffboardings.map((member, index) => ( +
+
+
+
+ +
+
+ + Complete offboarding for {member.name} + + + {member.completedItems}/{member.totalItems}{' '} + tasks done + +
+
+ +
+ {index < pendingOffboardings.length - 1 && ( +
+ )} +
+ ))} +
+ +
+ )} + diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx new file mode 100644 index 0000000000..bb1449f116 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/AccessRevocationList.tsx @@ -0,0 +1,360 @@ +'use client'; + +import { useAccessRevocations } from '@/hooks/use-access-revocations'; +import { + Button, + InputGroup, + InputGroupAddon, + InputGroupInput, + Text, +} from '@trycompai/design-system'; +import { + Checkmark, + DocumentAttachment, + Search, +} from '@trycompai/design-system/icons'; +import { format } from 'date-fns'; +import { useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; + +const MONOGRAM_COLORS = [ + 'bg-blue-500', + 'bg-green-500', + 'bg-purple-500', + 'bg-amber-500', + 'bg-red-500', + 'bg-teal-500', + 'bg-indigo-500', + 'bg-pink-500', +]; + +function getMonogramColor(name: string): string { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return MONOGRAM_COLORS[Math.abs(hash) % MONOGRAM_COLORS.length]; +} + +interface AccessRevocationListProps { + memberId: string; + canEdit: boolean; + onRevocationChange?: () => void; +} + +export function AccessRevocationList({ + memberId, + canEdit, + onRevocationChange, +}: AccessRevocationListProps) { + const { revocations, isLoading, revokeAccess, undoRevocation, revokeAll } = + useAccessRevocations(memberId); + const [processingVendorId, setProcessingVendorId] = useState( + null, + ); + const [isConfirmingAll, setIsConfirmingAll] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + const { remaining, revoked } = useMemo(() => { + if (!revocations) return { remaining: [], revoked: [] }; + const filtered = revocations.vendors.filter((v) => + v.vendorName.toLowerCase().includes(searchQuery.toLowerCase()), + ); + return { + remaining: filtered.filter((v) => !v.revoked), + revoked: filtered.filter((v) => v.revoked), + }; + }, [revocations, searchQuery]); + + const handleRevoke = async (vendorId: string, file?: File) => { + setProcessingVendorId(vendorId); + try { + await revokeAccess(vendorId, file ? { file } : undefined); + toast.success(file ? 'Access removal confirmed with evidence' : 'Access removal confirmed'); + onRevocationChange?.(); + } catch { + toast.error('Failed to confirm access removal'); + } finally { + setProcessingVendorId(null); + } + }; + + const handleUndo = async (vendorId: string) => { + setProcessingVendorId(vendorId); + try { + await undoRevocation(vendorId); + toast.success('Revocation undone'); + onRevocationChange?.(); + } catch { + toast.error('Failed to undo revocation'); + } finally { + setProcessingVendorId(null); + } + }; + + const handleConfirmAll = async () => { + setIsConfirmingAll(true); + try { + await revokeAll(); + toast.success('All vendor access removals confirmed'); + onRevocationChange?.(); + } catch { + toast.error('Failed to confirm all'); + } finally { + setIsConfirmingAll(false); + } + }; + + if (isLoading) { + return ( +
+ Loading vendor access list... +
+ ); + } + + if (!revocations || revocations.vendors.length === 0) { + return ( +
+ + No vendors configured. Add vendors to your organization to track + access revocation. + +
+ ); + } + + const allConfirmed = revocations.revokedCount === revocations.totalVendors; + + return ( +
+
+ + + + + setSearchQuery(e.target.value)} + /> + + {canEdit && !allConfirmed && ( +
+ +
+ )} +
+ + {remaining.length > 0 && ( + <> + +
+ {remaining.map((vendor) => ( + handleRevoke(vendor.vendorId, file)} + /> + ))} +
+ + )} + + {revoked.length > 0 && ( + <> + +
+ {revoked.map((vendor) => ( + handleUndo(vendor.vendorId)} + /> + ))} +
+ + )} + + {remaining.length === 0 && revoked.length === 0 && ( +
+ + {searchQuery ? 'No matching vendors' : 'No vendors found'} + +
+ )} +
+ ); +} + +function SectionHeader({ label, count }: { label: string; count: number }) { + return ( +
+ + {label} + · {count} + +
+ ); +} + +function VendorMark({ name, logoUrl }: { name: string; logoUrl?: string | null }) { + const [imgError, setImgError] = useState(false); + const color = getMonogramColor(name); + const letter = name.charAt(0).toUpperCase(); + + if (logoUrl && !imgError) { + return ( + {name} setImgError(true)} + /> + ); + } + + return ( +
+ {letter} +
+ ); +} + +interface VendorRowProps { + vendor: { vendorId: string; vendorName: string; logoUrl?: string | null }; + canEdit: boolean; + isProcessing: boolean; + onRevoke: (file?: File) => void; +} + +function VendorRow({ vendor, canEdit, isProcessing, onRevoke }: VendorRowProps) { + const fileInputRef = useRef(null); + + const handleFileSelected = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + onRevoke(file); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + return ( +
+
+ + {vendor.vendorName} +
+ {canEdit && ( +
+
+ +
+ +
+ +
+
+ )} +
+ ); +} + +interface RevokedVendorRowProps { + vendor: { + vendorId: string; + vendorName: string; + logoUrl?: string | null; + revokedAt: string | null; + revokedBy: { id: string; name: string; email: string } | null; + evidence?: { id: string; name: string; downloadUrl: string }[]; + }; + canEdit: boolean; + isProcessing: boolean; + onUndo: () => void; +} + +function RevokedVendorRow({ + vendor, + canEdit, + isProcessing, + onUndo, +}: RevokedVendorRowProps) { + return ( +
+
+ + {vendor.vendorName} +
+
+ {vendor.evidence && vendor.evidence.length > 0 && ( + + + {vendor.evidence[0].name} + + )} + {vendor.revokedBy && vendor.revokedAt && ( + + {vendor.revokedBy.name} ·{' '} + {format(new Date(vendor.revokedAt), 'MMM d, yyyy')} + + )} + {canEdit && ( +
+ +
+ )} +
+ +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 1a0e8e19f4..e5757f9024 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -10,8 +10,8 @@ import { TabsList, TabsTrigger, } from '@trycompai/design-system'; -import { useSearchParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { useCallback, useState } from 'react'; import type { DeviceWithChecks, FleetPolicy, Host } from '../../devices/types'; import type { BackgroundCheckBillingStatus, BackgroundCheckRecord } from './backgroundCheckTypes'; import { EmployeeBackgroundCheck } from './EmployeeBackgroundCheck'; @@ -20,8 +20,16 @@ import { EmployeeDevice } from './EmployeeDevice'; import { EmployeePageHeader } from './EmployeePageHeader'; import { EmployeePolicies } from './EmployeePolicies'; import { EmployeeHipaaTraining, EmployeeTrainingVideos } from './EmployeeTraining'; +import { OffboardingChecklist } from './OffboardingChecklist'; -type EmployeeTab = 'details' | 'policies' | 'training' | 'hipaa' | 'device' | 'background-check'; +type EmployeeTab = + | 'details' + | 'policies' + | 'training' + | 'hipaa' + | 'device' + | 'offboarding' + | 'background-check'; interface EmployeeProps { employee: Member & { @@ -63,12 +71,51 @@ export function Employee({ memberBackgroundCheckExempt, }: EmployeeProps) { const searchParams = useSearchParams(); - const querySelectedTab: EmployeeTab = - backgroundCheckStepEnabled && - (searchParams.get('background_check_step') || searchParams.get('background_check_billing')) - ? 'background-check' - : 'details'; - const [activeTab, setActiveTab] = useState(querySelectedTab); + const pathname = usePathname(); + const router = useRouter(); + + const availableTabs: EmployeeTab[] = [ + 'details', + 'policies', + 'training', + ...(hasHipaaFramework ? (['hipaa'] as EmployeeTab[]) : []), + 'device', + ...(backgroundCheckStepEnabled ? (['background-check'] as EmployeeTab[]) : []), + ...(employee.offboardDate ? (['offboarding'] as EmployeeTab[]) : []), + ]; + + const resolveTab = (): EmployeeTab => { + if ( + backgroundCheckStepEnabled && + (searchParams.get('background_check_step') || searchParams.get('background_check_billing')) + ) { + return 'background-check'; + } + const tabParam = searchParams.get('tab'); + if (tabParam && availableTabs.includes(tabParam as EmployeeTab)) { + return tabParam as EmployeeTab; + } + return 'details'; + }; + + const activeTab = resolveTab(); + + const handleTabChange = useCallback( + (value: string) => { + const params = new URLSearchParams(searchParams.toString()); + if (value === 'details') { + params.delete('tab'); + } else { + params.set('tab', value); + } + params.delete('background_check_step'); + params.delete('background_check_billing'); + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false }); + }, + [pathname, router, searchParams], + ); + const [memberExempt, setMemberExempt] = useState(memberBackgroundCheckExempt); const [lastSyncedExempt, setLastSyncedExempt] = useState(memberBackgroundCheckExempt); @@ -77,12 +124,6 @@ export function Employee({ setMemberExempt(memberBackgroundCheckExempt); } - useEffect(() => { - if (querySelectedTab === 'background-check') { - setActiveTab('background-check'); - } - }, [querySelectedTab]); - return ( { - if (value) setActiveTab(value as EmployeeTab); + if (value) handleTabChange(value); }} > @@ -111,6 +152,9 @@ export function Employee({ {backgroundCheckStepEnabled && ( Background Check )} + {employee.offboardDate && ( + Offboarding + )} @@ -142,6 +186,15 @@ export function Employee({ fleetPolicies={fleetPolicies} /> + {employee.offboardDate && ( + + + + )} {backgroundCheckStepEnabled && ( (employee.department ?? 'none'); const [status, setStatus] = useState(employee.isActive ? 'active' : 'inactive'); - const [joinDate, setJoinDate] = useState(new Date(employee.createdAt)); - const [datePickerOpen, setDatePickerOpen] = useState(false); + const [onboardDate, setOnboardDate] = useState( + employee.onboardDate ? new Date(employee.onboardDate) : undefined, + ); + const [offboardDate, setOffboardDate] = useState( + employee.offboardDate ? new Date(employee.offboardDate) : undefined, + ); + const [onboardDatePickerOpen, setOnboardDatePickerOpen] = useState(false); + const [offboardDatePickerOpen, setOffboardDatePickerOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); const api = useApi(); @@ -61,10 +67,15 @@ export const EmployeeDetails = ({ const jobTitleChanged = jobTitle !== (employee.jobTitle ?? ''); const departmentChanged = department !== (employee.department ?? 'none'); const statusChanged = status !== (employee.isActive ? 'active' : 'inactive'); - const dateChanged = joinDate.toISOString() !== new Date(employee.createdAt).toISOString(); + const onboardDateChanged = + (onboardDate?.toISOString() ?? null) !== + (employee.onboardDate ? new Date(employee.onboardDate).toISOString() : null); + const offboardDateChanged = + (offboardDate?.toISOString() ?? null) !== + (employee.offboardDate ? new Date(employee.offboardDate).toISOString() : null); - return nameChanged || jobTitleChanged || departmentChanged || statusChanged || dateChanged; - }, [name, jobTitle, department, status, joinDate, employee]); + return nameChanged || jobTitleChanged || departmentChanged || statusChanged || onboardDateChanged || offboardDateChanged; + }, [name, jobTitle, department, status, onboardDate, offboardDate, employee]); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -78,8 +89,9 @@ export const EmployeeDetails = ({ name?: string; department?: string; isActive?: boolean; - createdAt?: string; jobTitle?: string; + onboardDate?: string | null; + offboardDate?: string | null; } = {}; if (name !== (employee.user.name ?? '')) { @@ -91,15 +103,26 @@ export const EmployeeDetails = ({ if (department !== employee.department) { updateData.department = department; } - if (joinDate.toISOString() !== new Date(employee.createdAt).toISOString()) { - updateData.createdAt = joinDate.toISOString(); - } const isActive = status === 'active'; if (isActive !== employee.isActive) { updateData.isActive = isActive; } + const onboardDateChanged = + (onboardDate?.toISOString() ?? null) !== + (employee.onboardDate ? new Date(employee.onboardDate).toISOString() : null); + if (onboardDateChanged) { + updateData.onboardDate = onboardDate ? onboardDate.toISOString() : null; + } + + const offboardDateChanged = + (offboardDate?.toISOString() ?? null) !== + (employee.offboardDate ? new Date(employee.offboardDate).toISOString() : null); + if (offboardDateChanged) { + updateData.offboardDate = offboardDate ? offboardDate.toISOString() : null; + } + if (Object.keys(updateData).length === 0) { toast.info('No changes to save'); return; @@ -207,32 +230,69 @@ export const EmployeeDetails = ({ - {/* Join Date Field */} + {/* Onboard Date Field */} + + + + + + + + { + setOnboardDate(date ?? undefined); + setOnboardDatePickerOpen(false); + }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + + + + + {/* Offboard Date Field */} - + date && setJoinDate(date)} + selected={offboardDate} + onSelect={(date) => { + setOffboardDate(date ?? undefined); + setOffboardDatePickerOpen(false); + }} captionLayout="dropdown" fromYear={2000} - toYear={new Date().getFullYear()} - disabled={(date) => date > new Date()} + toYear={new Date().getFullYear() + 1} /> diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx new file mode 100644 index 0000000000..ac9916ba99 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklist.tsx @@ -0,0 +1,168 @@ +'use client'; + +import { useOffboardingChecklist } from '@/hooks/use-offboarding-checklist'; +import { HStack, Label, Section, Stack, Switch, Text } from '@trycompai/design-system'; +import { useCallback, useState } from 'react'; +import { toast } from 'sonner'; +import { OffboardingChecklistItem } from './OffboardingChecklistItem'; +import { OffboardingSummaryCard } from './OffboardingSummaryCard'; + +interface OffboardingChecklistProps { + memberId: string; + canEdit: boolean; + offboardDate: string; +} + +export function OffboardingChecklist({ + memberId, + canEdit, + offboardDate, +}: OffboardingChecklistProps) { + const { + checklist, + isLoading, + completeItem, + uncompleteItem, + uploadEvidence, + getDownloadUrl, + refreshChecklist, + } = useOffboardingChecklist(memberId); + + const [showOnlyRemaining, setShowOnlyRemaining] = useState(false); + + const handleComplete = useCallback( + async ({ templateItemId, file }: { templateItemId: string; file?: File }) => { + try { + await completeItem({ templateItemId, file }); + toast.success('Item completed'); + } catch { + toast.error('Failed to complete item'); + } + }, + [completeItem], + ); + + const handleUncomplete = useCallback( + async (templateItemId: string) => { + try { + await uncompleteItem(templateItemId); + toast.success('Item uncompleted'); + } catch { + toast.error('Failed to uncomplete item'); + } + }, + [uncompleteItem], + ); + + const handleUploadEvidence = useCallback( + async (templateItemId: string, file: File) => { + try { + await uploadEvidence(templateItemId, file); + toast.success('Evidence uploaded'); + } catch { + toast.error('Failed to upload evidence'); + } + }, + [uploadEvidence], + ); + + const handleDownload = useCallback( + async (attachmentId: string) => { + try { + const url = await getDownloadUrl(attachmentId); + window.open(url, '_blank', 'noopener,noreferrer'); + } catch { + toast.error('Failed to download file'); + } + }, + [getDownloadUrl], + ); + + if (isLoading) { + return ( +
+ Loading checklist... +
+ ); + } + + if (!checklist || checklist.items.length === 0) { + return ( +
+ + No checklist items configured. Add items in the offboarding checklist + settings. + +
+ ); + } + + const filteredItems = showOnlyRemaining + ? checklist.items.filter((item) => !item.completed) + : checklist.items; + + return ( + + {offboardDate && ( + item.evidence.length > 0)} + /> + )} + + +
+
+

Offboarding checklist

+

+ Track tasks required to complete this offboarding. +

+
+ + + + +
+ +
+ {filteredItems.map((item) => ( + refreshChecklist()} + /> + ))} + {filteredItems.length === 0 && showOnlyRemaining && ( +
+ + All tasks completed. Turn off the filter to see all items. + +
+ )} +
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx new file mode 100644 index 0000000000..49d20a374a --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingChecklistItem.tsx @@ -0,0 +1,413 @@ +'use client'; + +import type { ChecklistItem } from '@/hooks/use-offboarding-checklist'; +import { useAccessRevocations } from '@/hooks/use-access-revocations'; +import { + Badge, + Button, + Collapsible, + CollapsibleContent, + CollapsibleTrigger, + HStack, + Stack, + Text, +} from '@trycompai/design-system'; +import { + Checkmark, + ChevronDown, + DocumentDownload, + Upload, +} from '@trycompai/design-system/icons'; +import { useRef, useState } from 'react'; +import { AccessRevocationList } from './AccessRevocationList'; + +interface OffboardingChecklistItemProps { + item: ChecklistItem; + memberId: string; + canEdit: boolean; + onComplete: (args: { templateItemId: string; file?: File }) => Promise; + onUncomplete: (templateItemId: string) => Promise; + onUploadEvidence: (templateItemId: string, file: File) => Promise; + onDownload: (attachmentId: string) => Promise; + onChecklistRefresh?: () => void; +} + +function StatusCircle({ done, total }: { done: number; total: number }) { + const allDone = done === total && total > 0; + const partial = done > 0 && !allDone; + + if (allDone) { + return ( +
+ +
+ ); + } + if (partial) { + return ( +
+
+
+ ); + } + return ( +
+ ); +} + +function ChecklistStatusCircle({ + item, + memberId, +}: { + item: ChecklistItem; + memberId: string; +}) { + if (item.isAccessRevocation) { + return ; + } + const done = item.completed ? 1 : 0; + return ; +} + +function AccessRevocationStatusCircle({ memberId }: { memberId: string }) { + const { revocations } = useAccessRevocations(memberId); + if (!revocations) return ; + return ( + + ); +} + +function ItemBadges({ + item, +}: { + item: ChecklistItem; +}) { + return ( + <> + {item.isAccessRevocation && ( +
+ Critical +
+ )} + {item.evidenceRequired && ( +
+ Evidence +
+ )} + + ); +} + +function ItemProgress({ + item, + memberId, +}: { + item: ChecklistItem; + memberId: string; +}) { + if (item.isAccessRevocation) { + return ; + } + const done = item.completed ? 1 : 0; + const total = 1; + const pct = done / total; + return ( +
+ + {done}/{total} + +
+
+
+
+ ); +} + +function AccessRevocationProgress({ memberId }: { memberId: string }) { + const { revocations } = useAccessRevocations(memberId); + if (!revocations) return null; + const pct = + revocations.totalVendors > 0 + ? revocations.revokedCount / revocations.totalVendors + : 0; + return ( +
+ + {revocations.revokedCount}/{revocations.totalVendors} + +
+
+
+
+ ); +} + +export function OffboardingChecklistItem({ + item, + memberId, + canEdit, + onComplete, + onUncomplete, + onUploadEvidence, + onDownload, + onChecklistRefresh, +}: OffboardingChecklistItemProps) { + const [isOpen, setIsOpen] = useState(false); + const [isProcessing, setIsProcessing] = useState(false); + const dropzoneInputRef = useRef(null); + + const handleComplete = async () => { + if (isProcessing) return; + setIsProcessing(true); + try { + await onComplete({ templateItemId: item.templateItemId }); + } finally { + setIsProcessing(false); + } + }; + + const handleUncomplete = async () => { + if (isProcessing) return; + setIsProcessing(true); + try { + await onUncomplete(item.templateItemId); + } finally { + setIsProcessing(false); + } + }; + + const handleFileDrop = async (e: React.DragEvent) => { + e.preventDefault(); + if (isProcessing) return; + const file = e.dataTransfer.files[0]; + if (!file) return; + await handleFileUpload(file); + }; + + const handleFileSelect = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + try { + await handleFileUpload(file); + } finally { + if (dropzoneInputRef.current) dropzoneInputRef.current.value = ''; + } + }; + + const handleFileUpload = async (file: File) => { + if (isProcessing) return; + setIsProcessing(true); + try { + if (!item.completed) { + await onComplete({ templateItemId: item.templateItemId, file }); + } else { + await onUploadEvidence(item.templateItemId, file); + } + } finally { + setIsProcessing(false); + } + }; + + const isExpandable = + item.isAccessRevocation || item.evidenceRequired || canEdit; + + return ( + +
+ + +
+
+ {item.title} + +
+ {item.description && ( + + {item.description} + + )} +
+ {isExpandable && ( +
+ + +
+ )} +
+ + + {item.isAccessRevocation ? ( + + ) : ( +
+ {item.evidenceRequired ? ( + + ) : ( + + )} +
+ )} +
+
+
+ ); +} + +function SimpleContent({ + item, + canEdit, + isProcessing, + onComplete, + onUncomplete, +}: { + item: ChecklistItem; + canEdit: boolean; + isProcessing: boolean; + onComplete: () => void; + onUncomplete: () => void; +}) { + if (!canEdit) return null; + + return ( +
+ {item.completed ? ( + + ) : ( + + )} +
+ ); +} + +function EvidenceContent({ + item, + canEdit, + isProcessing, + dropzoneInputRef, + onFileDrop, + onFileSelect, + onDownload, + onUncomplete, +}: { + item: ChecklistItem; + canEdit: boolean; + isProcessing: boolean; + dropzoneInputRef: React.RefObject; + onFileDrop: (e: React.DragEvent) => void; + onFileSelect: (e: React.ChangeEvent) => void; + onDownload: (attachmentId: string) => void; + onUncomplete: () => void; +}) { + return ( + + {item.evidence.length > 0 && ( + + {item.evidence.map((file) => ( + + + {file.name} + +
+ +
+
+ ))} +
+ )} + + {canEdit && ( +
e.preventDefault()} + onClick={() => !isProcessing && dropzoneInputRef.current?.click()} + className={`flex cursor-pointer flex-col items-center gap-2 rounded-md border-2 border-dashed border-muted-foreground/25 px-4 py-6 text-center transition hover:border-muted-foreground/50 hover:bg-muted/25${isProcessing ? ' pointer-events-none opacity-50' : ''}`} + > + +
+ + {item.completed + ? 'Drop files here or click to add more evidence' + : 'Drop files here or click to upload proof and mark as complete'} + +
+ +
+ )} + + {item.completed && canEdit && ( +
+ +
+ )} +
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx new file mode 100644 index 0000000000..0a86a64004 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/OffboardingSummaryCard.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Button } from '@trycompai/design-system'; +import { Download } from '@trycompai/design-system/icons'; +import { differenceInDays, format } from 'date-fns'; + +interface OffboardingSummaryCardProps { + memberId: string; + offboardDate: string; + totalItems: number; + completedItems: number; + hasEvidence: boolean; +} + +export function OffboardingSummaryCard({ + memberId, + offboardDate, + totalItems, + completedItems, + hasEvidence, +}: OffboardingSummaryCardProps) { + const daysSince = Math.max(0, differenceInDays(new Date(), new Date(offboardDate))); + const progressPercent = + totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0; + + return ( +
+
+
+ + Offboarding progress + +
+
+ + {completedItems} + + + / {totalItems} tasks + +
+
+
+
+
+
+
+ + Termination date + +
+

+ {format(new Date(offboardDate), 'MMM d, yyyy')} +

+
+
+
+ + Days since + +
+

+ {daysSince} +

+
+
+
+ + Completion + +
+

+ {progressPercent}% +

+
+
+ +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index e3e49b92fc..5cb8463ebd 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -1,5 +1,6 @@ 'use client'; +import { format } from 'date-fns'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useState } from 'react'; @@ -327,6 +328,28 @@ export function MemberRow({
+ {/* ONBOARDED */} + + {member.onboardDate ? ( + + {format(member.onboardDate, 'MMM d, yyyy')} + + ) : ( + + )} + + + {/* OFFBOARDED */} + + {member.offboardDate ? ( + + {format(member.offboardDate, 'MMM d, yyyy')} + + ) : ( + + )} + + {/* TASKS */} {taskItems.length > 0 ? ( diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx index fc7b670f17..a8183d43f7 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx @@ -100,6 +100,16 @@ export function PendingInvitationRow({
+ {/* ONBOARDED */} + + + + + {/* OFFBOARDED */} + + + + {/* TASKS */} diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 923e87d054..f3936b7185 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'; import { useState } from 'react'; import { toast } from 'sonner'; +import { format } from 'date-fns'; import { useApi } from '@/hooks/use-api'; import { usePeopleActions } from '@/hooks/use-people-api'; import { parseRolesString } from '@/lib/permissions'; @@ -12,6 +13,8 @@ import { authClient } from '@/utils/auth-client'; import useSWR from 'swr'; import type { Invitation } from '@db'; import { + Button, + Calendar, Empty, EmptyDescription, EmptyHeader, @@ -19,6 +22,9 @@ import { InputGroup, InputGroupAddon, InputGroupInput, + Popover, + PopoverContent, + PopoverTrigger, Select, SelectContent, SelectItem, @@ -32,7 +38,7 @@ import { TableHeader, TableRow, } from '@trycompai/design-system'; -import { InProgress, Search } from '@trycompai/design-system/icons'; +import { Calendar as CalendarIcon, ChevronDown, InProgress, Search } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; import { useMemo } from 'react'; @@ -81,6 +87,10 @@ export function TeamMembersClient({ const [searchQuery, setSearchQuery] = useState(''); const [roleFilter, setRoleFilter] = useState(''); const [statusFilter, setStatusFilter] = useState(''); + const [onboardFrom, setOnboardFrom] = useState(); + const [onboardTo, setOnboardTo] = useState(); + const [offboardFrom, setOffboardFrom] = useState(); + const [offboardTo, setOffboardTo] = useState(); const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(25); @@ -128,15 +138,49 @@ export function TeamMembersClient({ }; const allItems = buildDisplayItems(data); + const hasOffboardFilter = !!(offboardFrom || offboardTo); + const effectiveStatusFilter = hasOffboardFilter && !statusFilter ? 'all' : statusFilter; const filteredItems = filterDisplayItems({ items: allItems, searchQuery, roleFilter, - statusFilter, + statusFilter: effectiveStatusFilter, }); - const activeMembers = filteredItems.filter((item) => item.type === 'member'); - const pendingInvites = filteredItems.filter((item) => item.type === 'invitation'); + const hasAnyDateFilter = !!(onboardFrom || onboardTo || offboardFrom || offboardTo); + + const dateFilteredItems = filteredItems.filter((item) => { + if (item.type !== 'member') return !hasAnyDateFilter; + const member = item as MemberWithUser; + + if (onboardFrom || onboardTo) { + if (!member.onboardDate) return false; + const onboard = member.onboardDate; + const d = new Date(onboard); + if (onboardFrom && d < onboardFrom) return false; + if (onboardTo) { + const end = new Date(onboardTo); + end.setHours(23, 59, 59, 999); + if (d > end) return false; + } + } + + if (offboardFrom || offboardTo) { + if (!member.offboardDate) return false; + const d = new Date(member.offboardDate); + if (offboardFrom && d < offboardFrom) return false; + if (offboardTo) { + const end = new Date(offboardTo); + end.setHours(23, 59, 59, 999); + if (d > end) return false; + } + } + + return true; + }); + + const activeMembers = dateFilteredItems.filter((item) => item.type === 'member'); + const pendingInvites = dateFilteredItems.filter((item) => item.type === 'invitation'); // Combine all items for table display const allDisplayItems = [...activeMembers, ...pendingInvites]; @@ -254,7 +298,11 @@ export function TeamMembersClient({ }} > - + + {hasOffboardFilter && !statusFilter + ? 'All People' + : ({ all: 'All People', active: 'Active', pending: 'Pending', deactivated: 'Deactivated' }[statusFilter] ?? 'Active')} + All People @@ -274,7 +322,9 @@ export function TeamMembersClient({ }} > - + + {{ owner: 'Owner', admin: 'Admin', auditor: 'Auditor', employee: 'Employee', contractor: 'Contractor' }[roleFilter] ?? 'All Roles'} + All Roles @@ -286,6 +336,20 @@ export function TeamMembersClient({
+ { setOnboardFrom(from); setOnboardTo(to); setPage(1); }} + onClear={() => { setOnboardFrom(undefined); setOnboardTo(undefined); setPage(1); }} + /> + { setOffboardFrom(from); setOffboardTo(to); setPage(1); }} + onClear={() => { setOffboardFrom(undefined); setOffboardTo(undefined); setPage(1); }} + /> {hasAnyConnection && (
@@ -462,6 +526,8 @@ export function TeamMembersClient({
ROLE
+ ONBOARDED + OFFBOARDED TASKS ACTIONS @@ -503,3 +569,178 @@ export function TeamMembersClient({ ); } + +const PRESETS = [ + { label: 'Last 7 days', days: 7 }, + { label: 'Last 30 days', days: 30 }, + { label: 'This quarter', days: 90 }, + { label: 'This year', days: 365 }, + { label: 'All time', days: 0 }, +] as const; + +function getPresetRange(days: number): { from: Date | undefined; to: Date | undefined } { + if (days === 0) return { from: undefined, to: undefined }; + const to = new Date(); + const from = new Date(); + from.setDate(from.getDate() - days); + from.setHours(0, 0, 0, 0); + return { from, to }; +} + +function getActivePresetLabel(from: Date | undefined, to: Date | undefined): string | null { + if (!from && !to) return 'Any time'; + if (!from || !to) return null; + const diffDays = Math.round((to.getTime() - from.getTime()) / (1000 * 60 * 60 * 24)); + const now = new Date(); + const isToToday = Math.abs(to.getTime() - now.getTime()) < 1000 * 60 * 60 * 24; + if (!isToToday) return null; + for (const p of PRESETS) { + if (p.days === 0) continue; + if (Math.abs(diffDays - p.days) <= 1) return p.label; + } + return null; +} + +function DateRangeFilter({ + label, + from, + to, + onApply, + onClear, +}: { + label: string; + from: Date | undefined; + to: Date | undefined; + onApply: (from: Date | undefined, to: Date | undefined) => void; + onClear: () => void; +}) { + const [open, setOpen] = useState(false); + const [draftFrom, setDraftFrom] = useState(from); + const [draftTo, setDraftTo] = useState(to); + const [activePreset, setActivePreset] = useState(null); + const [fromPickerOpen, setFromPickerOpen] = useState(false); + const [toPickerOpen, setToPickerOpen] = useState(false); + + const handleOpenChange = (isOpen: boolean) => { + if (isOpen) { + setDraftFrom(from); + setDraftTo(to); + setActivePreset(getActivePresetLabel(from, to)); + } + setOpen(isOpen); + }; + + const handlePreset = (days: number, presetLabel: string) => { + const range = getPresetRange(days); + setDraftFrom(range.from); + setDraftTo(range.to); + setActivePreset(presetLabel); + }; + + const handleApply = () => { + onApply(draftFrom, draftTo); + setOpen(false); + }; + + const handleClear = () => { + onClear(); + setOpen(false); + }; + + const displayLabel = from && to + ? `${format(from, 'MMM d')} – ${format(to, 'MMM d, yyyy')}` + : from + ? `From ${format(from, 'MMM d, yyyy')}` + : to + ? `Until ${format(to, 'MMM d, yyyy')}` + : 'Any time'; + + return ( +
+ + +
+ + {label} + · + {displayLabel} + +
+
+ +
+ + {label} between + + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + +
+ + {draftFrom ? format(draftFrom, 'MMM d, yyyy') : Start date} +
+
+ + { setDraftFrom(d ?? undefined); setActivePreset(null); setFromPickerOpen(false); }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + +
+ + + +
+ + {draftTo ? format(draftTo, 'MMM d, yyyy') : End date} +
+
+ + { setDraftTo(d ?? undefined); setActivePreset(null); setToPickerOpen(false); }} + captionLayout="dropdown" + fromYear={2000} + toYear={new Date().getFullYear() + 1} + /> + +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 95584cb613..3449451294 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -2,6 +2,11 @@ import { Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + HStack, PageHeader, PageLayout, Tabs, @@ -9,7 +14,7 @@ import { TabsList, TabsTrigger, } from '@trycompai/design-system'; -import { Add } from '@trycompai/design-system/icons'; +import { Add, Download, OverflowMenuVertical } from '@trycompai/design-system/icons'; import type { Role } from '@db'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import type { ReactNode } from 'react'; @@ -138,13 +143,34 @@ export function PeoplePageTabs({ } actions={ - + +
+ +
+ + } + > + + + + { + window.open('/api/offboarding-export?all=true', '_blank', 'noopener,noreferrer'); + }} + > + + Export offboarding + + + +
} /> } diff --git a/apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx b/apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx new file mode 100644 index 0000000000..3377ddd70b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/settings/components/OffboardingChecklistSettings.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { useState } from 'react'; +import { toast } from 'sonner'; +import { usePermissions } from '@/hooks/use-permissions'; +import { useApi } from '@/hooks/use-api'; +import { useApiSWR } from '@/hooks/use-api-swr'; +import { + Button, + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, + HStack, + Input, + Label, + Section, + Stack, + Switch, + Text, + Textarea, +} from '@trycompai/design-system'; +import { Add, TrashCan } from '@trycompai/design-system/icons'; + +interface TemplateItem { + id: string; + title: string; + description: string | null; + evidenceRequired: boolean; + sortOrder: number; + isDefault: boolean; + isEnabled: boolean; +} + +const TEMPLATE_ENDPOINT = '/v1/offboarding-checklist/template'; + +export function OffboardingChecklistSettings() { + const { hasPermission } = usePermissions(); + const canUpdate = hasPermission('organization', 'update'); + const { post, patch, delete: deleteReq } = useApi(); + + const { data, mutate } = useApiSWR(TEMPLATE_ENDPOINT); + const items = Array.isArray(data?.data) ? data.data : []; + + const [dialogOpen, setDialogOpen] = useState(false); + + const handleToggleEnabled = async ({ + item, + next, + }: { + item: TemplateItem; + next: boolean; + }) => { + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: Array.isArray(current.data) + ? current.data.map((i) => + i.id === item.id ? { ...i, isEnabled: next } : i, + ) + : current.data, + }; + }, + { revalidate: false }, + ); + + const res = await patch( + `${TEMPLATE_ENDPOINT}/${item.id}`, + { isEnabled: next }, + ); + + if (res.error) { + mutate(); + toast.error('Failed to update checklist item'); + return; + } + + toast.success(next ? 'Checklist item enabled' : 'Checklist item disabled'); + }; + + const handleToggleEvidence = async ({ + item, + next, + }: { + item: TemplateItem; + next: boolean; + }) => { + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: Array.isArray(current.data) + ? current.data.map((i) => + i.id === item.id ? { ...i, evidenceRequired: next } : i, + ) + : current.data, + }; + }, + { revalidate: false }, + ); + + const res = await patch( + `${TEMPLATE_ENDPOINT}/${item.id}`, + { evidenceRequired: next }, + ); + + if (res.error) { + mutate(); + toast.error('Failed to update evidence requirement'); + return; + } + + toast.success( + next ? 'Evidence now required' : 'Evidence no longer required', + ); + }; + + const handleDelete = async ({ item }: { item: TemplateItem }) => { + mutate( + (current) => { + if (!current) return current; + return { + ...current, + data: Array.isArray(current.data) + ? current.data.filter((i) => i.id !== item.id) + : current.data, + }; + }, + { revalidate: false }, + ); + + const res = await deleteReq(`${TEMPLATE_ENDPOINT}/${item.id}`); + + if (res.error) { + mutate(); + toast.error('Failed to delete checklist item'); + return; + } + + toast.success('Checklist item deleted'); + }; + + return ( +
+ +
+ + Configure the default checklist items for employee offboarding. + + {canUpdate && ( + + }> + + Add item + + { + setDialogOpen(false); + mutate(); + }} + /> + + )} +
+ + {items.length === 0 ? ( +
+ + No checklist items configured yet. + +
+ ) : ( + + {items.map((item) => ( + + ))} + + )} +
+
+ ); +} + +function ChecklistItemCard({ + item, + canUpdate, + onToggleEnabled, + onToggleEvidence, + onDelete, +}: { + item: TemplateItem; + canUpdate: boolean; + onToggleEnabled: (args: { item: TemplateItem; next: boolean }) => void; + onToggleEvidence: (args: { item: TemplateItem; next: boolean }) => void; + onDelete: (args: { item: TemplateItem }) => void; +}) { + return ( +
+
+ + {item.title} + {item.isDefault && ( +
+ Default +
+ )} +
+ {item.description ? ( + + {item.description} + + ) : null} +
+ + + + onToggleEvidence({ item, next: Boolean(next) }) + } + aria-label={`Evidence required for ${item.title}`} + /> + +
+
+ + + onToggleEnabled({ item, next: Boolean(next) }) + } + aria-label={`Enable ${item.title}`} + /> + {!item.isDefault && canUpdate && ( + + )} + +
+ ); +} + +function AddChecklistItemDialog({ + onCreated, +}: { + onCreated: () => void; +}) { + const { post } = useApi(); + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [evidenceRequired, setEvidenceRequired] = useState(false); + const [saving, setSaving] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) return; + + setSaving(true); + + const res = await post(TEMPLATE_ENDPOINT, { + title: title.trim(), + description: description.trim() || undefined, + evidenceRequired, + }); + + setSaving(false); + + if (res.error) { + toast.error('Failed to create checklist item'); + return; + } + + toast.success('Checklist item created'); + setTitle(''); + setDescription(''); + setEvidenceRequired(false); + onCreated(); + }; + + return ( + +
+ + Add checklist item + + +
+ + setTitle(e.target.value)} + placeholder="e.g., Return company laptop" + required + /> +
+
+ +