`) — check if `Stack`, `HStack`, `PageLayout`, `PageHeader`, `Section` could replace it.
+
+## Important rules
+
+- DS `Text`, `Stack`, `HStack`, `Badge`, `Button` do NOT accept `className` — wrap in `
` if styling needed
+- Icons come from `@trycompai/design-system/icons` (Carbon icons)
+- Only flag migrations where a DS equivalent actually exists — verify by checking exports
+- Don't flag `@comp/ui` usage for components that have no DS equivalent yet
+
+## Output format
+
+For each file, report:
+- File path
+- Each import that can be migrated, with the DS replacement
+- Specific icon mappings (e.g., `Trash2` → `TrashCan`, `ExternalLink` → `Launch`)
+- Any Button instances that should use `loading`/`iconLeft`/`iconRight` props
diff --git a/.claude/agents/rbac-reviewer.md b/.claude/agents/rbac-reviewer.md
new file mode 100644
index 0000000000..f515f87843
--- /dev/null
+++ b/.claude/agents/rbac-reviewer.md
@@ -0,0 +1,28 @@
+---
+name: rbac-reviewer
+description: Reviews code for RBAC compliance — checks guards, permissions, and frontend gating
+tools: Read, Grep, Glob, Bash
+---
+
+You are a senior security engineer reviewing code for RBAC compliance in a NestJS + Next.js monorepo.
+
+## API Endpoints (apps/api/src/)
+
+Check every controller endpoint for:
+- `@UseGuards(HybridAuthGuard, PermissionGuard)` at controller or endpoint level
+- `@RequirePermission('resource', 'action')` on every endpoint
+- Controller format: `@Controller({ path: 'name', version: '1' })` (NOT `@Controller('v1/name')`)
+- `@Public()` only on webhooks and unauthenticated endpoints
+
+## Frontend Components (apps/app/src/)
+
+Check every mutation element for:
+- `usePermissions` hook imported and used
+- Buttons/forms gated with `hasPermission('resource', 'action')`
+- No manual role string parsing (`role.includes('admin')`)
+- Actions columns hidden when user lacks write permission
+
+## Permission Resources
+`organization`, `member`, `control`, `evidence`, `policy`, `risk`, `vendor`, `task`, `framework`, `audit`, `finding`, `questionnaire`, `integration`, `apiKey`, `trust`, `pentest`, `app`, `compliance`
+
+Provide specific file paths, line numbers, and suggested fixes for every violation found.
diff --git a/.claude/agents/test-writer.md b/.claude/agents/test-writer.md
new file mode 100644
index 0000000000..f4300b9195
--- /dev/null
+++ b/.claude/agents/test-writer.md
@@ -0,0 +1,87 @@
+---
+name: test-writer
+description: Writes permission-gated component tests following the established Vitest + testing-library pattern
+tools: Read, Grep, Glob, Bash
+---
+
+You write unit tests for React components that use `usePermissions` for RBAC gating.
+
+## Infrastructure
+
+- **Framework**: Vitest with jsdom
+- **Libraries**: `@testing-library/react` + `@testing-library/jest-dom`
+- **Setup**: `apps/app/src/test-utils/setup.ts`
+- **Permission mocks**: `apps/app/src/test-utils/mocks/permissions.ts`
+- **Run**: `cd apps/app && npx vitest run path/to/test`
+
+## Process
+
+1. Read the component file to understand:
+ - What `hasPermission()` checks it uses
+ - Which UI elements are gated (buttons, forms, menu items)
+ - What data it renders unconditionally
+ - What props it expects and what hooks it uses
+
+2. Read existing test files nearby for patterns (mock setup, render helpers)
+
+3. Write the test file with these required scenarios:
+
+### Admin (write) user
+- All mutation elements (buttons, form submits, toggles) are **visible and enabled**
+- Data renders correctly
+
+### Auditor (read-only) user
+- Mutation elements are **hidden or disabled**
+- Read-only content still renders
+- No error states from missing permissions
+
+### Data always visible
+- Tables, lists, text content render regardless of permission level
+
+## Test template
+
+```tsx
+import { render, screen } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { setMockPermissions, ADMIN_PERMISSIONS, AUDITOR_PERMISSIONS } from '@/test-utils/mocks/permissions';
+import { ComponentUnderTest } from './ComponentUnderTest';
+
+// Mock hooks/dependencies as needed
+vi.mock('@/hooks/use-permissions');
+
+describe('ComponentUnderTest', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('admin user', () => {
+ beforeEach(() => setMockPermissions(ADMIN_PERMISSIONS));
+
+ it('renders mutation buttons', () => {
+ render(
);
+ expect(screen.getByRole('button', { name: /create/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('read-only user', () => {
+ beforeEach(() => setMockPermissions(AUDITOR_PERMISSIONS));
+
+ it('hides mutation buttons', () => {
+ render(
);
+ expect(screen.queryByRole('button', { name: /create/i })).not.toBeInTheDocument();
+ });
+
+ it('still renders data', () => {
+ render(
);
+ expect(screen.getByText(/expected content/i)).toBeInTheDocument();
+ });
+ });
+});
+```
+
+## Rules
+
+- Mock all external hooks (`useSWR`, `useRouter`, `apiClient`, etc.)
+- Use `screen.queryBy*` for elements that should NOT exist (returns null instead of throwing)
+- Use `screen.getBy*` for elements that MUST exist
+- Run the test after writing to verify it passes: `cd apps/app && npx vitest run path/to/file.test.tsx`
diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000000..8c97fa017d
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,24 @@
+{
+ "hooks": {
+ "PostToolUse": [
+ {
+ "matcher": "Edit|Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -qE '\\.(ts|tsx)$' && echo \"$file\" | grep -qE '^(apps/api|apps/app)/'; then echo 'TypeScript file modified — remember to run typecheck before committing'; fi"
+ }
+ ]
+ },
+ {
+ "matcher": "Write",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "file=\"$CLAUDE_FILE_PATH\"; if echo \"$file\" | grep -q 'packages/db/prisma/generated'; then echo 'BLOCKED: Do not write to generated Prisma files' && exit 1; fi"
+ }
+ ]
+ }
+ ]
+ }
+}
diff --git a/.claude/skills/add-integration/SKILL.md b/.claude/skills/add-integration/SKILL.md
new file mode 100644
index 0000000000..efcdadf153
--- /dev/null
+++ b/.claude/skills/add-integration/SKILL.md
@@ -0,0 +1,157 @@
+---
+name: add-integration
+description: Build a dynamic integration for the CompAI platform using the integration DSL
+disable-model-invocation: true
+---
+
+You are a **Principal Integration Engineer** building a dynamic integration for the CompAI platform.
+
+**Before starting:** Read `.claude/skills/add-integration/examples.md` for production-tested examples. Also call `GET /v1/internal/dynamic-integrations` to see existing integrations as reference.
+
+## Workflow
+
+### Step 1: Research the API
+- Find official REST API docs for $ARGUMENTS
+- Identify: base URL, API version, auth method (OAuth2 scopes, API key header)
+- Find exact endpoint paths and response schemas
+- Identify pagination strategy (page numbers, cursors, Link headers, `@odata.nextLink`)
+- Note rate limits
+- State confidence for each endpoint: ✅ Verified, 🟡 Likely, ❌ Unverified
+
+### Step 2: Identify Relevant Checks
+Map to evidence tasks. **Use the task name as the check name.**
+
+```
+2FA: frk_tt_68406cd9dde2d8cd4c463fe0
+Employee Access: frk_tt_68406ca292d9fffb264991b9
+Role-based Access Controls: frk_tt_68e80544d9734e0402cfa807
+Access Review Log: frk_tt_68e805457c2dcc784e72e3cc
+Secure Secrets: frk_tt_68407ae5274a64092c305104
+Secure Code: frk_tt_68406e353df3bc002994acef
+Code Changes: frk_tt_68406d64f09f13271c14dd01
+Sanitized Inputs: frk_tt_68406eedf0f0ddd220ea19c2
+Secure Devices: frk_tt_6840796f77d8a0dff53f947a
+Device List: frk_tt_68406903839203801ac8041a
+Encryption at Rest: frk_tt_68e52b26bf0e656af9e4e9c3
+Monitoring & Alerting: frk_tt_68406af04a4acb93083413b9
+Utility Monitoring: frk_tt_6849c1a1038c3f18cfff47bf
+Incident Response: frk_tt_68406b4f40c87c12ae0479ce
+App Availability: frk_tt_68406d2e86acc048d1774ea6
+TLS / HTTPS: frk_tt_68406f411fe27e47a0d6d5f3
+Employee Verification: frk_tt_68406951bd282273ebe286cc
+Employee Descriptions: frk_tt_684069a3a0dd8322b2ac3f03
+Data Masking: frk_tt_686b51339d7e9f8ef2081a70
+Backup logs: frk_tt_68e52b26b166e2c0a0d11956
+Backup Restoration Test: frk_tt_68e52b269db179c434734766
+Internal Security Audit: frk_tt_68e52b2618cb9d9722c6edfd
+Separation of Environments: frk_tt_68e52a484cad0014de7a628f
+Infrastructure Inventory: frk_tt_69033a6bfeb4759be36257bc
+Production Firewall: frk_tt_68fa2a852e70f757188f0c39
+Organisation Chart: frk_tt_68e52b274a7c38c62db08e80
+Systems Description: frk_tt_68dc1a3a9b92bb4ffb89e334
+Publish Policies: frk_tt_684076a02261faf3d331289d
+Public Policies: frk_tt_6840791cac0a7b780dbaf932
+Contact Information: frk_tt_68406a514e90bb6e32e0b107
+```
+
+### Step 3: Identify Required Variables
+Define user-provided config (project IDs, org names, tenant IDs, domains).
+Format: `{ id, label, type, required, helpText, placeholder }`
+Types: `text`, `number`, `boolean`, `select`, `multi-select`
+Reference as `{{variables.project_id}}`
+
+Common: Google/Firebase → `project_id`, Azure DevOps → `organization`, Microsoft 365/Intune → none, Slack/GitHub → none
+
+### Step 4: Pre-Deployment Validation (MANDATORY)
+
+**JSON structure:**
+- [ ] `capabilities` is `["checks"]` NOT `{"checks"}`
+- [ ] `defaultHeaders` is `{}` — valid JSON object
+
+**Base URL:**
+- [ ] Trailing slash if URL has path component: `https://api.example.com/v1.0/`
+
+**API paths:**
+- [ ] **ALWAYS use full URLs** in fetch/fetchPages paths: `https://graph.microsoft.com/v1.0/deviceManagement/...`
+- [ ] NEVER start paths with `/`
+- [ ] OData `$select` params go directly in the path URL, NOT in `params`
+
+**OAuth config:**
+- [ ] Google: `"authorizationParams": {"access_type": "offline", "prompt": "consent"}`
+- [ ] Microsoft: use EXPLICIT scopes, NOT `.default` (fails for Intune)
+- [ ] Microsoft: scopes must be **Delegated** (not Application) in Azure app
+- [ ] `supportsRefreshToken: true`
+
+**Checks:**
+- [ ] Every check has `variables` array with required user inputs
+- [ ] `onFail` has real remediation with actual UI paths
+- [ ] Check names match evidence task names exactly
+
+### Step 5: Deploy via API
+- Upsert: `PUT /v1/internal/dynamic-integrations`
+- Auth: `X-Internal-Token` header
+- Base URL: `http://localhost:3333` (local) or production
+
+Other endpoints: `GET` (list/detail), `PATCH` (update), `DELETE`, `POST .../activate`, `POST .../deactivate`
+
+### Step 6: Verify
+- Confirm `{ success: true, id, slug, checksCount }`
+- Call `GET /v1/internal/dynamic-integrations` to verify data
+
+### Step 7: Post-Integration Report
+**What was done:** integration name, slug, checks, task mappings
+**What user needs to do (step by step):**
+1. OAuth app: where to register, which Delegated scopes, grant admin consent, redirect URI `https://api.trycomp.ai/v1/integrations/oauth/callback`
+2. Admin panel: `/admin/integrations` → enter client ID + secret
+3. Provider-specific: e.g., enable Identity Platform, assign Intune license
+4. Test: connect, enter variables, run each check
+
+**Complexity:** 🟢 Simple (API key) | 🟡 Medium (existing OAuth provider) | 🔴 Complex (new OAuth app + approval)
+
+## Provider-Specific Rules
+
+### Google / Firebase / GCP
+- Authorize: `https://accounts.google.com/o/oauth2/v2/auth`
+- Token: `https://oauth2.googleapis.com/token`
+- **ALWAYS** `"authorizationParams": {"access_type": "offline", "prompt": "consent"}`
+- Scopes are full URLs: `https://www.googleapis.com/auth/firebase.readonly`
+- Needs Google app verification for external users
+- Firebase needs Identity Platform upgrade for admin APIs
+
+### Microsoft Graph (Office 365, Intune, Azure AD)
+- Authorize: `https://login.microsoftonline.com/common/oauth2/v2.0/authorize`
+- Token: `https://login.microsoftonline.com/common/oauth2/v2.0/token`
+- **Use EXPLICIT scopes** — do NOT use `.default`
+- Add `offline_access`, `openid`, `profile` to scopes
+- Scopes must be **Delegated** in Azure app + grant admin consent
+- Always use full URLs in paths: `https://graph.microsoft.com/v1.0/endpoint`
+- Pagination: `@odata.nextLink` returns full URL — cursor handler follows it
+- Intune needs Intune license on connecting account
+- Personal accounts (MSA) don't support admin APIs
+
+### Azure DevOps
+- Authorize: `https://app.vssps.visualstudio.com/oauth2/authorize`
+- Token: `https://app.vssps.visualstudio.com/oauth2/token`
+- Base URL: `https://dev.azure.com`
+- Requires `organization` variable
+- Scopes: `vso.code`, `vso.build`, `vso.project`
+
+## DSL Reference
+
+### Step Types
+- **fetch**: `{ type: "fetch", path: "https://full-url/endpoint", as: "varName", dataPath: "data", onError: "fail|skip|empty" }`
+- **fetchPages**: `{ type: "fetchPages", path: "https://full-url/endpoint", as: "varName", pagination: { strategy: "cursor", cursorParam, cursorPath, dataPath } }`
+- **forEach**: Iterate collection with filter, conditions, nested steps, onPass/onFail
+- **aggregate**: Count/sum threshold with countWhere operation
+- **branch**: Conditional with then/else step arrays
+- **emit**: Direct pass/fail with template
+
+### Operators
+`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `exists`, `notExists`, `truthy`, `falsy`, `contains`, `matches`, `in`, `age_within_days`, `age_exceeds_days`
+
+### Logical: `and`, `or`, `not`
+### Templates: `{{item.field}}`, `{{variables.project_id}}`, `{{now}}`
+
+## Quality Standards
+**DO NOT:** guess endpoints, use relative paths, use `.default` for Microsoft, skip validation checklist, forget variables, create JSON files, seed directly to DB
+**DO:** use full URLs in all paths, PUT upsert endpoint, explicit OAuth scopes, proper pagination, real remediation with UI paths, match check names to task names, run validation checklist before deploy
diff --git a/.claude/skills/add-integration/examples.md b/.claude/skills/add-integration/examples.md
new file mode 100644
index 0000000000..e3a35fdb8f
--- /dev/null
+++ b/.claude/skills/add-integration/examples.md
@@ -0,0 +1,337 @@
+# Dynamic Integration Examples
+
+Reference examples from production. Use these as templates when building new integrations.
+
+## Example 1: Firebase (Google OAuth)
+
+**Auth:** Google OAuth2 with PKCE, offline access for token refresh
+**Variables:** `project_id` (user provides their Firebase project ID)
+**Checks:** 3 checks mapping to 2FA, Employee Access, Encryption at Rest
+
+```json
+{
+ "slug": "firebase",
+ "name": "Firebase",
+ "description": "Monitor Firebase Authentication MFA settings, user access, and security rules",
+ "category": "Cloud",
+ "logoUrl": "https://www.vectorlogo.zone/logos/firebase/firebase-icon.svg",
+ "docsUrl": "https://firebase.google.com/docs/reference/rest",
+ "baseUrl": "https://identitytoolkit.googleapis.com/",
+ "defaultHeaders": {},
+ "authConfig": {
+ "type": "oauth2",
+ "config": {
+ "authorizeUrl": "https://accounts.google.com/o/oauth2/v2/auth",
+ "tokenUrl": "https://oauth2.googleapis.com/token",
+ "scopes": [
+ "https://www.googleapis.com/auth/identitytoolkit",
+ "https://www.googleapis.com/auth/firebase.readonly"
+ ],
+ "pkce": true,
+ "supportsRefreshToken": true,
+ "clientAuthMethod": "body",
+ "authorizationParams": { "access_type": "offline", "prompt": "consent" }
+ }
+ },
+ "capabilities": ["checks"],
+ "checks": [
+ {
+ "checkSlug": "mfa_config",
+ "name": "2FA",
+ "description": "Verifies that multi-factor authentication is enabled at the Firebase project level",
+ "taskMapping": "frk_tt_68406cd9dde2d8cd4c463fe0",
+ "defaultSeverity": "high",
+ "definition": {
+ "steps": [
+ {
+ "type": "fetch",
+ "path": "admin/v2/projects/{{variables.project_id}}/config",
+ "as": "config",
+ "onError": "fail"
+ },
+ {
+ "type": "branch",
+ "condition": {
+ "op": "or",
+ "conditions": [
+ { "field": "config.mfa.state", "operator": "eq", "value": "ENABLED" },
+ { "field": "config.mfa.state", "operator": "eq", "value": "MANDATORY" }
+ ]
+ },
+ "then": [
+ {
+ "type": "emit",
+ "result": "pass",
+ "template": {
+ "title": "MFA is enabled for Firebase project",
+ "description": "Multi-factor authentication is set to {{config.mfa.state}}",
+ "resourceType": "firebase-project",
+ "resourceId": "{{variables.project_id}}"
+ }
+ }
+ ],
+ "else": [
+ {
+ "type": "emit",
+ "result": "fail",
+ "template": {
+ "title": "MFA is not enabled for Firebase project",
+ "description": "MFA state is {{config.mfa.state}}. Should be ENABLED or MANDATORY.",
+ "resourceType": "firebase-project",
+ "resourceId": "{{variables.project_id}}",
+ "severity": "high",
+ "remediation": "Go to Firebase Console > Authentication > Sign-in method > Multi-factor authentication > Enable MFA."
+ }
+ }
+ ]
+ }
+ ]
+ },
+ "variables": [
+ {
+ "id": "project_id",
+ "label": "Firebase Project ID",
+ "type": "text",
+ "required": true,
+ "helpText": "Found in Firebase Console > Project Settings",
+ "placeholder": "my-firebase-project"
+ }
+ ]
+ },
+ {
+ "checkSlug": "user_access",
+ "name": "Employee Access",
+ "description": "Reviews Firebase Authentication users and MFA enrollment",
+ "taskMapping": "frk_tt_68406ca292d9fffb264991b9",
+ "defaultSeverity": "medium",
+ "definition": {
+ "steps": [
+ {
+ "type": "fetchPages",
+ "path": "v1/projects/{{variables.project_id}}/accounts:batchGet",
+ "as": "users",
+ "pagination": {
+ "strategy": "cursor",
+ "cursorParam": "nextPageToken",
+ "cursorPath": "nextPageToken",
+ "dataPath": "users"
+ },
+ "params": { "maxResults": "100" },
+ "onError": "fail"
+ },
+ {
+ "type": "forEach",
+ "collection": "users",
+ "itemAs": "user",
+ "resourceType": "user",
+ "resourceIdPath": "user.email",
+ "filter": {
+ "op": "and",
+ "conditions": [
+ { "field": "user.email", "operator": "exists" },
+ { "field": "user.disabled", "operator": "neq", "value": true }
+ ]
+ },
+ "conditions": [
+ { "field": "user.mfaInfo.length", "operator": "gt", "value": 0 }
+ ],
+ "onPass": {
+ "title": "MFA enrolled: {{user.email}}",
+ "description": "{{user.displayName}} has MFA configured",
+ "resourceType": "user",
+ "resourceId": "{{user.email}}"
+ },
+ "onFail": {
+ "title": "No MFA enrolled: {{user.email}}",
+ "description": "{{user.displayName}} has no MFA methods enrolled",
+ "resourceType": "user",
+ "resourceId": "{{user.email}}",
+ "severity": "medium",
+ "remediation": "Enable MFA at project level, then user can enroll via the app."
+ }
+ }
+ ]
+ },
+ "variables": [
+ {
+ "id": "project_id",
+ "label": "Firebase Project ID",
+ "type": "text",
+ "required": true,
+ "helpText": "Found in Firebase Console > Project Settings",
+ "placeholder": "my-firebase-project"
+ }
+ ]
+ }
+ ]
+}
+```
+
+**Key patterns in this example:**
+- `authorizationParams` with `access_type: offline` — required for Google
+- `baseUrl` has trailing slash — paths are relative to it
+- Security Rules check uses full URL for a different API domain (`firebaserules.googleapis.com`)
+- `branch` step for simple pass/fail on a single value
+- `fetchPages` with cursor pagination for user listing
+- `forEach` with `filter` to skip disabled users
+- Variables defined on each check for `project_id`
+
+---
+
+## Example 2: Microsoft Intune (Microsoft Graph OAuth)
+
+**Auth:** Microsoft OAuth2 with explicit scopes (NOT `.default`)
+**Variables:** None (tenant determined from OAuth)
+**Checks:** 3 checks using full URLs to Microsoft Graph API
+
+```json
+{
+ "slug": "intune",
+ "name": "Microsoft Intune",
+ "description": "Monitor device compliance and enrollment status",
+ "category": "Security",
+ "logoUrl": "https://img.logo.dev/microsoft.com?token=pk_AZatYxV5QDSfWpRDaBxzRQ",
+ "baseUrl": "https://graph.microsoft.com/v1.0/",
+ "defaultHeaders": {},
+ "authConfig": {
+ "type": "oauth2",
+ "config": {
+ "authorizeUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize",
+ "tokenUrl": "https://login.microsoftonline.com/common/oauth2/v2.0/token",
+ "scopes": [
+ "DeviceManagementManagedDevices.Read.All",
+ "DeviceManagementConfiguration.Read.All",
+ "ServiceHealth.Read.All",
+ "offline_access",
+ "openid",
+ "profile"
+ ],
+ "pkce": true,
+ "supportsRefreshToken": true,
+ "clientAuthMethod": "body"
+ }
+ },
+ "capabilities": ["checks"],
+ "checks": [
+ {
+ "checkSlug": "device_compliance",
+ "name": "Device Compliance Status",
+ "description": "Checks that all Intune-managed devices are compliant",
+ "taskMapping": "frk_tt_6840796f77d8a0dff53f947a",
+ "defaultSeverity": "high",
+ "definition": {
+ "steps": [
+ {
+ "type": "fetchPages",
+ "path": "https://graph.microsoft.com/v1.0/deviceManagement/managedDevices?$select=id,deviceName,complianceState,isEncrypted,operatingSystem,osVersion,userPrincipalName,userDisplayName,lastSyncDateTime",
+ "as": "devices",
+ "pagination": {
+ "strategy": "cursor",
+ "cursorParam": "$skiptoken",
+ "cursorPath": "@odata.nextLink",
+ "dataPath": "value"
+ },
+ "onError": "fail"
+ },
+ {
+ "type": "forEach",
+ "collection": "devices",
+ "itemAs": "device",
+ "resourceType": "device",
+ "resourceIdPath": "device.deviceName",
+ "conditions": [
+ { "field": "device.complianceState", "operator": "eq", "value": "compliant" }
+ ],
+ "onPass": {
+ "title": "Device compliant: {{device.deviceName}}",
+ "description": "{{device.operatingSystem}} {{device.osVersion}} — {{device.userDisplayName}}",
+ "resourceType": "device",
+ "resourceId": "{{device.deviceName}}"
+ },
+ "onFail": {
+ "title": "Device non-compliant: {{device.deviceName}}",
+ "description": "{{device.operatingSystem}} device is {{device.complianceState}}",
+ "resourceType": "device",
+ "resourceId": "{{device.deviceName}}",
+ "severity": "high",
+ "remediation": "Go to Intune admin center (https://intune.microsoft.com) > Devices > select device > Review compliance status."
+ }
+ }
+ ]
+ },
+ "variables": []
+ }
+ ]
+}
+```
+
+**Key patterns in this example:**
+- Microsoft scopes are EXPLICIT, not `.default`
+- Includes `offline_access`, `openid`, `profile` for token refresh
+- **Full URL in path** — `https://graph.microsoft.com/v1.0/deviceManagement/...` (most important pattern)
+- `@odata.nextLink` pagination — returns full URL, our cursor handler follows it
+- `$select` OData params directly in the path URL
+- No variables needed — Microsoft tenant is determined from OAuth token
+
+---
+
+## Common Patterns
+
+### Simple config check (pass/fail on a single setting)
+```json
+{
+ "type": "fetch", "path": "https://api.example.com/v1/settings", "as": "settings"
+},
+{
+ "type": "branch",
+ "condition": { "field": "settings.mfa_enabled", "operator": "eq", "value": true },
+ "then": [{ "type": "emit", "result": "pass", "template": { ... } }],
+ "else": [{ "type": "emit", "result": "fail", "template": { ... } }]
+}
+```
+
+### Per-user check (iterate users, check each)
+```json
+{
+ "type": "fetchPages", "path": "https://api.example.com/v1/users", "as": "users",
+ "pagination": { "strategy": "cursor", "cursorParam": "page_token", "cursorPath": "next_token", "dataPath": "data" }
+},
+{
+ "type": "forEach", "collection": "users", "itemAs": "user",
+ "resourceType": "user", "resourceIdPath": "user.email",
+ "conditions": [{ "field": "user.mfa_enabled", "operator": "eq", "value": true }],
+ "onPass": { "title": "MFA on: {{user.email}}", ... },
+ "onFail": { "title": "MFA off: {{user.email}}", "severity": "high", "remediation": "...", ... }
+}
+```
+
+### Threshold check (count items matching criteria)
+```json
+{
+ "type": "fetch", "path": "https://api.example.com/v1/issues", "as": "issues", "dataPath": "items"
+},
+{
+ "type": "aggregate", "collection": "issues", "operation": "countWhere",
+ "filter": { "field": "severity", "operator": "in", "value": ["critical", "high"] },
+ "condition": { "operator": "lte", "value": 5 },
+ "onPass": { "title": "Critical issues within threshold", ... },
+ "onFail": { "title": "Too many critical issues", "severity": "high", ... }
+}
+```
+
+### Empty collection handling
+```json
+{
+ "type": "fetch", "path": "https://api.example.com/v1/rules", "as": "rulesResponse"
+},
+{
+ "type": "branch",
+ "condition": { "field": "rulesResponse.rules.length", "operator": "gt", "value": 0 },
+ "then": [
+ { "type": "forEach", "collection": "rulesResponse.rules", ... }
+ ],
+ "else": [
+ { "type": "emit", "result": "fail", "template": { "title": "No rules configured", ... } }
+ ]
+}
+```
diff --git a/.claude/skills/audit-design-system/SKILL.md b/.claude/skills/audit-design-system/SKILL.md
new file mode 100644
index 0000000000..4afb32b178
--- /dev/null
+++ b/.claude/skills/audit-design-system/SKILL.md
@@ -0,0 +1,23 @@
+---
+name: audit-design-system
+description: Audit & fix design system usage — migrate @comp/ui and lucide-react to @trycompai/design-system
+---
+
+Audit the specified files for design system compliance. **Fix every issue found immediately.**
+
+## Rules
+
+1. **`@trycompai/design-system`** is the primary component library. `@comp/ui` is legacy — only use as last resort when no DS equivalent exists.
+2. **Always check DS exports first** before reaching for `@comp/ui`. Run `node -e "console.log(Object.keys(require('@trycompai/design-system')))"` to check.
+3. **Icons**: Use `@trycompai/design-system/icons` (Carbon icons), NOT `lucide-react`. Check with `node -e "const i = require('@trycompai/design-system/icons'); console.log(Object.keys(i).filter(k => k.match(/YourSearch/i)))"`.
+4. **DS components that do NOT accept `className`**: `Text`, `Stack`, `HStack`, `Badge`, `Button` — wrap in `
` for custom styling.
+5. **Button**: Use DS `Button` with `loading`, `iconLeft`, `iconRight` props instead of manually rendering spinners/icons.
+6. **Layout**: Use `PageLayout`, `PageHeader`, `Stack`, `HStack`, `Section`, `SettingGroup`.
+7. **Patterns**: Sheet (`Sheet > SheetContent > SheetHeader + SheetBody`), Drawer, Collapsible.
+
+## Process
+1. Read files specified in `$ARGUMENTS`
+2. Find `@comp/ui` imports — check if DS equivalent exists
+3. Find `lucide-react` imports — find matching Carbon icons
+4. Migrate components and icons
+5. Run build to verify: `npx turbo run typecheck --filter=@comp/app`
diff --git a/.claude/skills/audit-hooks/SKILL.md b/.claude/skills/audit-hooks/SKILL.md
new file mode 100644
index 0000000000..ab74d70795
--- /dev/null
+++ b/.claude/skills/audit-hooks/SKILL.md
@@ -0,0 +1,34 @@
+---
+name: audit-hooks
+description: Audit & fix hooks and API usage patterns — eliminate server actions, raw fetch, and stale patterns
+---
+
+Audit the specified files for hook and API usage compliance. **Fix every issue found immediately.**
+
+## Forbidden Patterns (fix immediately)
+
+1. **`useAction` from `next-safe-action`** → replace with SWR hook or custom mutation hook
+2. **Server actions mutating via `@db`** → delete and use API hook instead
+3. **Direct `@db` in client components** → replace with `apiClient` via hook
+4. **Direct `@db` in Next.js pages for mutations** → replace with `serverApi`
+5. **Raw `fetch()` without `credentials: 'include'`** → use `apiClient`
+6. **`window.location.reload()` after mutations** → use SWR `mutate()`
+7. **`router.refresh()` after mutations** → use SWR `mutate()`
+8. **`useEffect` + `apiClient.get` for data fetching** → replace with `useSWR`
+9. **Callback props for data refresh** (`onXxxAdded`, `onSuccess`) → remove, rely on SWR cache sharing
+
+## Required Patterns
+
+- **Client data fetching**: `useSWR` with `apiClient` or custom hook
+- **Client mutations**: custom hooks wrapping `apiClient` with `mutate()` for cache invalidation
+- **Server components**: `serverApi` from `apps/app/src/lib/api-server.ts`
+- **SWR**: `fallbackData` for SSR data, `revalidateOnMount: !initialData`
+- **API response**: lists = `response.data.data`, single = `response.data`
+- **`mutate()` safety**: guard against `undefined` in optimistic update functions
+- **`Array.isArray()` checks**: when consuming SWR data that could be stale
+
+## Process
+1. Read files specified in `$ARGUMENTS`
+2. Find forbidden patterns and fix them
+3. Ensure all data fetching uses SWR hooks
+4. Run typecheck to verify: `npx turbo run typecheck --filter=@comp/app`
diff --git a/.claude/skills/audit-rbac/SKILL.md b/.claude/skills/audit-rbac/SKILL.md
new file mode 100644
index 0000000000..a7acb60e14
--- /dev/null
+++ b/.claude/skills/audit-rbac/SKILL.md
@@ -0,0 +1,42 @@
+---
+name: audit-rbac
+description: Audit & fix RBAC and audit log compliance in API endpoints and frontend components
+---
+
+Audit the specified files or directories for RBAC and audit log compliance. **Fix every issue found immediately.**
+
+## Rules
+
+### API Endpoints (NestJS — `apps/api/src/`)
+1. **Every mutation endpoint** (POST, PATCH, PUT, DELETE) MUST have `@RequirePermission('resource', 'action')`. If missing, **add it**.
+2. **Read endpoints** (GET) should have `@RequirePermission('resource', 'read')`. If missing, **add it**.
+3. **Self-endpoints** (e.g., `/me/preferences`) may skip `@RequirePermission` — authentication via `HybridAuthGuard` is sufficient.
+4. **Controller format**: Must use `@Controller({ path: 'name', version: '1' })`, NOT `@Controller('v1/name')`. If wrong, **fix it**.
+5. **Guards**: Use `@UseGuards(HybridAuthGuard, PermissionGuard)` at controller or endpoint level. Never skip PermissionGuard.
+6. **Webhooks**: External webhook endpoints use `@Public()` — no auth required.
+
+### Frontend Components (`apps/app/src/`)
+1. **Every mutation element** (button, form submit, toggle, switch, file upload) MUST be gated with `usePermissions` from `@/hooks/use-permissions`. If not:
+ - **Create/Add buttons**: Wrap with `{hasPermission('resource', 'create') &&
...`
+ - **Edit/Delete in dropdown menus**: Wrap the menu item
+ - **Inline form fields on detail pages**: Add `disabled={!canUpdate}`
+ - **Status/property selectors**: Add `disabled={!canUpdate}`
+2. **Actions columns** in tables: hide entire column when user lacks write permission.
+3. **No manual role string parsing** (`role.includes('admin')`) — use `hasPermission()`.
+4. **Nav items**: gate with `canAccessRoute(permissions, 'routeSegment')`.
+5. **Page-level**: call `requireRoutePermission('segment', orgId)` server-side.
+
+### Permission Resources
+`organization`, `member`, `control`, `evidence`, `policy`, `risk`, `vendor`, `task`, `framework`, `audit`, `finding`, `questionnaire`, `integration`, `apiKey`, `trust`, `pentest`, `app`, `compliance`
+
+### Multi-Product RBAC
+- Products (compliance, pen testing) are org-level feature flags — NOT RBAC
+- `app:read` gates compliance dashboard; `pentest:read` gates security product
+- Custom roles can grant access to any combination of resources
+- Portal-only resources (`policy`, `compliance`) do NOT grant app access
+
+## Process
+1. Read files specified in `$ARGUMENTS` (or scan the directory)
+2. Check each rule above
+3. **Fix every violation immediately** — don't just report
+4. Run typecheck to verify: `npx turbo run typecheck --filter=@comp/api --filter=@comp/app`
diff --git a/.claude/skills/audit-tests/SKILL.md b/.claude/skills/audit-tests/SKILL.md
new file mode 100644
index 0000000000..9d80007715
--- /dev/null
+++ b/.claude/skills/audit-tests/SKILL.md
@@ -0,0 +1,30 @@
+---
+name: audit-tests
+description: Audit & fix unit tests for permission-gated components
+---
+
+Check that unit tests exist and pass for permission-gated components. **Write missing tests immediately.**
+
+## Infrastructure
+- **Framework**: Vitest with jsdom
+- **Component testing**: `@testing-library/react` + `@testing-library/jest-dom`
+- **Setup**: `apps/app/src/test-utils/setup.ts`
+- **Permission mocks**: `apps/app/src/test-utils/mocks/permissions.ts`
+- **Run**: `cd apps/app && npx vitest run`
+
+## Required Test Pattern
+
+Every component importing `usePermissions` MUST have tests covering:
+
+1. **Admin (write) user**: mutation elements visible/enabled
+2. **Auditor (read-only)**: mutation elements hidden/disabled
+3. **Data always visible**: read-only content renders regardless of permissions
+
+Use `setMockPermissions`, `ADMIN_PERMISSIONS`, `AUDITOR_PERMISSIONS` from test utils.
+
+## Process
+1. Find components with `usePermissions` in `$ARGUMENTS`
+2. Check for corresponding `.test.tsx` files
+3. Write missing tests following the pattern above
+4. Fix any failing tests
+5. Run: `cd apps/app && npx vitest run`
diff --git a/.claude/skills/production-readiness/SKILL.md b/.claude/skills/production-readiness/SKILL.md
new file mode 100644
index 0000000000..fec480252e
--- /dev/null
+++ b/.claude/skills/production-readiness/SKILL.md
@@ -0,0 +1,21 @@
+---
+name: production-readiness
+description: Run all audit checks (RBAC, hooks, design system, tests) and verify build
+disable-model-invocation: true
+---
+
+Run a comprehensive production readiness check on $ARGUMENTS.
+
+Use parallel subagents to run all four audits simultaneously:
+1. audit-rbac on $ARGUMENTS
+2. audit-hooks on $ARGUMENTS
+3. audit-design-system on $ARGUMENTS
+4. audit-tests on $ARGUMENTS
+
+Then run full monorepo verification:
+```bash
+npx turbo run typecheck --filter=@comp/api --filter=@comp/app
+cd apps/app && npx vitest run
+```
+
+Output a Production Readiness Report summarizing all fixes applied and build status.
diff --git a/.cursorrules b/.cursorrules
index bc3e95bd52..e99401dea2 100644
--- a/.cursorrules
+++ b/.cursorrules
@@ -1,40 +1,18 @@
-# Commit Message Rules
-
-When generating commit messages, ALWAYS follow the Conventional Commits specification:
-
-## Format
-
-`(): `
-
-## Types (use exactly these):
-
-- feat: A new feature
-- fix: A bug fix
-- docs: Documentation only changes
-- style: Changes that do not affect the meaning of the code (formatting, missing semi-colons, etc)
-- refactor: A code change that neither fixes a bug nor adds a feature
-- perf: A code change that improves performance
-- test: Adding missing tests or correcting existing tests
-- build: Changes that affect the build system or external dependencies
-- ci: Changes to CI configuration files and scripts
-- chore: Other changes that don't modify src or test files
-- revert: Reverts a previous commit
-
-## Rules:
-
-1. Use lowercase for type
-2. Scope is optional but recommended (e.g., auth, api, ui, db)
-3. Description must be in imperative mood ("add" not "added" or "adds")
-4. Description must start with lowercase
-5. No period at the end of the description
-6. Keep the first line under 72 characters
-
-## Examples:
-
-- ✅ feat(auth): add social login with Google OAuth
-- ✅ fix(api): resolve race condition in user update endpoint
-- ✅ chore: update dependencies to latest versions
-- ✅ docs(readme): add installation instructions for Windows
-- ❌ Added new feature (missing type)
-- ❌ feat: Added login (wrong tense, capitalized)
-- ❌ fix(api): fixes bug. (wrong tense, has period)
+# Project Rules
+
+Read CLAUDE.md at the repo root and apps/api/CLAUDE.md for comprehensive project rules.
+
+## Quick Reference
+
+- **Package manager**: `bun` (never npm/yarn/pnpm)
+- **No `as any`** casts. No `@ts-ignore`. Fix the types instead.
+- **Max 300 lines** per file.
+- **Session auth only** — no JWT. Use `credentials: 'include'` for API calls.
+- **RBAC**: `@RequirePermission('resource', 'action')` on every API endpoint. Gate UI with `hasPermission()`.
+- **Design system**: Always `@trycompai/design-system` first, `@comp/ui` only as fallback. Icons from `@trycompai/design-system/icons`.
+- **Data fetching**: Server components use `serverApi`. Client components use SWR hooks with `apiClient`.
+- **No server actions** for new features. Call NestJS API directly.
+- **Tests required** for every new feature. TDD preferred.
+- **Conventional commits**: `(): `
+- **Controller format**: `@Controller({ path: 'name', version: '1' })`, NOT `@Controller('v1/name')`
+- **Permission resources**: organization, member, control, evidence, policy, risk, vendor, task, framework, audit, finding, questionnaire, integration, apiKey, trust, pentest, app, compliance
diff --git a/.github/workflows/device-agent-release.yml b/.github/workflows/device-agent-release.yml
index 821e34b7e2..d35272b826 100644
--- a/.github/workflows/device-agent-release.yml
+++ b/.github/workflows/device-agent-release.yml
@@ -194,18 +194,25 @@ jobs:
ESIGNER_CREDENTIAL_ID: ${{ secrets.ESIGNER_CREDENTIAL_ID }}
ESIGNER_TOTP_SECRET: ${{ secrets.ESIGNER_TOTP_SECRET }}
run: |
- # Download and extract CodeSignTool
+ if (-not $env:ESIGNER_USERNAME -or -not $env:ESIGNER_PASSWORD -or -not $env:ESIGNER_CREDENTIAL_ID -or -not $env:ESIGNER_TOTP_SECRET) {
+ throw "One or more ESIGNER secrets are not configured. Cannot sign."
+ }
+
Invoke-WebRequest -Uri "https://github.com/SSLcom/CodeSignTool/releases/download/v1.3.0/CodeSignTool-v1.3.0-windows.zip" -OutFile "codesigntool.zip"
Expand-Archive -Path "codesigntool.zip" -DestinationPath "codesigntool"
- # Find the jar file
- $jar = Get-ChildItem -Path "codesigntool" -Recurse -Filter "code_sign_tool-*.jar" | Select-Object -First 1
+ $cstDir = Get-ChildItem -Path "codesigntool" -Directory | Select-Object -First 1
+ if (-not $cstDir) { throw "CodeSignTool directory not found after extraction" }
+ Write-Host "CodeSignTool directory: $($cstDir.FullName)"
+
+ $jar = Get-ChildItem -Path $cstDir.FullName -Recurse -Filter "code_sign_tool-*.jar" | Select-Object -First 1
if (-not $jar) { throw "CodeSignTool jar not found" }
Write-Host "Found CodeSignTool jar at: $($jar.FullName)"
- # Sign each .exe file using Java directly (skips .bat which needs bundled JDK)
- Get-ChildItem -Filter "*.exe" | ForEach-Object {
+ $releaseDir = Get-Location
+ Get-ChildItem -Path $releaseDir -Filter "*.exe" | ForEach-Object {
Write-Host "Signing $($_.Name)..."
+ Push-Location $cstDir.FullName
& java -Xmx1024M -jar "$($jar.FullName)" sign `
-username="$env:ESIGNER_USERNAME" `
-password="$env:ESIGNER_PASSWORD" `
@@ -213,9 +220,30 @@ jobs:
-totp_secret="$env:ESIGNER_TOTP_SECRET" `
-input_file_path="$($_.FullName)" `
-override="true"
- if ($LASTEXITCODE -ne 0) { throw "Code signing failed for $($_.Name)" }
- Write-Host "Signed $($_.Name) successfully"
+ $signExitCode = $LASTEXITCODE
+ Pop-Location
+ if ($signExitCode -ne 0) { throw "Code signing failed for $($_.Name) (exit code: $signExitCode)" }
+ Write-Host "CodeSignTool completed for $($_.Name)"
+ }
+
+ - name: Verify Windows code signature
+ shell: powershell
+ working-directory: packages/device-agent/release
+ run: |
+ $allSigned = $true
+ Get-ChildItem -Filter "*.exe" | ForEach-Object {
+ $sig = Get-AuthenticodeSignature -FilePath $_.FullName
+ Write-Host "File: $($_.Name)"
+ Write-Host " Status: $($sig.Status)"
+ Write-Host " Signer: $($sig.SignerCertificate.Subject)"
+ Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
+ Write-Host " Valid from: $($sig.SignerCertificate.NotBefore) to $($sig.SignerCertificate.NotAfter)"
+ if ($sig.Status -ne 'Valid') {
+ Write-Host "::error::Signature verification FAILED for $($_.Name) — Status: $($sig.Status)"
+ $allSigned = $false
+ }
}
+ if (-not $allSigned) { throw "One or more .exe files are NOT properly signed. Failing build." }
- name: Recalculate latest.yml hash after signing
shell: bash
diff --git a/.gitignore b/.gitignore
index 8bead217e9..77fc3860d0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,6 +40,10 @@ yarn-error.log*
# turbo
.turbo
+# claude code - personal settings and memory (commands/ is shared)
+.claude/settings.local.json
+.claude/projects/
+
# react-email
.react-email
packages/email/public
@@ -87,4 +91,4 @@ packages/*/dist
scripts/sync-release-branch.sh
/.vscode
-.claude/projects/-Users-marfuen-code-comp/
\ No newline at end of file
+.claude/audit-findings.md
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000000..523ff9ae7e
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,150 @@
+# Project Rules
+
+## Tooling
+
+- **Package manager**: `bun` (never npm/yarn/pnpm)
+- **Build**: `bun run build` (uses turbo). Filter: `bun run --filter '@comp/app' build`
+- **Typecheck**: `bun run typecheck` or `npx turbo run typecheck --filter=@comp/api`
+- **Tests (app)**: `cd apps/app && npx vitest run`
+- **Tests (api)**: `cd apps/api && npx jest src/ --passWithNoTests`
+- **Lint**: `bun run lint`
+
+## Code Style
+
+- **Max 300 lines per file.** Split into focused modules if exceeded.
+- **No `as any` casts.** Ever. Use proper types, generics, or `unknown` with type guards.
+- **No `@ts-ignore` or `@ts-expect-error`.** Fix the type instead.
+- **Strict TypeScript**: Use zod for runtime validation, generics over `any`.
+- **Early returns** to avoid nested conditionals.
+- **Named parameters** for functions with 2+ arguments.
+- **Event handlers**: prefix with `handle` (e.g., `handleSubmit`).
+
+## Monorepo Structure
+
+```
+apps/
+ api/ # NestJS API (auth, RBAC, business logic)
+ app/ # Next.js frontend (compliance + security products)
+ portal/ # Employee portal
+packages/
+ auth/ # RBAC definitions (permissions.ts) — single source of truth
+ db/ # Prisma schema + client
+ ui/ # Legacy component library (being phased out)
+```
+
+## Authentication & Session
+
+- **Session-based auth only.** No JWT tokens. All requests use `credentials: 'include'` to send httpOnly session cookies.
+- **HybridAuthGuard** supports 3 methods in order: API Key (`x-api-key`), Service Token (`x-service-token`), Session (cookies). `@Public()` skips auth.
+- **Client-side**: `apiClient` from `@/lib/api-client` (always sends cookies).
+- **Server-side**: `serverApi` from `@/lib/api-server.ts`.
+- **Raw `fetch()` to API**: MUST include `credentials: 'include'`, otherwise 401.
+
+## API Architecture
+
+We are migrating away from Next.js server actions toward calling the NestJS API directly.
+
+### Simple CRUD operations
+Client components call the NestJS API via custom SWR hooks. No server action wrapper needed.
+
+### Multi-step orchestration
+When an operation requires multiple API calls (e.g., S3 upload + PATCH), create a Next.js API route (`apps/app/src/app/api/...`) that orchestrates them.
+
+### What NOT to do
+- Do NOT use server actions for new features
+- Do NOT keep server actions as wrappers around API calls
+- Do NOT add direct database (`@db`) access in the Next.js app for mutations — always go through the API
+- Do NOT use `useAction` from `next-safe-action` for new code
+
+### API Client
+- Server-side (Next.js API routes/pages): `serverApi` from `apps/app/src/lib/api-server.ts`
+- Client-side (hooks): `apiClient` / `api` from `@/lib/api-client`
+
+### API Response Format
+- **List endpoints**: `{ data: [...], count, authType, authenticatedUser }` → access via `response.data.data`
+- **Single resource endpoints**: `{ ...entity, authType, authenticatedUser }` → access via `response.data`
+- Both `apiClient` and `serverApi` wrap in `{ data, error, status }`
+
+## RBAC
+
+### Permissions Model
+- Flat `resource:action` model (e.g., `pentest:read`, `control:update`)
+- Single source of truth: `packages/auth/src/permissions.ts`
+- Built-in roles: `owner`, `admin`, `auditor`, `employee`, `contractor`
+- Custom roles: stored in `organization_role` table per organization
+- Multiple roles per user (comma-separated in `member.role`)
+
+### Multi-Product Architecture
+- **Products** (compliance, pen testing) are org-level subscription/feature flags — NOT RBAC
+- **RBAC** controls user access within products
+- `app:read` gates the compliance dashboard; `pentest:read` gates security product
+- Portal-only resources (`policy`, `compliance`) do NOT grant app access
+
+### API Endpoint Requirements
+Every customer-facing API endpoint MUST have:
+```typescript
+@UseGuards(HybridAuthGuard, PermissionGuard) // at controller or endpoint level
+@RequirePermission('resource', 'action') // on every endpoint
+```
+- Controller format: `@Controller({ path: 'name', version: '1' })`, NOT `@Controller('v1/name')`
+- `@Public()` for unauthenticated endpoints (webhooks, etc.)
+- The `AuditLogInterceptor` only logs when `@RequirePermission` metadata is present
+
+### Frontend Permission Gating
+- **Nav items**: Gate with `canAccessRoute(permissions, 'routeSegment')`
+- **Rail icons**: Gate product sections (Compliance, Security, Trust, Settings) by permission
+- **Mutation buttons**: Gate with `hasPermission(permissions, 'resource', 'action')`
+- **Page-level**: Every product layout uses `requireRoutePermission('segment', orgId)` server-side
+- **Route permissions**: Defined in `ROUTE_PERMISSIONS` in `apps/app/src/lib/permissions.ts`
+- No manual role string parsing (`role.includes('admin')`) — always use permission checks
+
+### Permission Resources
+`organization`, `member`, `control`, `evidence`, `policy`, `risk`, `vendor`, `task`, `framework`, `audit`, `finding`, `questionnaire`, `integration`, `apiKey`, `trust`, `pentest`, `app`, `compliance`
+
+## Design System
+
+- **Always prefer `@trycompai/design-system`** over `@comp/ui`. Check DS exports first.
+- `@comp/ui` is the legacy library being phased out — only use as last resort.
+- **Icons**: `@trycompai/design-system/icons` (Carbon icons), NOT `lucide-react`
+- **DS components that do NOT accept `className`**: `Text`, `Stack`, `HStack`, `Badge`, `Button` — wrap in `` for custom styling
+- **Layout**: Use `PageLayout`, `PageHeader`, `Stack`, `HStack`, `Section`, `SettingGroup`
+- **Patterns**: Sheet (`Sheet > SheetContent > SheetHeader + SheetBody`), Drawer, Collapsible
+
+## Data Fetching
+
+- **Server components**: Fetch with `serverApi`, pass as `fallbackData` to client
+- **Client components**: `useSWR` with `apiClient` or custom hooks (e.g., `usePolicy`, `useTask`)
+- **SWR hooks**: Use `fallbackData` for SSR initial data, `revalidateOnMount: !initialData`
+- **`mutate()` safety**: Guard against `undefined` in optimistic update functions
+- **`Array.isArray()` checks**: When consuming SWR data that could be stale
+
+## Testing
+
+- **Every new feature MUST include tests.** No exceptions.
+- **TDD preferred**: Write failing tests first, then make them pass.
+- **App tests**: Vitest + @testing-library/react (jsdom environment)
+- **API tests**: Jest with NestJS testing utilities
+- **Permission tests**: Test admin (write) and read-only user scenarios
+- **Run from package dir**: `cd apps/app && npx vitest run` or `cd apps/api && npx jest`
+
+## Database
+
+- **Schema**: `packages/db/prisma/schema/` (split into files per model)
+- **IDs**: Always use prefixed CUIDs: `@default(dbgenerated("generate_prefixed_cuid('prefix'::text)"))`
+- **Migrations**: `cd packages/db && bunx prisma migrate dev --name your_name`
+- **Multi-tenancy**: Always scope queries by `organizationId`
+- **Transactions**: Use for operations modifying multiple records
+
+## Git
+
+- **Conventional commits**: `
(): ` (imperative, lowercase)
+- **Never use `git stash`** unless explicitly asked
+- **Never skip hooks** (`--no-verify`)
+- **Never force push** to main/master
+
+## Forms
+
+- All forms use **React Hook Form + Zod** validation
+- Define Zod schema first, infer type with `z.infer`
+- Use `Controller` for complex components (Select, Combobox)
+- Never use `useState` for form field values
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
deleted file mode 100644
index 94833f04d7..0000000000
--- a/CODE_OF_CONDUCT.md
+++ /dev/null
@@ -1,128 +0,0 @@
-# Contributor Covenant Code of Conduct
-
-## Our Pledge
-
-We as members, contributors, and leaders pledge to make participation in our
-community a harassment-free experience for everyone, regardless of age, body
-size, visible or invisible disability, ethnicity, sex characteristics, gender
-identity and expression, level of experience, education, socio-economic status,
-nationality, personal appearance, race, religion, or sexual identity
-and orientation.
-
-We pledge to act and interact in ways that contribute to an open, welcoming,
-diverse, inclusive, and healthy community.
-
-## Our Standards
-
-Examples of behavior that contributes to a positive environment for our
-community include:
-
-- Demonstrating empathy and kindness toward other people
-- Being respectful of differing opinions, viewpoints, and experiences
-- Giving and gracefully accepting constructive feedback
-- Accepting responsibility and apologizing to those affected by our mistakes,
- and learning from the experience
-- Focusing on what is best not just for us as individuals, but for the
- overall community
-
-Examples of unacceptable behavior include:
-
-- The use of sexualized language or imagery, and sexual attention or
- advances of any kind
-- Trolling, insulting or derogatory comments, and personal or political attacks
-- Public or private harassment
-- Publishing others' private information, such as a physical or email
- address, without their explicit permission
-- Other conduct which could reasonably be considered inappropriate in a
- professional setting
-
-## Enforcement Responsibilities
-
-Community leaders are responsible for clarifying and enforcing our standards of
-acceptable behavior and will take appropriate and fair corrective action in
-response to any behavior that they deem inappropriate, threatening, offensive,
-or harmful.
-
-Community leaders have the right and responsibility to remove, edit, or reject
-comments, commits, code, wiki edits, issues, and other contributions that are
-not aligned to this Code of Conduct, and will communicate reasons for moderation
-decisions when appropriate.
-
-## Scope
-
-This Code of Conduct applies within all community spaces, and also applies when
-an individual is officially representing the community in public spaces.
-Examples of representing our community include using an official e-mail address,
-posting via an official social media account, or acting as an appointed
-representative at an online or offline event.
-
-## Enforcement
-
-Instances of abusive, harassing, or otherwise unacceptable behavior may be
-reported to the community leaders responsible for enforcement at
-help@trycomp.ai
-All complaints will be reviewed and investigated promptly and fairly.
-
-All community leaders are obligated to respect the privacy and security of the
-reporter of any incident.
-
-## Enforcement Guidelines
-
-Community leaders will follow these Community Impact Guidelines in determining
-the consequences for any action they deem in violation of this Code of Conduct:
-
-### 1. Correction
-
-**Community Impact**: Use of inappropriate language or other behavior deemed
-unprofessional or unwelcome in the community.
-
-**Consequence**: A private, written warning from community leaders, providing
-clarity around the nature of the violation and an explanation of why the
-behavior was inappropriate. A public apology may be requested.
-
-### 2. Warning
-
-**Community Impact**: A violation through a single incident or series
-of actions.
-
-**Consequence**: A warning with consequences for continued behavior. No
-interaction with the people involved, including unsolicited interaction with
-those enforcing the Code of Conduct, for a specified period of time. This
-includes avoiding interactions in community spaces as well as external channels
-like social media. Violating these terms may lead to a temporary or
-permanent ban.
-
-### 3. Temporary Ban
-
-**Community Impact**: A serious violation of community standards, including
-sustained inappropriate behavior.
-
-**Consequence**: A temporary ban from any sort of interaction or public
-communication with the community for a specified period of time. No public or
-private interaction with the people involved, including unsolicited interaction
-with those enforcing the Code of Conduct, is allowed during this period.
-Violating these terms may lead to a permanent ban.
-
-### 4. Permanent Ban
-
-**Community Impact**: Demonstrating a pattern of violation of community
-standards, including sustained inappropriate behavior, harassment of an
-individual, or aggression toward or disparagement of classes of individuals.
-
-**Consequence**: A permanent ban from any sort of public interaction within
-the community.
-
-## Attribution
-
-This Code of Conduct is adapted from the [Contributor Covenant][homepage],
-version 2.0, available at
-https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
-
-Community Impact Guidelines were inspired by [Mozilla's code of conduct
-enforcement ladder](https://github.com/mozilla/diversity).
-
-[homepage]: https://www.contributor-covenant.org
-
-For answers to common questions about this code of conduct, see the FAQ at
-https://www.contributor-covenant.org/faq. Translations are available at
-https://www.contributor-covenant.org/translations.
diff --git a/REVIEW.md b/REVIEW.md
new file mode 100644
index 0000000000..fb0498de91
--- /dev/null
+++ b/REVIEW.md
@@ -0,0 +1,57 @@
+# Code Review Guidelines
+
+## Always check
+
+### RBAC & Security
+- Every customer-facing API endpoint MUST have `@UseGuards(HybridAuthGuard, PermissionGuard)` and `@RequirePermission('resource', 'action')`
+- `@Public()` is only acceptable for webhooks and unauthenticated endpoints (e.g., trust portal public pages)
+- No manual role string parsing (`role.includes('admin')`) — always use permission check utilities
+- Frontend mutation buttons must be gated with `hasPermission(permissions, 'resource', 'action')`
+- Raw `fetch()` calls to the API must include `credentials: 'include'`
+- Database queries must be scoped by `organizationId` for multi-tenancy
+- Error messages must not leak internal details (stack traces, DB structure, internal IDs)
+
+### Server Actions & API Architecture
+- No new server actions — client components should call the NestJS API directly via `apiClient`/`api` or SWR hooks
+- No `useAction` from `next-safe-action` in new code
+- No direct `@db` imports in the Next.js app for mutations — all mutations go through the NestJS API
+- Server actions are acceptable ONLY for server-side-only operations like encryption/decryption that need access to server env vars
+- Multi-step orchestration should use Next.js API routes (`apps/app/src/app/api/...`), not server actions
+
+### Design System
+- New UI must use `@trycompai/design-system` components, not `@comp/ui` (legacy, being phased out)
+- Icons must come from `@trycompai/design-system/icons` (Carbon icons), not `lucide-react`
+- DS components `Text`, `Stack`, `HStack`, `Badge`, `Button` do not accept `className` — wrap in a `` for custom styling
+- Use DS `Button` props like `loading`, `iconLeft`, `iconRight` instead of manually rendering spinners/icons inside buttons
+
+### TypeScript
+- No `as any` casts — use proper types, generics, or `unknown` with type guards
+- No `@ts-ignore` or `@ts-expect-error` — fix the underlying type issue
+- Files must not exceed 300 lines — split into focused modules
+
+### Data Fetching
+- Client components should use `useSWR` with `apiClient` or custom hooks
+- Server components should fetch with `serverApi` and pass as `fallbackData`
+- `mutate()` optimistic update functions must guard against `undefined` input
+- Use `Array.isArray()` checks when consuming SWR data that could be stale
+
+### Database
+- New IDs must use prefixed CUIDs: `@default(dbgenerated("generate_prefixed_cuid('prefix'::text)"))`
+- Operations modifying multiple records must use transactions
+- Migrations must be backward-compatible
+
+### API Controller Format
+- Controllers must use `@Controller({ path: 'name', version: '1' })`, NOT `@Controller('v1/name')` (causes double prefix bug)
+- API list endpoints return `{ data: [...], count }` — single resource endpoints return the entity flat
+
+### Forms
+- Forms must use React Hook Form + Zod validation
+- No `useState` for form field values — use the form's state management
+
+## Skip
+- Pre-existing `@comp/ui` usage in files not touched by the PR
+- Pre-existing `lucide-react` usage in files not touched by the PR
+- Pre-existing server actions in files not touched by the PR
+- Test files using simplified mock types
+- Generated files under `packages/db/prisma/generated/`
+- OpenAPI spec file `packages/docs/openapi.json`
diff --git a/apps/api/.cursorrules b/apps/api/.cursorrules
new file mode 100644
index 0000000000..392a8df421
--- /dev/null
+++ b/apps/api/.cursorrules
@@ -0,0 +1,45 @@
+# API Rules
+
+Read CLAUDE.md in this directory for comprehensive API development guidelines.
+
+## Quick Reference
+
+- **Auth**: Session-based only (no JWT). `HybridAuthGuard` + `PermissionGuard` on every endpoint.
+- **RBAC**: `@RequirePermission('resource', 'action')` required. Without it, `AuditLogInterceptor` won't log.
+- **Controller**: `@Controller({ path: 'name', version: '1' })`, NOT `@Controller('v1/name')` (double prefix bug).
+- **Tests**: Every feature needs tests. `npx jest src/
--passWithNoTests`.
+- **No `as any`**. Max 300 lines per file.
+- **Multi-tenancy**: Always scope DB queries by `organizationId`.
+- **Billing errors**: `HttpException` with `HttpStatus.PAYMENT_REQUIRED` (no PaymentRequiredException).
+- **Webhooks**: Use `@Public()` decorator.
+- **Nested JSON**: Use `@Req() req` + `req.body` instead of DTO when receiving complex nested objects.
+- **Permission resources**: organization, member, control, evidence, policy, risk, vendor, task, framework, audit, finding, questionnaire, integration, apiKey, trust, pentest, app, compliance
+
+## Testing
+
+**Every new feature MUST include tests.** This is mandatory, not optional.
+
+```bash
+# Run tests for a specific module
+npx jest src/ --passWithNoTests
+
+# Run all API tests
+npx turbo run test --filter=@comp/api
+
+# Type-check
+npx turbo run typecheck --filter=@comp/api
+```
+
+### Test File Conventions
+
+- Colocate: `foo.service.ts` → `foo.service.spec.ts`
+- Mock external deps (DB, external APIs)
+- Test success, error, and edge cases
+- Override guards in controller tests with `.overrideGuard(HybridAuthGuard).useValue({ canActivate: () => true })`
+
+## Code Style
+
+- Use `@AuthContext()` for auth context, `@OrganizationId()` for org ID
+- NestJS exceptions: `BadRequestException`, `NotFoundException`, `ForbiddenException`
+- Prisma via `@trycompai/db`, always scope by `organizationId`
+- Transactions for multi-record operations
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 9e4894a9b9..83bf73f587 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -14,7 +14,10 @@ APP_AWS_ENDPOINT="" # optional for using services like MinIO
DATABASE_URL=
NOVU_API_KEY=
-INTERNAL_API_TOKEN=
+
+# Service tokens for internal service-to-service auth (scoped, per-service)
+SERVICE_TOKEN_TRIGGER= # Used by Trigger.dev tasks
+SERVICE_TOKEN_PORTAL= # Used by Portal app
# Upstash
UPSTASH_REDIS_REST_URL=
diff --git a/apps/api/.gitignore b/apps/api/.gitignore
index 01b6aa2b3a..dfd2c2e37b 100644
--- a/apps/api/.gitignore
+++ b/apps/api/.gitignore
@@ -2,6 +2,11 @@
/dist
/node_modules
/build
+src/**/*.js
+*.Extension.js
+customPrismaExtension.js
+emailExtension.js
+integrationPlatformExtension.js
# Logs
logs
@@ -56,4 +61,6 @@ pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
-prisma/schema.prisma
\ No newline at end of file
+prisma/schema.prisma
+trigger.config.js
+test/**/*.js
diff --git a/apps/api/CLAUDE.md b/apps/api/CLAUDE.md
new file mode 100644
index 0000000000..a76533f21e
--- /dev/null
+++ b/apps/api/CLAUDE.md
@@ -0,0 +1,159 @@
+# API Development Guidelines
+
+This document provides guidelines for AI assistants (Claude, Cursor, etc.) when working on the API codebase.
+
+## Project Structure
+
+```
+apps/api/src/
+├── auth/ # Authentication (better-auth, guards, decorators)
+├── roles/ # Custom roles CRUD API
+├── stripe/ # Stripe billing (global module)
+├── security-penetration-tests/ # Pen testing product (controller, service, billing)
+├── / # Feature modules (controller, service, DTOs)
+└── utils/ # Shared utilities
+```
+
+## Authentication
+
+- **Session-based auth only.** No JWT tokens.
+- **HybridAuthGuard** checks in order: API Key (`x-api-key`), Service Token (`x-service-token`), Session (cookies via better-auth).
+- `@Public()` decorator skips auth entirely (use for webhooks).
+- Access auth context via `@AuthContext()` decorator.
+- Access organization ID via `@OrganizationId()` decorator.
+
+## RBAC System
+
+The API uses a hybrid RBAC system:
+
+- **Built-in roles**: owner, admin, auditor, employee, contractor (defined in `packages/auth/src/permissions.ts`)
+- **Custom roles**: Stored in `organization_role` table with JSON permissions
+- **Permissions**: Flat `resource:action` format (e.g., `control:read`, `pentest:create`)
+- **Multiple roles**: Users can have multiple roles (comma-separated in `member.role`)
+
+### Permission Resources
+`organization`, `member`, `control`, `evidence`, `policy`, `risk`, `vendor`, `task`, `framework`, `audit`, `finding`, `questionnaire`, `integration`, `apiKey`, `trust`, `pentest`, `app`, `compliance`
+
+### Endpoint Protection
+
+Every customer-facing endpoint MUST have:
+```typescript
+@UseGuards(HybridAuthGuard, PermissionGuard) // at controller or endpoint level
+@RequirePermission('resource', 'action') // on every endpoint
+```
+
+- **Controller format**: `@Controller({ path: 'name', version: '1' })`, NOT `@Controller('v1/name')` (double prefix bug)
+- **Webhooks**: `@Public()` — no auth/RBAC required
+- **Self-endpoints** (e.g., `/me`): `HybridAuthGuard` only, no `@RequirePermission` needed
+- `AuditLogInterceptor` only logs mutations when `@RequirePermission` metadata is present — without it, changes are silently untracked
+
+### Multi-Product Architecture
+- Products (compliance, pen testing) are org-level subscription concerns — NOT RBAC
+- RBAC controls user access within products
+- `pentest` is its own resource: `['create', 'read', 'delete']`
+- Custom roles can grant access to any combination of product resources
+
+## Testing Requirements
+
+### Mandatory Testing
+
+**Every new feature MUST include tests.** Before marking a task as complete:
+
+1. Write unit tests for new services and controllers
+2. Run the tests to verify they pass
+3. Commit tests alongside the feature code
+
+### Running Tests
+
+```bash
+# Run tests for a specific module (from apps/api directory)
+npx jest src/ --passWithNoTests
+
+# Run tests for changed files only
+npx jest --onlyChanged
+
+# Run all API tests (from repo root)
+npx turbo run test --filter=@comp/api
+
+# Type-check before committing
+npx turbo run typecheck --filter=@comp/api
+```
+
+### Test Patterns
+
+```typescript
+// Mock external dependencies
+jest.mock('@trycompai/db', () => ({
+ db: {
+ someTable: {
+ findFirst: jest.fn(),
+ create: jest.fn(),
+ },
+ },
+}));
+
+// Override guards in controller tests
+const module = await Test.createTestingModule({
+ controllers: [MyController],
+ providers: [{ provide: MyService, useValue: mockService }],
+})
+ .overrideGuard(HybridAuthGuard)
+ .useValue({ canActivate: () => true })
+ .compile();
+```
+
+### What to Test
+
+| Component | Test Coverage |
+|-----------|---------------|
+| Services | All public methods, validation logic, error handling |
+| Controllers | Parameter passing to services, response mapping |
+| Guards | Authorization decisions, edge cases |
+| DTOs | Validation decorators (via e2e or integration tests) |
+| Utils | All functions, edge cases, error conditions |
+
+## Code Style
+
+### Error Handling
+
+- Use NestJS exceptions: `BadRequestException`, `NotFoundException`, `ForbiddenException`
+- Use `HttpException` with `HttpStatus.PAYMENT_REQUIRED` for billing failures (402)
+- Provide clear, actionable error messages
+- Don't expose internal details in error responses
+
+### Database Access
+
+- Use Prisma via `@trycompai/db`
+- Always scope queries by `organizationId` for multi-tenancy
+- Use transactions for operations that modify multiple records
+
+### Gotchas
+
+- **ValidationPipe with `transform: true`** mangles nested JSON. Use `@Req() req` and `req.body` directly for endpoints receiving complex nested JSON (like TipTap content).
+- **No `as any` casts.** Define proper types.
+- **Max 300 lines per file.** Split into focused modules.
+
+## Development Workflow
+
+1. **Before coding**: Read existing code patterns in the module
+2. **During coding**: Follow established patterns, add types
+3. **After coding**:
+ - Run `npx turbo run typecheck --filter=@comp/api`
+ - Write and run tests: `npx jest src/`
+ - Commit with conventional commit message
+
+## Common Commands
+
+```bash
+# Start API in development
+npx turbo run dev --filter=@comp/api
+
+# Type-check
+npx turbo run typecheck --filter=@comp/api
+
+# Run specific test file
+npx jest src/roles/roles.service.spec.ts
+
+# Generate Prisma client after schema changes
+cd packages/db && npx prisma generate
+```
diff --git a/apps/api/Dockerfile.multistage b/apps/api/Dockerfile.multistage
index 8d77571dc9..67611dd2b7 100644
--- a/apps/api/Dockerfile.multistage
+++ b/apps/api/Dockerfile.multistage
@@ -9,6 +9,7 @@ WORKDIR /app
COPY package.json bun.lock ./
# Copy all workspace package.json files
+COPY packages/auth/package.json ./packages/auth/
COPY packages/db/package.json ./packages/db/
COPY packages/utils/package.json ./packages/utils/
COPY packages/integration-platform/package.json ./packages/integration-platform/
@@ -30,6 +31,7 @@ FROM deps AS builder
WORKDIR /app
# Copy workspace packages source
+COPY packages/auth ./packages/auth
COPY packages/db ./packages/db
COPY packages/utils ./packages/utils
COPY packages/integration-platform ./packages/integration-platform
@@ -44,6 +46,7 @@ COPY apps/api ./apps/api
COPY --from=deps /app/node_modules ./node_modules
# Build workspace packages
+RUN cd packages/auth && bun run build && cd ../..
RUN cd packages/db && bun run build && cd ../..
RUN cd packages/integration-platform && bun run build && cd ../..
RUN cd packages/email && bun run build && cd ../..
@@ -77,6 +80,7 @@ COPY --from=builder /app/apps/api/prisma ./prisma
COPY --from=builder /app/apps/api/package.json ./package.json
# Copy workspace packages that are referenced by node_modules symlinks
+COPY --from=builder /app/packages/auth ./packages/auth
COPY --from=builder /app/packages/db ./packages/db
COPY --from=builder /app/packages/utils ./packages/utils
COPY --from=builder /app/packages/integration-platform ./packages/integration-platform
diff --git a/apps/api/buildspec.yml b/apps/api/buildspec.yml
index 38198432db..c3d4f14fe4 100644
--- a/apps/api/buildspec.yml
+++ b/apps/api/buildspec.yml
@@ -37,8 +37,10 @@ phases:
# Build workspace packages
- echo "Building workspace packages..."
+ - cd packages/auth && bun run build && cd ../..
- cd packages/db && bun run build && cd ../..
- cd packages/integration-platform && bun run build && cd ../..
+ - cd packages/company && bun run build && cd ../..
- echo "Building NestJS application..."
- echo "APP_NAME is set to $APP_NAME"
@@ -77,19 +79,27 @@ phases:
- rm -rf ../docker-build/node_modules/@trycompai/utils
- rm -rf ../docker-build/node_modules/@trycompai/db
- rm -rf ../docker-build/node_modules/@comp/integration-platform
+ - rm -rf ../docker-build/node_modules/@comp/auth
+ - rm -rf ../docker-build/node_modules/@comp/company
- mkdir -p ../docker-build/node_modules/@trycompai/utils
- mkdir -p ../docker-build/node_modules/@trycompai/db
- mkdir -p ../docker-build/node_modules/@comp/integration-platform
+ - mkdir -p ../docker-build/node_modules/@comp/auth
+ - mkdir -p ../docker-build/node_modules/@comp/company
- cp -r ../../packages/utils/src ../docker-build/node_modules/@trycompai/utils/
- cp ../../packages/utils/package.json ../docker-build/node_modules/@trycompai/utils/
- cp -r ../../packages/db/dist ../docker-build/node_modules/@trycompai/db/
- cp ../../packages/db/package.json ../docker-build/node_modules/@trycompai/db/
- cp -r ../../packages/integration-platform/dist ../docker-build/node_modules/@comp/integration-platform/
- cp ../../packages/integration-platform/package.json ../docker-build/node_modules/@comp/integration-platform/
+ - cp -r ../../packages/auth/dist ../docker-build/node_modules/@comp/auth/
+ - cp ../../packages/auth/package.json ../docker-build/node_modules/@comp/auth/
+ - cp -r ../../packages/company/dist ../docker-build/node_modules/@comp/company/
+ - cp ../../packages/company/package.json ../docker-build/node_modules/@comp/company/
- cp Dockerfile ../docker-build/
- # Remove workspace dependency from package.json (it's copied manually above)
- - cat package.json | jq 'del(.dependencies["@comp/integration-platform"])' > ../docker-build/package.json
+ # Remove workspace dependencies from package.json (they're copied manually above)
+ - cat package.json | jq 'del(.dependencies["@comp/integration-platform"]) | del(.dependencies["@comp/auth"]) | del(.dependencies["@comp/company"])' > ../docker-build/package.json
- cp ../../bun.lock ../docker-build/ || true
- echo "Building Docker image..."
diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json
index f9aa683b1a..ceb68c2b37 100644
--- a/apps/api/nest-cli.json
+++ b/apps/api/nest-cli.json
@@ -2,6 +2,7 @@
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
+ "entryFile": "src/main",
"compilerOptions": {
"deleteOutDir": true
}
diff --git a/apps/api/package.json b/apps/api/package.json
index 7202037f9c..5744056536 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -7,12 +7,14 @@
"@ai-sdk/anthropic": "^2.0.53",
"@ai-sdk/groq": "^2.0.32",
"@ai-sdk/openai": "^2.0.65",
+ "@aws-sdk/client-ec2": "^3.911.0",
"@aws-sdk/client-s3": "^3.859.0",
"@aws-sdk/client-securityhub": "^3.948.0",
"@aws-sdk/client-sts": "^3.948.0",
"@aws-sdk/s3-request-presigner": "^3.859.0",
"@browserbasehq/sdk": "^2.6.0",
"@browserbasehq/stagehand": "^3.0.5",
+ "@comp/auth": "workspace:*",
"@comp/company": "workspace:*",
"@comp/integration-platform": "workspace:*",
"@mendable/firecrawl-js": "^4.9.3",
@@ -25,8 +27,10 @@
"@prisma/client": "6.18.0",
"@prisma/instrumentation": "^6.13.0",
"@react-email/components": "^0.0.41",
+ "@react-email/render": "^2.0.4",
"@trigger.dev/build": "4.0.6",
"@trigger.dev/sdk": "4.0.6",
+ "@thallesp/nestjs-better-auth": "^2.4.0",
"@trycompai/db": "1.3.22",
"@trycompai/email": "workspace:*",
"@upstash/redis": "^1.34.2",
@@ -56,6 +60,7 @@
"resend": "^6.4.2",
"rxjs": "^7.8.1",
"safe-stable-stringify": "^2.5.0",
+ "stripe": "^20.4.0",
"swagger-ui-express": "^5.0.1",
"xlsx": "^0.18.5",
"zod": "^4.0.14"
@@ -92,16 +97,25 @@
},
"jest": {
"moduleFileExtensions": [
- "js", "json", "ts"
+ "js",
+ "json",
+ "ts",
+ "tsx"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
- "^.+\\.(t|j)s$": "ts-jest"
+ "^.+\\.(t|j)sx?$": "ts-jest"
},
- "collectCoverageFrom": ["**/*.(t|j)s"],
+ "collectCoverageFrom": [
+ "**/*.(t|j)s"
+ ],
"coverageDirectory": "../coverage",
- "testEnvironment": "node"
+ "testEnvironment": "node",
+ "moduleNameMapper": {
+ "^@db$": "/../prisma/index",
+ "^@/(.*)$": "/$1"
+ }
},
"license": "UNLICENSED",
"private": true,
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 131df119c6..c06bd8ef9d 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -36,7 +36,14 @@ import { AssistantChatModule } from './assistant-chat/assistant-chat.module';
import { OrgChartModule } from './org-chart/org-chart.module';
import { TrainingModule } from './training/training.module';
import { EvidenceFormsModule } from './evidence-forms/evidence-forms.module';
+import { FrameworksModule } from './frameworks/frameworks.module';
+import { AuditModule } from './audit/audit.module';
+import { ControlsModule } from './controls/controls.module';
+import { RolesModule } from './roles/roles.module';
+import { EmailModule } from './email/email.module';
+import { SecretsModule } from './secrets/secrets.module';
import { SecurityPenetrationTestsModule } from './security-penetration-tests/security-penetration-tests.module';
+import { StripeModule } from './stripe/stripe.module';
@Module({
imports: [
@@ -85,7 +92,14 @@ import { SecurityPenetrationTestsModule } from './security-penetration-tests/sec
TrainingModule,
OrgChartModule,
EvidenceFormsModule,
+ FrameworksModule,
+ RolesModule,
+ AuditModule,
+ ControlsModule,
+ EmailModule,
+ SecretsModule,
SecurityPenetrationTestsModule,
+ StripeModule,
],
controllers: [AppController],
providers: [
diff --git a/apps/api/src/assistant-chat/assistant-chat-tools.ts b/apps/api/src/assistant-chat/assistant-chat-tools.ts
new file mode 100644
index 0000000000..eb4314a7e9
--- /dev/null
+++ b/apps/api/src/assistant-chat/assistant-chat-tools.ts
@@ -0,0 +1,112 @@
+import { db, Departments, RiskCategory, RiskStatus } from '@trycompai/db';
+import { z } from 'zod';
+
+type Permissions = Record;
+
+interface ToolContext {
+ organizationId: string;
+ userId: string;
+ permissions: Permissions;
+}
+
+function hasPermission(permissions: Permissions, resource: string, action: string): boolean {
+ return permissions[resource]?.includes(action) ?? false;
+}
+
+export function buildTools(ctx: ToolContext) {
+ const tools: Record Promise }> = {};
+
+ // Always available
+ tools.findOrganization = {
+ description: "Find the user's organization and its details",
+ inputSchema: z.object({}),
+ execute: async () => {
+ const org = await db.organization.findUnique({
+ where: { id: ctx.organizationId },
+ select: { name: true },
+ });
+ return org ? { organization: org } : { organization: null, message: 'Organization not found' };
+ },
+ };
+
+ tools.getUser = {
+ description: "Get the user's id and organization id",
+ inputSchema: z.object({}),
+ execute: async () => ({
+ userId: ctx.userId,
+ organizationId: ctx.organizationId,
+ }),
+ };
+
+ // Policy tools — require policy:read
+ if (hasPermission(ctx.permissions, 'policy', 'read')) {
+ tools.getPolicies = {
+ description: 'Get all policies for the organization',
+ inputSchema: z.object({
+ status: z.enum(['draft', 'published']).optional(),
+ }),
+ execute: async ({ status }: { status?: 'draft' | 'published' }) => {
+ const policies = await db.policy.findMany({
+ where: { organizationId: ctx.organizationId, status },
+ select: { id: true, name: true, description: true, department: true },
+ });
+ return policies.length === 0
+ ? { policies: [], message: 'No policies found' }
+ : { policies };
+ },
+ };
+
+ tools.getPolicyContent = {
+ description: 'Get the content of a specific policy by id. Run getPolicies first to get ids.',
+ inputSchema: z.object({ id: z.string() }),
+ execute: async ({ id }: { id: string }) => {
+ const policy = await db.policy.findUnique({
+ where: { id, organizationId: ctx.organizationId },
+ select: { content: true },
+ });
+ return policy ? { content: policy.content } : { content: null, message: 'Policy not found' };
+ },
+ };
+ }
+
+ // Risk tools — require risk:read
+ if (hasPermission(ctx.permissions, 'risk', 'read')) {
+ tools.getRisks = {
+ description: 'Get risks for the organization',
+ inputSchema: z.object({
+ status: z.enum(Object.values(RiskStatus) as [RiskStatus, ...RiskStatus[]]).optional(),
+ department: z.enum(Object.values(Departments) as [Departments, ...Departments[]]).optional(),
+ category: z.enum(Object.values(RiskCategory) as [RiskCategory, ...RiskCategory[]]).optional(),
+ owner: z.string().optional(),
+ }),
+ execute: async (input: { status?: RiskStatus; department?: Departments; category?: RiskCategory; owner?: string }) => {
+ const risks = await db.risk.findMany({
+ where: {
+ organizationId: ctx.organizationId,
+ status: input.status,
+ department: input.department,
+ category: input.category,
+ assigneeId: input.owner,
+ },
+ select: { id: true, title: true, status: true },
+ });
+ return risks.length === 0
+ ? { risks: [], message: 'No risks found' }
+ : { risks };
+ },
+ };
+
+ tools.getRiskById = {
+ description: 'Get a risk by id',
+ inputSchema: z.object({ id: z.string() }),
+ execute: async ({ id }: { id: string }) => {
+ const risk = await db.risk.findUnique({
+ where: { id, organizationId: ctx.organizationId },
+ });
+ return risk ? { risk } : { risk: null, message: 'Risk not found' };
+ },
+ };
+ }
+
+ return tools;
+}
diff --git a/apps/api/src/assistant-chat/assistant-chat.controller.ts b/apps/api/src/assistant-chat/assistant-chat.controller.ts
index dbd58220ad..4693acfa83 100644
--- a/apps/api/src/assistant-chat/assistant-chat.controller.ts
+++ b/apps/api/src/assistant-chat/assistant-chat.controller.ts
@@ -4,48 +4,60 @@ import {
Controller,
Delete,
Get,
+ HttpException,
+ HttpStatus,
+ Post,
Put,
+ Req,
+ Res,
UseGuards,
+ Logger,
} from '@nestjs/common';
import {
- ApiHeader,
ApiOperation,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
+import { openai } from '@ai-sdk/openai';
+import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai';
+import type { Response, Request } from 'express';
import { AuthContext } 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 { SkipAuditLog } from '../audit/skip-audit-log.decorator';
import { SaveAssistantChatHistoryDto } from './assistant-chat.dto';
import { AssistantChatService } from './assistant-chat.service';
+import { buildTools } from './assistant-chat-tools';
import type { AssistantChatMessage } from './assistant-chat.types';
+import { RolesService } from '../roles/roles.service';
@ApiTags('Assistant Chat')
@Controller({ path: 'assistant-chat', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@RequirePermission('app', 'read')
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for JWT auth, optional for API key auth)',
- required: false,
-})
export class AssistantChatController {
- constructor(private readonly assistantChatService: AssistantChatService) {}
+ private readonly logger = new Logger(AssistantChatController.name);
+
+ constructor(
+ private readonly assistantChatService: AssistantChatService,
+ private readonly rolesService: RolesService,
+ ) {}
private getUserScopedContext(auth: AuthContextType): {
organizationId: string;
userId: string;
} {
- // Defensive checks (should already be guaranteed by HybridAuthGuard + AuthContext decorator)
if (!auth.organizationId) {
throw new BadRequestException('Organization ID is required');
}
if (auth.isApiKey) {
throw new BadRequestException(
- 'Assistant chat history is only available for user-authenticated requests (Bearer JWT).',
+ 'Assistant chat is only available for user-authenticated requests.',
);
}
@@ -56,6 +68,107 @@ export class AssistantChatController {
return { organizationId: auth.organizationId, userId: auth.userId };
}
+ @Post('completions')
+ @SkipAuditLog()
+ @ApiOperation({
+ summary: 'Stream AI chat completion',
+ description:
+ 'Streams an AI response based on the conversation messages. Tools are permission-gated per user.',
+ })
+ @ApiResponse({ status: 200, description: 'Streaming AI response' })
+ async completions(
+ @AuthContext() auth: AuthContextType,
+ @Req() req: Request,
+ @Res() res: Response,
+ ) {
+ // @Res() bypasses NestJS exception filters, so we must handle errors manually
+ try {
+ if (!process.env.OPENAI_API_KEY) {
+ res.status(HttpStatus.SERVICE_UNAVAILABLE).json({ message: 'AI service not configured' });
+ return;
+ }
+
+ const { organizationId, userId } = this.getUserScopedContext(auth);
+
+ const body = req.body as { messages?: UIMessage[] };
+ const messages = body?.messages ?? [];
+
+ const userRoles = auth.userRoles ?? [];
+ const permissions = await this.rolesService.resolvePermissions(
+ organizationId,
+ userRoles,
+ );
+
+ const tools = buildTools({ organizationId, userId, permissions });
+
+ const nowIso = new Date().toISOString();
+
+ const systemPrompt = `
+You're an expert in GRC, and a helpful assistant in Comp AI,
+a platform that helps companies get compliant with frameworks
+like SOC 2, ISO 27001 and GDPR.
+
+You must respond in basic markdown format (only use paragraphs, lists and bullet points).
+
+Keep responses concise and to the point.
+
+If you are unsure about the answer, say "I don't know" or "I don't know the answer to that question".
+
+Important:
+- Today's date/time is ${nowIso}.
+- You are assisting a user inside a live application (organizationId: ${organizationId}).
+- Prefer using available tools to fetch up-to-date org data (policies, risks, organization details) rather than guessing.
+- If the question depends on the customer's current configuration/data and you haven't retrieved it, call the relevant tool first.
+- If the user asks about data you don't have tools for, let them know you can't access that information with their current permissions.
+`;
+
+ const result = streamText({
+ model: openai('gpt-5'),
+ system: systemPrompt,
+ messages: convertToModelMessages(messages),
+ tools,
+ stopWhen: stepCountIs(5),
+ });
+
+ const webResponse = result.toUIMessageStreamResponse({
+ sendReasoning: false,
+ });
+
+ res.status(webResponse.status);
+ webResponse.headers.forEach((value, key) => {
+ res.setHeader(key, value);
+ });
+
+ if (webResponse.body) {
+ const reader = webResponse.body.getReader();
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ res.write(value);
+ }
+ } catch (error) {
+ this.logger.error('Stream reading error', error);
+ } finally {
+ res.end();
+ }
+ } else {
+ res.end();
+ }
+ } catch (error) {
+ this.logger.error('Completions endpoint error', error);
+ if (!res.headersSent) {
+ const status = error instanceof HttpException ? error.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
+ const message = error instanceof HttpException ? error.message : 'Internal server error';
+ res.status(status).json({ message });
+ } else {
+ res.end();
+ }
+ }
+ }
+
@Get('history')
@ApiOperation({
summary: 'Get assistant chat history',
diff --git a/apps/api/src/assistant-chat/assistant-chat.module.ts b/apps/api/src/assistant-chat/assistant-chat.module.ts
index a06068c0c5..8640b73def 100644
--- a/apps/api/src/assistant-chat/assistant-chat.module.ts
+++ b/apps/api/src/assistant-chat/assistant-chat.module.ts
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
+import { RolesModule } from '../roles/roles.module';
import { AssistantChatController } from './assistant-chat.controller';
import { AssistantChatService } from './assistant-chat.service';
@Module({
- imports: [AuthModule],
+ imports: [AuthModule, RolesModule],
controllers: [AssistantChatController],
providers: [AssistantChatService],
})
diff --git a/apps/api/src/attachments/attachments.controller.spec.ts b/apps/api/src/attachments/attachments.controller.spec.ts
new file mode 100644
index 0000000000..f77a0d39f9
--- /dev/null
+++ b/apps/api/src/attachments/attachments.controller.spec.ts
@@ -0,0 +1,76 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { AttachmentsController } from './attachments.controller';
+import { AttachmentsService } from './attachments.service';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {},
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('AttachmentsController', () => {
+ let controller: AttachmentsController;
+ let attachmentsService: jest.Mocked;
+
+ const mockAttachmentsService = {
+ getAttachmentDownloadUrl: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [AttachmentsController],
+ providers: [
+ { provide: AttachmentsService, useValue: mockAttachmentsService },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(AttachmentsController);
+ attachmentsService = module.get(AttachmentsService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getAttachmentDownloadUrl', () => {
+ it('should call attachmentsService.getAttachmentDownloadUrl with correct params', async () => {
+ const downloadResult = {
+ downloadUrl: 'https://bucket.s3.amazonaws.com/file.pdf?sig=abc',
+ expiresIn: 900,
+ };
+ mockAttachmentsService.getAttachmentDownloadUrl.mockResolvedValue(
+ downloadResult,
+ );
+
+ const result = await controller.getAttachmentDownloadUrl(
+ 'org_123',
+ 'att_abc123',
+ );
+
+ expect(
+ attachmentsService.getAttachmentDownloadUrl,
+ ).toHaveBeenCalledWith('org_123', 'att_abc123');
+ expect(result).toEqual(downloadResult);
+ });
+
+ it('should propagate errors from the service', async () => {
+ mockAttachmentsService.getAttachmentDownloadUrl.mockRejectedValue(
+ new Error('Attachment not found'),
+ );
+
+ await expect(
+ controller.getAttachmentDownloadUrl('org_123', 'att_invalid'),
+ ).rejects.toThrow('Attachment not found');
+ });
+ });
+});
diff --git a/apps/api/src/attachments/attachments.controller.ts b/apps/api/src/attachments/attachments.controller.ts
index b320e9a1ae..2f347a0a85 100644
--- a/apps/api/src/attachments/attachments.controller.ts
+++ b/apps/api/src/attachments/attachments.controller.ts
@@ -1,6 +1,5 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
- ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
@@ -9,22 +8,19 @@ import {
} from '@nestjs/swagger';
import { 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 { AttachmentsService } from './attachments.service';
@ApiTags('Attachments')
@Controller({ path: 'attachments', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class AttachmentsController {
constructor(private readonly attachmentsService: AttachmentsService) {}
@Get(':attachmentId/download')
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'Get attachment download URL',
description: 'Generate a fresh signed URL for downloading any attachment',
diff --git a/apps/api/src/attachments/attachments.service.ts b/apps/api/src/attachments/attachments.service.ts
index 48a2e97f2d..f38b51ad00 100644
--- a/apps/api/src/attachments/attachments.service.ts
+++ b/apps/api/src/attachments/attachments.service.ts
@@ -291,6 +291,16 @@ export class AttachmentsService {
}
}
+ /**
+ * Get attachment by ID
+ */
+ async getAttachmentById(organizationId: string, attachmentId: string) {
+ return db.attachment.findFirst({
+ where: { id: attachmentId, organizationId },
+ select: { id: true, name: true, type: true },
+ });
+ }
+
/**
* Delete attachment from S3 and database
*/
@@ -440,6 +450,40 @@ export class AttachmentsService {
});
}
+ /**
+ * Generate a presigned URL for viewing a PDF inline in the browser
+ */
+ async getPresignedInlinePdfUrl(s3Key: string): Promise {
+ const getCommand = new GetObjectCommand({
+ Bucket: this.bucketName,
+ Key: s3Key,
+ ResponseContentDisposition: 'inline',
+ ResponseContentType: 'application/pdf',
+ });
+
+ return getSignedUrl(this.s3Client, getCommand, {
+ expiresIn: this.SIGNED_URL_EXPIRY,
+ });
+ }
+
+ /**
+ * Upload a buffer to S3 with a specific key (no auto-generated path)
+ */
+ async uploadBuffer(
+ s3Key: string,
+ buffer: Buffer,
+ contentType: string,
+ ): Promise {
+ const putCommand = new PutObjectCommand({
+ Bucket: this.bucketName,
+ Key: s3Key,
+ Body: buffer,
+ ContentType: contentType,
+ });
+
+ await this.s3Client.send(putCommand);
+ }
+
async getObjectBuffer(s3Key: string): Promise {
const getCommand = new GetObjectCommand({
Bucket: this.bucketName,
diff --git a/apps/api/src/audit/audit-log.constants.ts b/apps/api/src/audit/audit-log.constants.ts
new file mode 100644
index 0000000000..ded7922095
--- /dev/null
+++ b/apps/api/src/audit/audit-log.constants.ts
@@ -0,0 +1,70 @@
+import { AuditLogEntityType, CommentEntityType } from '@db';
+
+export const MUTATION_METHODS = new Set(['POST', 'PATCH', 'PUT', 'DELETE']);
+
+export const SENSITIVE_KEYS = new Set([
+ 'password',
+ 'secret',
+ 'token',
+ 'apiKey',
+ 'api_key',
+ 'accessToken',
+ 'access_token',
+ 'refreshToken',
+ 'refresh_token',
+ 'authorization',
+ 'credential',
+ 'credentials',
+ 'privateKey',
+ 'private_key',
+]);
+
+export const RESOURCE_TO_ENTITY_TYPE: Record<
+ string,
+ AuditLogEntityType | null
+> = {
+ organization: AuditLogEntityType.organization,
+ member: AuditLogEntityType.people,
+ invitation: AuditLogEntityType.people,
+ control: AuditLogEntityType.control,
+ evidence: AuditLogEntityType.task,
+ policy: AuditLogEntityType.policy,
+ risk: AuditLogEntityType.risk,
+ vendor: AuditLogEntityType.vendor,
+ task: AuditLogEntityType.task,
+ framework: AuditLogEntityType.framework,
+ finding: AuditLogEntityType.finding,
+ integration: AuditLogEntityType.integration,
+ portal: AuditLogEntityType.trust,
+ trust: AuditLogEntityType.trust,
+ app: AuditLogEntityType.organization,
+ questionnaire: AuditLogEntityType.organization,
+ audit: null,
+};
+
+export const RESOURCE_TO_PRISMA_MODEL: Record = {
+ policy: 'policy',
+ vendor: 'vendor',
+ risk: 'risk',
+ control: 'control',
+ finding: 'finding',
+ organization: 'organization',
+ member: 'member',
+ framework: 'frameworkInstance',
+ task: 'taskItem',
+ portal: 'trust',
+};
+
+export const COMMENT_ENTITY_TYPE_MAP: Record = {
+ [CommentEntityType.task]: AuditLogEntityType.task,
+ [CommentEntityType.vendor]: AuditLogEntityType.vendor,
+ [CommentEntityType.risk]: AuditLogEntityType.risk,
+ [CommentEntityType.policy]: AuditLogEntityType.policy,
+};
+
+// Fields that reference the member table and should be resolved to user names.
+// Key = request body field name, value = display label in audit log.
+export const MEMBER_REF_FIELDS: Record = {
+ assigneeId: 'assignee',
+ approverId: 'approver',
+};
diff --git a/apps/api/src/audit/audit-log.controller.spec.ts b/apps/api/src/audit/audit-log.controller.spec.ts
new file mode 100644
index 0000000000..15bd98ba7f
--- /dev/null
+++ b/apps/api/src/audit/audit-log.controller.spec.ts
@@ -0,0 +1,254 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { AuditLogController } from './audit-log.controller';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext as AuthContextType } from '../auth/types';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ app: ['read'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+const mockFindMany = jest.fn();
+jest.mock('@trycompai/db', () => ({
+ db: {
+ auditLog: {
+ findMany: (...args: unknown[]) => mockFindMany(...args),
+ },
+ },
+ Prisma: {},
+}));
+
+describe('AuditLogController', () => {
+ let controller: AuditLogController;
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContextType = {
+ authType: 'session' as const,
+ userId: 'usr_1',
+ userEmail: 'user@example.com',
+ organizationId: 'org_1',
+ memberId: 'mem_1',
+ permissions: [],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [AuditLogController],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(AuditLogController);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getAuditLogs', () => {
+ it('should return logs with default take of 50', async () => {
+ const mockLogs = [{ id: 'log_1' }, { id: 'log_2' }];
+ mockFindMany.mockResolvedValue(mockLogs);
+
+ const result = await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ );
+
+ expect(result).toEqual({
+ data: mockLogs,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_1', email: 'user@example.com' },
+ });
+ expect(mockFindMany).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ include: {
+ user: {
+ select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true },
+ },
+ member: true,
+ organization: true,
+ },
+ orderBy: { timestamp: 'desc' },
+ take: 50,
+ });
+ });
+
+ it('should filter by single entityType', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ 'policy',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { organizationId: 'org_1', entityType: 'policy' },
+ }),
+ );
+ });
+
+ it('should filter by multiple comma-separated entityTypes', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ 'risk,task',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ organizationId: 'org_1',
+ entityType: { in: ['risk', 'task'] },
+ },
+ }),
+ );
+ });
+
+ it('should filter by single entityId', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ 'ent_1',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { organizationId: 'org_1', entityId: 'ent_1' },
+ }),
+ );
+ });
+
+ it('should filter by multiple comma-separated entityIds', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ 'ent_1,ent_2',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ organizationId: 'org_1',
+ entityId: { in: ['ent_1', 'ent_2'] },
+ },
+ }),
+ );
+ });
+
+ it('should filter by pathContains', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ 'auto_123',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ organizationId: 'org_1',
+ data: {
+ path: ['path'],
+ string_contains: 'auto_123',
+ },
+ },
+ }),
+ );
+ });
+
+ it('should respect custom take parameter capped at 100', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ '200',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 100 }),
+ );
+ });
+
+ it('should clamp take to minimum of 1', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ '-5',
+ );
+
+ // parseInt('-5') = -5, Math.max(1, -5) = 1
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 1 }),
+ );
+ });
+
+ it('should default take to 50 for invalid take values', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ await controller.getAuditLogs(
+ 'org_1',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ 'invalid',
+ );
+
+ expect(mockFindMany).toHaveBeenCalledWith(
+ expect.objectContaining({ take: 50 }),
+ );
+ });
+
+ it('should not include authenticatedUser when userId is absent', async () => {
+ mockFindMany.mockResolvedValue([]);
+
+ const authContextNoUser: AuthContextType = {
+ authType: 'api-key' as const,
+ organizationId: 'org_1',
+ permissions: [],
+ } as AuthContextType;
+
+ const result = await controller.getAuditLogs(
+ 'org_1',
+ authContextNoUser,
+ );
+
+ expect(result).toEqual({
+ data: [],
+ authType: 'api-key',
+ });
+ });
+ });
+});
diff --git a/apps/api/src/audit/audit-log.controller.ts b/apps/api/src/audit/audit-log.controller.ts
new file mode 100644
index 0000000000..4db1274b2a
--- /dev/null
+++ b/apps/api/src/audit/audit-log.controller.ts
@@ -0,0 +1,77 @@
+import { Controller, Get, Query, UseGuards } from '@nestjs/common';
+import { ApiOperation, ApiQuery, ApiSecurity, ApiTags } from '@nestjs/swagger';
+import { db, Prisma } from '@trycompai/db';
+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';
+
+@ApiTags('Audit Logs')
+@Controller({ path: 'audit-logs', version: '1' })
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
+export class AuditLogController {
+ @Get()
+ @RequirePermission('app', 'read')
+ @ApiOperation({ summary: 'Get audit logs filtered by entity type and ID' })
+ @ApiQuery({ name: 'entityType', required: false, description: 'Filter by entity type (e.g. policy, task, control)' })
+ @ApiQuery({ name: 'entityId', required: false, description: 'Filter by entity ID' })
+ @ApiQuery({ name: 'pathContains', required: false, description: 'Filter by path substring (e.g. automation ID)' })
+ @ApiQuery({ name: 'take', required: false, description: 'Number of logs to return (max 100, default 50)' })
+ async getAuditLogs(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ @Query('entityType') entityType?: string,
+ @Query('entityId') entityId?: string,
+ @Query('pathContains') pathContains?: string,
+ @Query('take') take?: string,
+ ) {
+ // organizationId comes from auth context (not user input) — ensures tenant isolation
+ const where: Record = { organizationId };
+ if (entityType) {
+ // Support comma-separated entity types (e.g. "risk,task")
+ const types = entityType.split(',').map((t) => t.trim()).filter(Boolean);
+ where.entityType = types.length === 1 ? types[0] : { in: types };
+ }
+ if (entityId) {
+ // Support comma-separated entity IDs
+ const ids = entityId.split(',').map((id) => id.trim()).filter(Boolean);
+ where.entityId = ids.length === 1 ? ids[0] : { in: ids };
+ }
+ if (pathContains) {
+ where.data = {
+ path: ['path'],
+ string_contains: pathContains,
+ } satisfies Prisma.JsonFilter;
+ }
+
+ const parsedTake = take
+ ? Math.min(100, Math.max(1, parseInt(take, 10) || 50))
+ : 50;
+
+ const logs = await db.auditLog.findMany({
+ where,
+ include: {
+ user: {
+ select: { id: true, name: true, email: true, image: true, isPlatformAdmin: true },
+ },
+ member: true,
+ organization: true,
+ },
+ orderBy: { timestamp: 'desc' },
+ take: parsedTake,
+ });
+
+ return {
+ data: logs,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+}
diff --git a/apps/api/src/audit/audit-log.interceptor.spec.ts b/apps/api/src/audit/audit-log.interceptor.spec.ts
new file mode 100644
index 0000000000..94be902fbc
--- /dev/null
+++ b/apps/api/src/audit/audit-log.interceptor.spec.ts
@@ -0,0 +1,1382 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ExecutionContext, CallHandler } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { of } from 'rxjs';
+
+// Mock auth.server before importing anything that depends on permission.guard
+jest.mock('../auth/auth.server', () => ({
+ auth: {
+ api: {
+ hasPermission: jest.fn(),
+ },
+ },
+}));
+
+const mockCreate = jest.fn();
+const mockFindUnique = jest.fn();
+const mockPolicyFindUnique = jest.fn();
+const mockMemberFindMany = jest.fn();
+const mockControlFindMany = jest.fn();
+jest.mock('@db', () => ({
+ db: {
+ auditLog: {
+ create: (...args: unknown[]) => mockCreate(...args),
+ },
+ policy: {
+ findUnique: (...args: unknown[]) => mockPolicyFindUnique(...args),
+ },
+ vendor: {
+ findUnique: (...args: unknown[]) => mockFindUnique(...args),
+ },
+ risk: {
+ findUnique: (...args: unknown[]) => mockFindUnique(...args),
+ },
+ control: {
+ findUnique: (...args: unknown[]) => mockFindUnique(...args),
+ findMany: (...args: unknown[]) => mockControlFindMany(...args),
+ },
+ member: {
+ findMany: (...args: unknown[]) => mockMemberFindMany(...args),
+ },
+ },
+ AuditLogEntityType: {
+ organization: 'organization',
+ framework: 'framework',
+ requirement: 'requirement',
+ control: 'control',
+ policy: 'policy',
+ task: 'task',
+ people: 'people',
+ risk: 'risk',
+ vendor: 'vendor',
+ tests: 'tests',
+ integration: 'integration',
+ trust: 'trust',
+ finding: 'finding',
+ },
+ CommentEntityType: {
+ task: 'task',
+ vendor: 'vendor',
+ risk: 'risk',
+ policy: 'policy',
+ },
+ Prisma: {},
+}));
+
+// Import after mocks
+import { AuditLogInterceptor } from './audit-log.interceptor';
+import { PERMISSIONS_KEY } from '../auth/permission.guard';
+import { AUDIT_READ_KEY, SKIP_AUDIT_LOG_KEY } from './skip-audit-log.decorator';
+
+describe('AuditLogInterceptor', () => {
+ let interceptor: AuditLogInterceptor;
+ let reflector: Reflector;
+
+ const createMockExecutionContext = (
+ overrides: {
+ method?: string;
+ url?: string;
+ organizationId?: string;
+ userId?: string;
+ memberId?: string;
+ params?: Record;
+ body?: Record;
+ } = {},
+ ): ExecutionContext => {
+ return {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ method: overrides.method ?? 'PATCH',
+ url: overrides.url ?? '/v1/policies/pol_123',
+ organizationId: overrides.organizationId ?? 'org_123',
+ userId: overrides.userId ?? 'user_123',
+ memberId: overrides.memberId ?? 'mem_123',
+ params: overrides.params ?? { id: 'pol_123' },
+ body: overrides.body ?? undefined,
+ headers: {},
+ }),
+ }),
+ getHandler: () => jest.fn(),
+ getClass: () => jest.fn(),
+ } as unknown as ExecutionContext;
+ };
+
+ const createMockCallHandler = (response: unknown = { id: 'new_123' }): CallHandler => ({
+ handle: () => of(response),
+ });
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [AuditLogInterceptor, Reflector],
+ }).compile();
+
+ interceptor = module.get(AuditLogInterceptor);
+ reflector = module.get(Reflector);
+ mockCreate.mockReset();
+ mockCreate.mockResolvedValue({});
+ mockFindUnique.mockReset();
+ mockFindUnique.mockResolvedValue(null);
+ mockPolicyFindUnique.mockReset();
+ mockPolicyFindUnique.mockResolvedValue(null);
+ mockMemberFindMany.mockReset();
+ mockMemberFindMany.mockResolvedValue([]);
+ mockControlFindMany.mockReset();
+ mockControlFindMany.mockResolvedValue([]);
+ });
+
+ it('should skip GET requests', (done) => {
+ const context = createMockExecutionContext({ method: 'GET' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should skip HEAD requests', (done) => {
+ const context = createMockExecutionContext({ method: 'HEAD' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should log POST requests', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['create'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies',
+ params: {},
+ });
+ const handler = createMockCallHandler({ id: 'pol_new' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ organizationId: 'org_123',
+ userId: 'user_123',
+ memberId: 'mem_123',
+ entityType: 'policy',
+ entityId: 'pol_new',
+ description: 'Created policy',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log PATCH requests with entity ID from params', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_123',
+ description: 'Updated policy',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log DELETE requests', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'vendor', actions: ['delete'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'DELETE',
+ url: '/v1/vendors/ven_456',
+ params: { id: 'ven_456' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'vendor',
+ entityId: 'ven_456',
+ description: 'Deleted vendor',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should skip routes with @SkipAuditLog()', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === SKIP_AUDIT_LOG_KEY) return true;
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'finding', actions: ['create'] }];
+ }
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({ method: 'POST' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should skip requests without userId', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ userId: '',
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should skip requests without organizationId', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ organizationId: '',
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should skip routes without @RequirePermission', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) return undefined;
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({ method: 'POST' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should handle db errors gracefully without throwing', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ mockCreate.mockRejectedValue(new Error('DB connection failed'));
+
+ const context = createMockExecutionContext({ method: 'PATCH' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalled();
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should map resource "member" to entity type "people"', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'member', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({ method: 'PATCH' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'people',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should map resource "trust" to entity type "trust"', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'trust', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({ method: 'PATCH' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'trust',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should skip audit resource to avoid audit-about-audit', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'audit', actions: ['read'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({ method: 'POST' });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+
+ it('should extract entity ID from response body for POST creates', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'risk', actions: ['create'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/risks',
+ params: {},
+ });
+ const handler = createMockCallHandler({ id: 'risk_789', name: 'Test Risk' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityId: 'risk_789',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should only log fields that actually changed for PATCH requests', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ // Mock current DB values
+ mockPolicyFindUnique.mockResolvedValue({
+ frequency: 'annually',
+ status: 'published',
+ name: 'My Policy',
+ });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ body: { frequency: 'quarterly', status: 'published', name: 'My Policy' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ data: expect.objectContaining({
+ changes: {
+ frequency: { previous: 'annually', current: 'quarterly' },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should show all fields as new for POST creates', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['create'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies',
+ params: {},
+ body: { name: 'New Policy', frequency: 'monthly' },
+ });
+ const handler = createMockCallHandler({ id: 'pol_new' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ data: expect.objectContaining({
+ changes: {
+ name: { previous: null, current: 'New Policy' },
+ frequency: { previous: null, current: 'monthly' },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should redact sensitive fields', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'integration', actions: ['create'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/integrations',
+ params: {},
+ body: { name: 'GitHub', apiKey: 'sk-secret-123' },
+ });
+ const handler = createMockCallHandler({ id: 'int_123' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ data: expect.objectContaining({
+ changes: {
+ name: { previous: null, current: 'GitHub' },
+ apiKey: { previous: null, current: '[REDACTED]' },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should not include changes key when no request body', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'vendor', actions: ['delete'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'DELETE',
+ url: '/v1/vendors/ven_456',
+ params: { id: 'ven_456' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ const callArg = mockCreate.mock.calls[0][0];
+ expect(callArg.data.data).not.toHaveProperty('changes');
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should not create changes entry when nothing changed', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ // Same values — nothing actually changed
+ mockPolicyFindUnique.mockResolvedValue({
+ frequency: 'monthly',
+ status: 'published',
+ });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ body: { frequency: 'monthly', status: 'published' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ const callArg = mockCreate.mock.calls[0][0];
+ expect(callArg.data.data).not.toHaveProperty('changes');
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log comment creation with the parent entity (policy) and no changes', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'task', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/comments',
+ params: {},
+ body: {
+ content: '{"type":"doc","content":[{"type":"text","text":"This looks good!"}]}',
+ entityId: 'pol_abc',
+ entityType: 'policy',
+ },
+ });
+ const handler = createMockCallHandler({ id: 'cmt_123' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_abc',
+ description: 'Commented on policy',
+ }),
+ });
+ // Should NOT include changes for comments
+ const callArg = mockCreate.mock.calls[0][0];
+ expect(callArg.data.data).not.toHaveProperty('changes');
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should resolve assigneeId to human-readable names in changes', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ mockPolicyFindUnique.mockResolvedValue({
+ assigneeId: 'mem_old',
+ frequency: 'monthly',
+ });
+
+ mockMemberFindMany.mockResolvedValue([
+ { id: 'mem_old', user: { name: 'Alice Smith' } },
+ { id: 'mem_new', user: { name: 'Bob Jones' } },
+ ]);
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ body: { assigneeId: 'mem_new', frequency: 'monthly' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ data: expect.objectContaining({
+ changes: {
+ assignee: { previous: 'Alice Smith (mem_old)', current: 'Bob Jones (mem_new)' },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should show Unassigned when assigneeId is null', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ mockPolicyFindUnique.mockResolvedValue({
+ assigneeId: null,
+ });
+
+ mockMemberFindMany.mockResolvedValue([
+ { id: 'mem_new', user: { name: 'Bob Jones' } },
+ ]);
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ body: { assigneeId: 'mem_new' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ data: expect.objectContaining({
+ changes: {
+ assignee: { previous: 'Unassigned', current: 'Bob Jones (mem_new)' },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log comment creation on a vendor with vendor entity type', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'task', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/comments',
+ params: {},
+ body: {
+ content: 'Need review',
+ entityId: 'ven_456',
+ entityType: 'vendor',
+ },
+ });
+ const handler = createMockCallHandler({ id: 'cmt_456' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'vendor',
+ entityId: 'ven_456',
+ description: 'Commented on vendor',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log control mapping with resolved names and before/after', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ // Existing controls on the policy
+ mockPolicyFindUnique.mockResolvedValue({
+ controls: [
+ { id: 'ctrl_1', name: 'Access Control' },
+ ],
+ });
+
+ // Resolve control names
+ mockControlFindMany.mockResolvedValue([
+ { id: 'ctrl_1', name: 'Access Control' },
+ { id: 'ctrl_2', name: 'Encryption' },
+ ]);
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/controls',
+ params: { id: 'pol_123' },
+ body: { controlIds: ['ctrl_2'] },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Mapped controls to policy',
+ data: expect.objectContaining({
+ changes: {
+ controls: {
+ previous: 'Access Control (ctrl_1)',
+ current: expect.stringContaining('Access Control (ctrl_1)'),
+ },
+ },
+ }),
+ }),
+ });
+ // Current should also contain the new control
+ const callArg = mockCreate.mock.calls[0][0];
+ const changes = callArg.data.data.changes;
+ expect(changes.controls.current).toContain('Encryption (ctrl_2)');
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log control unmapping with resolved names', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['delete'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ // Existing controls on the policy
+ mockPolicyFindUnique.mockResolvedValue({
+ controls: [
+ { id: 'ctrl_1', name: 'Access Control' },
+ { id: 'ctrl_2', name: 'Encryption' },
+ ],
+ });
+
+ // Resolve control names
+ mockControlFindMany.mockResolvedValue([
+ { id: 'ctrl_1', name: 'Access Control' },
+ { id: 'ctrl_2', name: 'Encryption' },
+ ]);
+
+ const context = createMockExecutionContext({
+ method: 'DELETE',
+ url: '/v1/policies/pol_123/controls/ctrl_2',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Unmapped control from policy',
+ data: expect.objectContaining({
+ changes: {
+ controls: {
+ previous: 'Access Control (ctrl_1), Encryption (ctrl_2)',
+ current: 'Access Control (ctrl_1)',
+ },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should show None when all controls are removed', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['delete'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ // Only one control exists
+ mockPolicyFindUnique.mockResolvedValue({
+ controls: [
+ { id: 'ctrl_1', name: 'Access Control' },
+ ],
+ });
+
+ mockControlFindMany.mockResolvedValue([
+ { id: 'ctrl_1', name: 'Access Control' },
+ ]);
+
+ const context = createMockExecutionContext({
+ method: 'DELETE',
+ url: '/v1/policies/pol_123/controls/ctrl_1',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Unmapped control from policy',
+ data: expect.objectContaining({
+ changes: {
+ controls: {
+ previous: 'Access Control (ctrl_1)',
+ current: 'None',
+ },
+ },
+ }),
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe publishing a policy version with version number', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['publish'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/versions/publish',
+ params: { id: 'pol_123' },
+ body: { setAsActive: true },
+ });
+ const handler = createMockCallHandler({
+ data: { versionId: 'ver_abc', version: 3 },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_123',
+ description: 'Published policy version 3',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe creating a new policy version draft', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/versions',
+ params: { id: 'pol_123' },
+ body: {},
+ });
+ const handler = createMockCallHandler({
+ data: { versionId: 'ver_xyz', version: 5 },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Created policy version 5',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe activating a policy version', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['publish'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/versions/ver_abc/activate',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler({
+ data: { versionId: 'ver_abc', version: 2 },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Activated policy version 2',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe submitting a version for approval', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['approve'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/versions/ver_abc/submit-for-approval',
+ params: { id: 'pol_123' },
+ body: { approverId: 'mem_approver' },
+ });
+ const handler = createMockCallHandler({
+ data: { versionId: 'ver_abc', version: 4 },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Submitted policy version 4 for approval',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe deleting a policy version', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['delete'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'DELETE',
+ url: '/v1/policies/pol_123/versions/ver_abc',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler({
+ data: { deletedVersion: 2 },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Deleted policy version 2',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe updating version content without changes diff', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123/versions/ver_abc',
+ params: { id: 'pol_123' },
+ body: { content: [{ type: 'doc', content: [{ type: 'text', text: 'Hello' }] }] },
+ });
+ const handler = createMockCallHandler({ data: { versionId: 'ver_abc', version: 3 } });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Updated policy version 3 content',
+ }),
+ });
+ // Should NOT include changes for content edits (TipTap JSON is not useful as a diff)
+ const callArg = mockCreate.mock.calls[0][0];
+ expect(callArg.data.data).not.toHaveProperty('changes');
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe accepting policy changes with version number', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['approve'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/accept-changes',
+ params: { id: 'pol_123' },
+ body: { approverId: 'mem_approver' },
+ });
+ const handler = createMockCallHandler({
+ data: { success: true, version: 4, emailNotifications: [] },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Approved and published policy version 4',
+ }),
+ });
+ // Should not include changes for approval actions
+ const callArg = mockCreate.mock.calls[0][0];
+ expect(callArg.data.data).not.toHaveProperty('changes');
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe denying policy changes', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['approve'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/deny-changes',
+ params: { id: 'pol_123' },
+ body: { approverId: 'mem_approver' },
+ });
+ const handler = createMockCallHandler({
+ data: { success: true },
+ });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ description: 'Denied policy changes',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should log GET requests with @AuditRead() decorator', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['read'] }];
+ }
+ if (key === AUDIT_READ_KEY) return true;
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'GET',
+ url: '/v1/policies/pol_123/pdf/signed-url?versionId=ver_456',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler({ url: 'https://s3.example.com/policy.pdf' });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_123',
+ description: 'Downloaded policy PDF',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe regenerating a policy', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'POST',
+ url: '/v1/policies/pol_123/regenerate',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler({ success: true });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_123',
+ description: 'Regenerated policy',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe archiving a policy', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+ mockPolicyFindUnique.mockResolvedValue({ isArchived: false });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ body: { isArchived: true },
+ });
+ const handler = createMockCallHandler({ isArchived: true });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_123',
+ description: 'Archived policy',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should describe restoring a policy', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['update'] }];
+ }
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+ mockPolicyFindUnique.mockResolvedValue({ isArchived: true });
+
+ const context = createMockExecutionContext({
+ method: 'PATCH',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ body: { isArchived: false },
+ });
+ const handler = createMockCallHandler({ isArchived: false });
+
+ interceptor.intercept(context, handler).subscribe({
+ next: () => {
+ setTimeout(() => {
+ expect(mockCreate).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ entityType: 'policy',
+ entityId: 'pol_123',
+ description: 'Restored policy',
+ }),
+ });
+ done();
+ }, 50);
+ },
+ });
+ });
+
+ it('should still skip GET requests without @AuditRead()', (done) => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockImplementation((key) => {
+ if (key === PERMISSIONS_KEY) {
+ return [{ resource: 'policy', actions: ['read'] }];
+ }
+ if (key === AUDIT_READ_KEY) return false;
+ if (key === SKIP_AUDIT_LOG_KEY) return false;
+ return undefined;
+ });
+
+ const context = createMockExecutionContext({
+ method: 'GET',
+ url: '/v1/policies/pol_123',
+ params: { id: 'pol_123' },
+ });
+ const handler = createMockCallHandler();
+
+ interceptor.intercept(context, handler).subscribe({
+ complete: () => {
+ expect(mockCreate).not.toHaveBeenCalled();
+ done();
+ },
+ });
+ });
+});
diff --git a/apps/api/src/audit/audit-log.interceptor.ts b/apps/api/src/audit/audit-log.interceptor.ts
new file mode 100644
index 0000000000..afed9ad5e2
--- /dev/null
+++ b/apps/api/src/audit/audit-log.interceptor.ts
@@ -0,0 +1,289 @@
+import {
+ CallHandler,
+ ExecutionContext,
+ Injectable,
+ Logger,
+ NestInterceptor,
+} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { AuditLogEntityType, db, Prisma } from '@db';
+import { Observable, from, switchMap, tap } from 'rxjs';
+import {
+ PERMISSIONS_KEY,
+ RequiredPermission,
+} from '../auth/permission.guard';
+import { AuthenticatedRequest } from '../auth/types';
+import { AUDIT_READ_KEY, SKIP_AUDIT_LOG_KEY } from './skip-audit-log.decorator';
+import {
+ MEMBER_REF_FIELDS,
+ MUTATION_METHODS,
+ RESOURCE_TO_ENTITY_TYPE,
+} from './audit-log.constants';
+import {
+ type AuditContextOverride,
+ type ChangesRecord,
+ buildChanges,
+ buildDescription,
+ extractCommentContext,
+ extractDownloadDescription,
+ extractEntityId,
+ extractFindingDescription,
+ extractPolicyActionDescription,
+ extractVersionDescription,
+} from './audit-log.utils';
+import {
+ buildRelationMappingChanges,
+ fetchCurrentValues,
+ resolveMemberNames,
+} from './audit-log.resolvers';
+
+@Injectable()
+export class AuditLogInterceptor implements NestInterceptor {
+ private readonly logger = new Logger(AuditLogInterceptor.name);
+
+ constructor(private readonly reflector: Reflector) {}
+
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const request = context.switchToHttp().getRequest();
+ const method = request.method;
+
+ const isAuditRead = this.reflector.getAllAndOverride(
+ AUDIT_READ_KEY,
+ [context.getHandler(), context.getClass()],
+ );
+
+ if (!MUTATION_METHODS.has(method) && !isAuditRead) {
+ return next.handle();
+ }
+
+ if (this.shouldSkip(context)) {
+ return next.handle();
+ }
+
+ const requiredPermissions =
+ this.reflector.getAllAndOverride(PERMISSIONS_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+
+ if (!requiredPermissions?.length) {
+ return next.handle();
+ }
+
+ const { organizationId, userId, memberId } = request;
+ if (!organizationId || !userId) {
+ return next.handle();
+ }
+
+ const { resource, actions } = requiredPermissions[0];
+ // Derive the actual action from the HTTP method rather than using the first
+ // permission action. This is important when a controller declares multiple
+ // actions (e.g. ['create','read','update','delete']) at the class level.
+ const METHOD_TO_ACTION: Record = {
+ POST: 'create',
+ GET: 'read',
+ PATCH: 'update',
+ PUT: 'update',
+ DELETE: 'delete',
+ };
+ const action = METHOD_TO_ACTION[method] ?? actions[0];
+
+ if (resource === 'audit') {
+ return next.handle();
+ }
+
+ const requestBody = (request as any).body as
+ | Record
+ | undefined;
+ const entityId = (request as any).params?.id as string | undefined;
+ const isUpdate =
+ (method === 'PATCH' || method === 'PUT') && requestBody && entityId;
+
+ const safePreFlightPromise = this.preflight(
+ request.url,
+ method,
+ resource,
+ requestBody,
+ entityId,
+ isUpdate ? Object.keys(requestBody) : null,
+ ).catch((err) => {
+ this.logger.error('Audit preflight failed, proceeding without pre-flight data', err);
+ return { previousValues: null, memberNames: {} as Record, relationMappingResult: null };
+ });
+
+ return from(safePreFlightPromise).pipe(
+ switchMap(({ previousValues, memberNames, relationMappingResult }) =>
+ next.handle().pipe(
+ tap({
+ next: (responseBody) => {
+ const commentCtx = extractCommentContext(
+ request.url,
+ method,
+ requestBody,
+ );
+
+ let changes: ChangesRecord | null;
+ const versionDesc = extractVersionDescription(
+ request.url,
+ method,
+ responseBody,
+ requestBody,
+ );
+ const downloadDesc = extractDownloadDescription(
+ request.url,
+ method,
+ );
+ const policyActionDesc = extractPolicyActionDescription(
+ request.url,
+ method,
+ requestBody,
+ );
+ const findingDesc = extractFindingDescription(
+ request.url,
+ method,
+ resource,
+ (request as { userRoles?: string[] }).userRoles,
+ );
+ let descriptionOverride: string | null =
+ versionDesc ?? downloadDesc ?? policyActionDesc ?? findingDesc;
+
+ const isAutomationUpdate = policyActionDesc && /automations/.test(request.url) && method === 'PATCH';
+ const isAttachmentAction = policyActionDesc && /attachments/.test(request.url);
+
+ if (commentCtx || versionDesc || (policyActionDesc && !isAutomationUpdate && !isAttachmentAction)) {
+ // Comments and version operations don't produce meaningful diffs
+ // But preserve the comment/reason/changelog if provided in the request body
+ const note = requestBody?.comment || requestBody?.changelog;
+ const noteLabel = requestBody?.changelog ? 'changelog' : 'reason';
+ changes = note && typeof note === 'string'
+ ? { [noteLabel]: { previous: null, current: note } }
+ : null;
+ } else if (isAttachmentAction) {
+ // For attachments, show file details in the expandable section
+ // Upload: file info in request body. Delete: file info in response body.
+ const attachmentChanges: ChangesRecord = {};
+ const fileName = requestBody?.fileName || (responseBody && typeof responseBody === 'object' ? (responseBody as Record).fileName : null);
+ const fileType = requestBody?.fileType || (responseBody && typeof responseBody === 'object' ? (responseBody as Record).fileType : null);
+ if (fileName) attachmentChanges.file = { previous: null, current: fileName };
+ if (fileType) attachmentChanges.type = { previous: null, current: fileType };
+ changes = Object.keys(attachmentChanges).length > 0 ? attachmentChanges : null;
+ } else if (relationMappingResult) {
+ changes = relationMappingResult.changes;
+ descriptionOverride ??= relationMappingResult.description;
+ } else {
+ changes = requestBody
+ ? buildChanges(requestBody, previousValues, memberNames)
+ : null;
+ }
+
+ void this.persist(
+ organizationId,
+ userId,
+ memberId,
+ method,
+ request.url,
+ resource,
+ action,
+ request,
+ responseBody,
+ changes,
+ commentCtx,
+ descriptionOverride,
+ ).catch((err) => {
+ this.logger.error('Failed to create audit log entry', err);
+ });
+ },
+ }),
+ ),
+ ),
+ );
+ }
+
+ private shouldSkip(context: ExecutionContext): boolean {
+ return !!this.reflector.getAllAndOverride(SKIP_AUDIT_LOG_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ }
+
+ private async preflight(
+ url: string,
+ method: string,
+ resource: string,
+ requestBody: Record | undefined,
+ entityId: string | undefined,
+ updateFieldNames: string[] | null,
+ ) {
+ const previousValues =
+ updateFieldNames && entityId
+ ? await fetchCurrentValues(resource, entityId, updateFieldNames)
+ : null;
+
+ const memberIds = new Set();
+ if (requestBody) {
+ for (const field of Object.keys(MEMBER_REF_FIELDS)) {
+ const newVal = requestBody[field];
+ if (typeof newVal === 'string' && newVal) memberIds.add(newVal);
+ const prevVal = previousValues?.[field];
+ if (typeof prevVal === 'string' && prevVal) memberIds.add(prevVal);
+ }
+ }
+ const memberNames = await resolveMemberNames([...memberIds]);
+
+ const relationMappingResult = await buildRelationMappingChanges(
+ url,
+ method,
+ requestBody,
+ entityId,
+ );
+
+ return { previousValues, memberNames, relationMappingResult };
+ }
+
+ private async persist(
+ organizationId: string,
+ userId: string,
+ memberId: string | undefined,
+ method: string,
+ path: string,
+ resource: string,
+ action: string,
+ request: AuthenticatedRequest,
+ responseBody: unknown,
+ changes: ChangesRecord | null,
+ commentContext: AuditContextOverride | null,
+ descriptionOverride: string | null,
+ ): Promise {
+ const entityType =
+ commentContext?.entityType ?? RESOURCE_TO_ENTITY_TYPE[resource] ?? null;
+ const entityId =
+ commentContext?.entityId ?? extractEntityId(request, method, responseBody);
+ const description =
+ commentContext?.description ??
+ descriptionOverride ??
+ buildDescription(method, action, resource);
+
+ const auditData: Record = {
+ action: description,
+ method,
+ path,
+ resource,
+ permission: action,
+ };
+ if (changes) {
+ auditData.changes = changes;
+ }
+
+ await db.auditLog.create({
+ data: {
+ organizationId,
+ userId,
+ memberId: memberId ?? null,
+ entityType,
+ entityId,
+ description,
+ data: auditData as Prisma.InputJsonValue,
+ },
+ });
+ }
+}
diff --git a/apps/api/src/audit/audit-log.resolvers.ts b/apps/api/src/audit/audit-log.resolvers.ts
new file mode 100644
index 0000000000..7babbd8bd1
--- /dev/null
+++ b/apps/api/src/audit/audit-log.resolvers.ts
@@ -0,0 +1,161 @@
+import { db } from '@db';
+import { RESOURCE_TO_PRISMA_MODEL } from './audit-log.constants';
+import type { ChangesRecord, RelationMappingResult } from './audit-log.utils';
+
+export async function resolveMemberNames(
+ memberIds: string[],
+): Promise> {
+ if (memberIds.length === 0) return {};
+ try {
+ const members = await db.member.findMany({
+ where: { id: { in: memberIds } },
+ select: { id: true, user: { select: { name: true } } },
+ });
+ const map: Record = {};
+ for (const m of members) {
+ map[m.id] = m.user?.name || m.id;
+ }
+ return map;
+ } catch {
+ return {};
+ }
+}
+
+async function resolveControlNames(
+ controlIds: string[],
+): Promise> {
+ if (controlIds.length === 0) return {};
+ try {
+ const controls = await db.control.findMany({
+ where: { id: { in: controlIds } },
+ select: { id: true, name: true },
+ });
+ const map: Record = {};
+ for (const c of controls) {
+ map[c.id] = c.name ? `${c.name} (${c.id})` : c.id;
+ }
+ return map;
+ } catch {
+ return {};
+ }
+}
+
+/** Map of resource names to their Prisma models that have a `controls` relation */
+const RESOURCE_CONTROL_MODELS: Record = {
+ policies: 'policy',
+ risks: 'risk',
+};
+
+async function fetchControlIds(resource: string, parentId: string): Promise {
+ const modelName = RESOURCE_CONTROL_MODELS[resource];
+ if (!modelName) return [];
+ try {
+ const model = (db as unknown as Record)[modelName];
+ if (!model?.findUnique) return [];
+ const record = await model.findUnique({
+ where: { id: parentId },
+ select: { controls: { select: { id: true } } },
+ });
+ return (record as { controls?: { id: string }[] })?.controls?.map((c) => c.id) ?? [];
+ } catch {
+ return [];
+ }
+}
+
+/** Extract the resource name from a URL like /v1/policies/:id/controls */
+function extractResourceFromPath(path: string): string {
+ const match = path.match(/\/v1\/(\w+)\/[^/]+\/controls/);
+ return match?.[1] ?? 'resource';
+}
+
+export async function buildRelationMappingChanges(
+ path: string,
+ method: string,
+ requestBody: Record | undefined,
+ entityId: string | undefined,
+): Promise {
+ // POST /v1//:id/controls — mapping new controls
+ const mappingMatch = path.match(/\/v1\/\w+\/[^/]+\/controls\/?$/);
+ if (
+ mappingMatch &&
+ method === 'POST' &&
+ requestBody?.controlIds &&
+ entityId
+ ) {
+ const resource = extractResourceFromPath(path);
+ const newIds = requestBody.controlIds as string[];
+ const currentIds = await fetchControlIds(resource, entityId);
+ const allIds = [...new Set([...currentIds, ...newIds])];
+ const nameMap = await resolveControlNames(allIds);
+
+ const prevDisplay =
+ currentIds.length > 0
+ ? currentIds.map((id) => nameMap[id] || id).join(', ')
+ : 'None';
+ const afterIds = [...new Set([...currentIds, ...newIds])];
+ const afterDisplay = afterIds.map((id) => nameMap[id] || id).join(', ');
+
+ const singularResource = resource.replace(/s$/, '');
+
+ return {
+ changes: { controls: { previous: prevDisplay, current: afterDisplay } },
+ description: `Mapped controls to ${singularResource}`,
+ };
+ }
+
+ // DELETE /v1//:id/controls/:controlId — unmapping
+ const unmapMatch = path.match(
+ /\/v1\/(\w+)\/([^/]+)\/controls\/([^/]+)\/?$/,
+ );
+ if (unmapMatch && method === 'DELETE') {
+ const resource = unmapMatch[1];
+ const parentId = unmapMatch[2];
+ const removedControlId = unmapMatch[3];
+ const currentIds = await fetchControlIds(resource, parentId);
+
+ const allIds = [...new Set([...currentIds, removedControlId])];
+ const nameMap = await resolveControlNames(allIds);
+
+ const prevDisplay =
+ currentIds.length > 0
+ ? currentIds.map((id) => nameMap[id] || id).join(', ')
+ : 'None';
+ const afterIds = currentIds.filter((id) => id !== removedControlId);
+ const afterDisplay =
+ afterIds.length > 0
+ ? afterIds.map((id) => nameMap[id] || id).join(', ')
+ : 'None';
+
+ const singularResource = resource.replace(/s$/, '');
+
+ return {
+ changes: { controls: { previous: prevDisplay, current: afterDisplay } },
+ description: `Unmapped control from ${singularResource}`,
+ };
+ }
+
+ return null;
+}
+
+export async function fetchCurrentValues(
+ resource: string,
+ entityId: string,
+ fieldNames: string[],
+): Promise | null> {
+ const modelName = RESOURCE_TO_PRISMA_MODEL[resource];
+ if (!modelName) return null;
+
+ const model = (db as unknown as Record)[modelName];
+ if (!model?.findUnique) return null;
+
+ const select: Record = {};
+ for (const field of fieldNames) {
+ select[field] = true;
+ }
+
+ try {
+ return await model.findUnique({ where: { id: entityId }, select });
+ } catch {
+ return null;
+ }
+}
diff --git a/apps/api/src/audit/audit-log.utils.ts b/apps/api/src/audit/audit-log.utils.ts
new file mode 100644
index 0000000000..5c1856ac39
--- /dev/null
+++ b/apps/api/src/audit/audit-log.utils.ts
@@ -0,0 +1,365 @@
+import { AuditLogEntityType } from '@db';
+import { AuthenticatedRequest } from '../auth/types';
+import {
+ COMMENT_ENTITY_TYPE_MAP,
+ MEMBER_REF_FIELDS,
+ SENSITIVE_KEYS,
+} from './audit-log.constants';
+
+export type AuditContextOverride = {
+ entityType: AuditLogEntityType;
+ entityId: string;
+ description: string;
+};
+
+export type ChangesRecord = Record<
+ string,
+ { previous: unknown; current: unknown }
+>;
+
+export type RelationMappingResult = {
+ changes: ChangesRecord;
+ description: string;
+};
+
+export function extractCommentContext(
+ path: string,
+ method: string,
+ requestBody: Record | undefined,
+): AuditContextOverride | null {
+ if (!path.includes('/comments')) return null;
+
+ if (method === 'POST' && requestBody) {
+ const bodyEntityType = requestBody.entityType as string | undefined;
+ const bodyEntityId = requestBody.entityId as string | undefined;
+ if (bodyEntityType && bodyEntityId) {
+ const mappedType = COMMENT_ENTITY_TYPE_MAP[bodyEntityType];
+ if (mappedType) {
+ return {
+ entityType: mappedType,
+ entityId: bodyEntityId,
+ description: `Commented on ${bodyEntityType}`,
+ };
+ }
+ }
+ }
+
+ if (method === 'DELETE') {
+ return {
+ entityType: null as unknown as AuditLogEntityType,
+ entityId: null as unknown as string,
+ description: 'Deleted comment',
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Detects download/export GET endpoints and returns a human-readable
+ * description. Returns null for non-download endpoints.
+ */
+export function extractDownloadDescription(
+ path: string,
+ method: string,
+): string | null {
+ if (method !== 'GET') return null;
+
+ const pathWithoutQuery = path.split('?')[0];
+
+ if (/\/pdf\/signed-url\/?$/.test(pathWithoutQuery))
+ return 'Downloaded policy PDF';
+ if (/\/download-all\/?$/.test(pathWithoutQuery))
+ return 'Downloaded all policies PDF';
+ if (/\/evidence\/automation\/[^/]+\/pdf\/?$/.test(pathWithoutQuery))
+ return 'Exported automation evidence PDF';
+ if (/\/evidence\/export\/?$/.test(pathWithoutQuery))
+ return 'Exported task evidence';
+ if (/\/evidence-export\/all\/?$/.test(pathWithoutQuery))
+ return 'Exported all organization evidence';
+
+ return null;
+}
+
+/**
+ * Detects version and approval endpoints and builds a description
+ * that includes the version number from the response body.
+ */
+export function extractVersionDescription(
+ path: string,
+ method: string,
+ responseBody: unknown,
+ requestBody?: Record,
+): string | null {
+ // Only match policy version paths, not automation version paths
+ const isPolicyVersionPath = /\/policies\/[^/]+\/versions(?:\/|$)/.test(path);
+ const isApprovalPath = /\/(accept|deny)-changes\/?$/.test(path);
+
+ if (!isPolicyVersionPath && !isApprovalPath) return null;
+
+ const versionNum = extractVersionNumber(responseBody);
+ const suffix = versionNum ? ` version ${versionNum}` : '';
+
+ // POST /v1/policies/:id/accept-changes
+ if (/\/accept-changes\/?$/.test(path) && method === 'POST') {
+ return `Approved and published policy${suffix}`;
+ }
+
+ // POST /v1/policies/:id/deny-changes
+ if (/\/deny-changes\/?$/.test(path) && method === 'POST') {
+ return 'Denied policy changes';
+ }
+
+ if (/\/versions\/publish\/?$/.test(path) && method === 'POST') {
+ return `Published policy${suffix}`;
+ }
+
+ if (/\/versions\/[^/]+\/activate\/?$/.test(path) && method === 'POST') {
+ return `Activated policy${suffix}`;
+ }
+
+ if (
+ /\/versions\/[^/]+\/submit-for-approval\/?$/.test(path) &&
+ method === 'POST'
+ ) {
+ return `Submitted policy${suffix} for approval`;
+ }
+
+ if (/\/versions\/?$/.test(path) && method === 'POST') {
+ return `Created policy${suffix}`;
+ }
+
+ // PATCH /v1/policies/:id/versions/:versionId (edit content)
+ if (/\/versions\/[^/]+\/?$/.test(path) && method === 'PATCH') {
+ return `Updated policy${suffix} content`;
+ }
+
+ if (/\/versions\/[^/]+\/?$/.test(path) && method === 'DELETE') {
+ const deletedVersion = extractDeletedVersionNumber(responseBody);
+ const delSuffix = deletedVersion ? ` version ${deletedVersion}` : '';
+ return `Deleted policy${delSuffix}`;
+ }
+
+ return null;
+}
+
+function extractVersionNumber(responseBody: unknown): number | null {
+ if (!responseBody || typeof responseBody !== 'object') return null;
+ const body = responseBody as Record;
+ if (typeof body.version === 'number') return body.version;
+ if (body.data && typeof body.data === 'object') {
+ const data = body.data as Record;
+ if (typeof data.version === 'number') return data.version;
+ }
+ return null;
+}
+
+function extractDeletedVersionNumber(responseBody: unknown): number | null {
+ if (!responseBody || typeof responseBody !== 'object') return null;
+ const body = responseBody as Record;
+ if (typeof body.deletedVersion === 'number') return body.deletedVersion;
+ if (body.data && typeof body.data === 'object') {
+ const data = body.data as Record;
+ if (typeof data.deletedVersion === 'number') return data.deletedVersion;
+ }
+ return null;
+}
+
+/**
+ * Detects policy-specific actions (regenerate, archive/restore) and returns
+ * a human-readable description. Returns null for non-matching endpoints.
+ */
+export function extractPolicyActionDescription(
+ path: string,
+ method: string,
+ requestBody: Record | undefined,
+): string | null {
+ // POST /v1/policies/:id/regenerate or /v1/tasks/:id/regenerate
+ if (/\/regenerate\/?$/.test(path) && method === 'POST') {
+ return path.includes('/tasks/') ? 'Regenerated evidence' : 'Regenerated policy';
+ }
+
+ // POST /v1/tasks/:id/approve
+ if (/\/tasks\/[^/]+\/approve\/?$/.test(path) && method === 'POST') {
+ return 'Approved evidence';
+ }
+
+ // POST /v1/tasks/:id/reject
+ if (/\/tasks\/[^/]+\/reject\/?$/.test(path) && method === 'POST') {
+ return 'Rejected evidence';
+ }
+
+ // POST /v1/tasks/:id/submit-for-review
+ if (/\/submit-for-review\/?$/.test(path) && method === 'POST') {
+ return 'Submitted evidence for review';
+ }
+
+ // Attachment CRUD — /v1/tasks/:taskId/attachments[/:attachmentId]
+ if (/\/attachments(\/[^/]+)?\/?$/.test(path) && !/(download)/.test(path)) {
+ if (method === 'POST') return 'Uploaded attachment';
+ if (method === 'DELETE') return 'Deleted attachment';
+ }
+
+ // Custom automation version — /v1/tasks/:taskId/automations/:automationId/versions
+ if (/\/automations\/[^/]+\/versions\/?$/.test(path) && method === 'POST') {
+ const version = requestBody?.version;
+ const suffix = typeof version === 'number' ? ` v${version}` : '';
+ return `Published custom automation${suffix}`;
+ }
+
+ // Custom automation CRUD — /v1/tasks/:taskId/automations[/:automationId]
+ if (/\/tasks\/[^/]+\/automations(\/[^/]+)?\/?$/.test(path) && !/(runs|versions)/.test(path)) {
+ if (method === 'POST') return 'Created custom automation';
+ if (method === 'PATCH') {
+ if (requestBody && 'isEnabled' in requestBody) {
+ return requestBody.isEnabled ? 'Enabled custom automation' : 'Disabled custom automation';
+ }
+ if (requestBody && 'evaluationCriteria' in requestBody) {
+ return 'Updated automation evaluation criteria';
+ }
+ return 'Updated custom automation';
+ }
+ if (method === 'DELETE') return 'Deleted custom automation';
+ }
+
+ // Browser automation CRUD — /v1/browserbase/automations[/:automationId]
+ if (/\/browserbase\/automations(\/[^/]+)?\/?$/.test(path)) {
+ if (method === 'POST') return 'Created browser automation';
+ if (method === 'PATCH') return 'Updated browser automation';
+ if (method === 'DELETE') return 'Deleted browser automation';
+ }
+
+ const pathWithoutQuery = path.split('?')[0];
+
+ // POST /v1/policies/:id/pdf (upload)
+ if (/\/pdf\/?$/.test(pathWithoutQuery) && method === 'POST') {
+ return 'Uploaded policy PDF';
+ }
+
+ // DELETE /v1/policies/:id/pdf (delete)
+ if (/\/pdf\/?$/.test(pathWithoutQuery) && method === 'DELETE') {
+ return 'Deleted policy PDF';
+ }
+
+ // PATCH /v1/policies/:id with isArchived field
+ if (method === 'PATCH' && /\/policies\/[^/]+\/?$/.test(pathWithoutQuery) && requestBody && 'isArchived' in requestBody) {
+ return requestBody.isArchived ? 'Archived policy' : 'Restored policy';
+ }
+
+ return null;
+}
+
+/**
+ * Detects finding-specific actions and builds a description
+ * that includes the actor's role (auditor vs platform admin).
+ */
+export function extractFindingDescription(
+ path: string,
+ method: string,
+ resource: string,
+ userRoles?: string[],
+): string | null {
+ if (resource !== 'finding') return null;
+
+ const isAuditor = userRoles?.includes('auditor');
+ const actor = isAuditor ? 'Auditor' : 'Admin';
+
+ switch (method) {
+ case 'POST':
+ return `${actor} created a finding`;
+ case 'PATCH':
+ case 'PUT': {
+ return `${actor} updated a finding`;
+ }
+ case 'DELETE':
+ return `${actor} deleted a finding`;
+ default:
+ return null;
+ }
+}
+
+export function buildDescription(
+ _method: string,
+ action: string,
+ resource: string,
+): string {
+ switch (action) {
+ case 'create':
+ return `Created ${resource}`;
+ case 'read':
+ return `Viewed ${resource}`;
+ case 'update':
+ return `Updated ${resource}`;
+ case 'delete':
+ return `Deleted ${resource}`;
+ default: {
+ const capitalizedAction =
+ action.charAt(0).toUpperCase() + action.slice(1);
+ return `${capitalizedAction} ${resource}`;
+ }
+ }
+}
+
+function sanitizeValue(key: string, value: unknown): unknown {
+ if (SENSITIVE_KEYS.has(key)) return '[REDACTED]';
+ if (value instanceof Date) return value.toISOString();
+ if (value && typeof value === 'object' && !Array.isArray(value))
+ return '[Object]';
+ return value;
+}
+
+export function buildChanges(
+ body: Record,
+ previousValues: Record | null,
+ memberNames: Record,
+): ChangesRecord | null {
+ const changes: ChangesRecord = {};
+
+ for (const [key, newValue] of Object.entries(body)) {
+ const previousRaw = previousValues?.[key];
+
+ if (previousValues && String(previousRaw) === String(newValue)) continue;
+
+ const displayLabel = MEMBER_REF_FIELDS[key];
+ if (displayLabel) {
+ const prevId = previousRaw ? String(previousRaw) : null;
+ const newId = newValue ? String(newValue) : null;
+ const prevName = prevId ? memberNames[prevId] : null;
+ const newName = newId ? memberNames[newId] : null;
+ changes[displayLabel] = {
+ previous: prevName ? `${prevName} (${prevId})` : 'Unassigned',
+ current: newName ? `${newName} (${newId})` : 'Unassigned',
+ };
+ continue;
+ }
+
+ const sanitizedNew = sanitizeValue(key, newValue);
+ const sanitizedPrev = previousValues
+ ? sanitizeValue(key, previousRaw)
+ : null;
+ changes[key] = { previous: sanitizedPrev, current: sanitizedNew };
+ }
+
+ return Object.keys(changes).length > 0 ? changes : null;
+}
+
+export function extractEntityId(
+ request: AuthenticatedRequest,
+ method: string,
+ responseBody: unknown,
+): string | null {
+ const params = (request as any).params;
+ const paramId = params?.id || params?.taskId;
+ if (paramId) return paramId;
+
+ if (method === 'POST' && responseBody && typeof responseBody === 'object') {
+ const body = responseBody as Record;
+ if (typeof body.id === 'string') return body.id;
+ if (body.data && typeof body.data === 'object') {
+ const data = body.data as Record;
+ if (typeof data.id === 'string') return data.id;
+ }
+ }
+
+ return null;
+}
diff --git a/apps/api/src/audit/audit.module.ts b/apps/api/src/audit/audit.module.ts
new file mode 100644
index 0000000000..a3ff349976
--- /dev/null
+++ b/apps/api/src/audit/audit.module.ts
@@ -0,0 +1,17 @@
+import { Module } from '@nestjs/common';
+import { APP_INTERCEPTOR } from '@nestjs/core';
+import { AuthModule } from '../auth/auth.module';
+import { AuditLogController } from './audit-log.controller';
+import { AuditLogInterceptor } from './audit-log.interceptor';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [AuditLogController],
+ providers: [
+ {
+ provide: APP_INTERCEPTOR,
+ useClass: AuditLogInterceptor,
+ },
+ ],
+})
+export class AuditModule {}
diff --git a/apps/api/src/audit/skip-audit-log.decorator.ts b/apps/api/src/audit/skip-audit-log.decorator.ts
new file mode 100644
index 0000000000..fe7871817f
--- /dev/null
+++ b/apps/api/src/audit/skip-audit-log.decorator.ts
@@ -0,0 +1,20 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const SKIP_AUDIT_LOG_KEY = 'skipAuditLog';
+
+/**
+ * Decorator to skip automatic audit logging for a specific route.
+ * Use this on routes that already have manual audit logging via
+ * dedicated audit services (e.g., FindingAuditService).
+ */
+export const SkipAuditLog = () => SetMetadata(SKIP_AUDIT_LOG_KEY, true);
+
+export const AUDIT_READ_KEY = 'auditRead';
+
+/**
+ * Opt a GET endpoint into audit logging.
+ * By default, only mutations (POST/PATCH/PUT/DELETE) are logged.
+ * Apply this to read endpoints that should be tracked for compliance
+ * (e.g., PDF downloads, data exports).
+ */
+export const AuditRead = () => SetMetadata(AUDIT_READ_KEY, true);
diff --git a/apps/api/src/auth/api-key.guard.ts b/apps/api/src/auth/api-key.guard.ts
index fc5a8b879e..4675919e7d 100644
--- a/apps/api/src/auth/api-key.guard.ts
+++ b/apps/api/src/auth/api-key.guard.ts
@@ -24,14 +24,15 @@ export class ApiKeyGuard implements CanActivate {
}
// Validate the API key
- const organizationId = await this.apiKeyService.validateApiKey(apiKey);
+ const result = await this.apiKeyService.validateApiKey(apiKey);
- if (!organizationId) {
+ if (!result) {
throw new UnauthorizedException('Invalid or expired API key');
}
- // Attach the organization ID to the request for use in controllers
- (request as any).organizationId = organizationId;
+ // Attach the organization ID and scopes to the request for use in controllers
+ (request as any).organizationId = result.organizationId;
+ (request as any).apiKeyScopes = result.scopes;
return true;
}
diff --git a/apps/api/src/auth/api-key.service.spec.ts b/apps/api/src/auth/api-key.service.spec.ts
new file mode 100644
index 0000000000..bd5b580183
--- /dev/null
+++ b/apps/api/src/auth/api-key.service.spec.ts
@@ -0,0 +1,79 @@
+jest.mock('@comp/auth', () => ({
+ statement: {
+ organization: ['read', 'update', 'delete'],
+ member: ['create', 'read', 'update', 'delete'],
+ invitation: ['create', 'read', 'delete'],
+ team: ['create', 'read', 'update', 'delete'],
+ control: ['create', 'read', 'update', 'delete'],
+ evidence: ['create', 'read', 'update', 'delete'],
+ policy: ['create', 'read', 'update', 'delete'],
+ risk: ['create', 'read', 'update', 'delete'],
+ vendor: ['create', 'read', 'update', 'delete'],
+ task: ['create', 'read', 'update', 'delete'],
+ framework: ['create', 'read', 'update', 'delete'],
+ audit: ['create', 'read', 'update'],
+ finding: ['create', 'read', 'update', 'delete'],
+ questionnaire: ['create', 'read', 'update', 'delete'],
+ integration: ['create', 'read', 'update', 'delete'],
+ apiKey: ['create', 'read', 'delete'],
+ app: ['read'],
+ trust: ['read', 'update'],
+ pentest: ['create', 'read', 'delete'],
+ training: ['read', 'update'],
+ },
+}));
+
+import { ApiKeyService } from './api-key.service';
+
+describe('ApiKeyService', () => {
+ let service: ApiKeyService;
+
+ beforeEach(() => {
+ service = new ApiKeyService();
+ });
+
+ describe('getAvailableScopes', () => {
+ let scopes: string[];
+
+ beforeEach(() => {
+ scopes = service.getAvailableScopes();
+ });
+
+ it('should not include any invitation:* scopes', () => {
+ const matches = scopes.filter((s) => s.startsWith('invitation:'));
+ expect(matches).toEqual([]);
+ });
+
+ it('should not include any team:* scopes', () => {
+ const matches = scopes.filter((s) => s.startsWith('team:'));
+ expect(matches).toEqual([]);
+ });
+
+ it('should not include any compliance:* scopes', () => {
+ const matches = scopes.filter((s) => s.startsWith('compliance:'));
+ expect(matches).toEqual([]);
+ });
+
+ it('should include expected public resources', () => {
+ const expected = [
+ 'risk', 'vendor', 'task', 'control', 'policy',
+ 'evidence', 'framework', 'audit', 'finding',
+ 'questionnaire', 'integration', 'apiKey', 'pentest',
+ ];
+ for (const resource of expected) {
+ const matching = scopes.filter((s) => s.startsWith(`${resource}:`));
+ expect(matching.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should return scopes in resource:action format', () => {
+ for (const scope of scopes) {
+ expect(scope).toMatch(/^[a-zA-Z]+:[a-zA-Z]+$/);
+ }
+ });
+
+ it('should not return an empty array', () => {
+ expect(scopes.length).toBeGreaterThan(0);
+ });
+ });
+});
diff --git a/apps/api/src/auth/api-key.service.ts b/apps/api/src/auth/api-key.service.ts
index e776d5a096..9785ba33e0 100644
--- a/apps/api/src/auth/api-key.service.ts
+++ b/apps/api/src/auth/api-key.service.ts
@@ -1,6 +1,18 @@
-import { Injectable, Logger } from '@nestjs/common';
+import {
+ Injectable,
+ Logger,
+ NotFoundException,
+ BadRequestException,
+} from '@nestjs/common';
import { db } from '@trycompai/db';
-import { createHash } from 'node:crypto';
+import { statement } from '@comp/auth';
+import { createHash, randomBytes } from 'node:crypto';
+
+/** Result from validating an API key */
+export interface ApiKeyValidationResult {
+ organizationId: string;
+ scopes: string[];
+}
@Injectable()
export class ApiKeyService {
@@ -23,6 +35,112 @@ export class ApiKeyService {
return createHash('sha256').update(apiKey).digest('hex');
}
+ private generateApiKey(): string {
+ const apiKey = randomBytes(32).toString('hex');
+ return `comp_${apiKey}`;
+ }
+
+ /** Extract the first 8 chars after the `comp_` prefix for indexed lookup */
+ private extractPrefix(apiKey: string): string {
+ return apiKey.slice(5, 13);
+ }
+
+ private generateSalt(): string {
+ return randomBytes(16).toString('hex');
+ }
+
+ async create(
+ organizationId: string,
+ name: string,
+ expiresAt?: string,
+ scopes?: string[],
+ ) {
+ // Validate scopes if provided
+ const validatedScopes = scopes?.length ? scopes : [];
+ if (validatedScopes.length > 0) {
+ const availableScopes = this.getAvailableScopes();
+ const invalid = validatedScopes.filter((s) => !availableScopes.includes(s));
+ if (invalid.length > 0) {
+ throw new BadRequestException(
+ `Invalid scopes: ${invalid.join(', ')}`,
+ );
+ }
+ }
+
+ const apiKey = this.generateApiKey();
+ const salt = this.generateSalt();
+ const hashedKey = this.hashApiKey(apiKey, salt);
+
+ let expirationDate: Date | null = null;
+ if (expiresAt && expiresAt !== 'never') {
+ const now = new Date();
+ switch (expiresAt) {
+ case '30days':
+ expirationDate = new Date(now.setDate(now.getDate() + 30));
+ break;
+ case '90days':
+ expirationDate = new Date(now.setDate(now.getDate() + 90));
+ break;
+ case '1year':
+ expirationDate = new Date(
+ now.setFullYear(now.getFullYear() + 1),
+ );
+ break;
+ default:
+ throw new BadRequestException(
+ `Invalid expiresAt value: ${expiresAt}. Must be "never", "30days", "90days", or "1year".`,
+ );
+ }
+ }
+
+ const keyPrefix = this.extractPrefix(apiKey);
+
+ const record = await db.apiKey.create({
+ data: {
+ name,
+ key: hashedKey,
+ keyPrefix,
+ salt,
+ expiresAt: expirationDate,
+ organizationId,
+ scopes: validatedScopes,
+ },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ expiresAt: true,
+ },
+ });
+
+ return {
+ ...record,
+ key: apiKey,
+ createdAt: record.createdAt.toISOString(),
+ expiresAt: record.expiresAt ? record.expiresAt.toISOString() : null,
+ };
+ }
+
+ async revoke(apiKeyId: string, organizationId: string) {
+ const result = await db.apiKey.updateMany({
+ where: {
+ id: apiKeyId,
+ organizationId,
+ },
+ data: {
+ isActive: false,
+ },
+ });
+
+ if (result.count === 0) {
+ throw new NotFoundException(
+ 'API key not found or not authorized to revoke',
+ );
+ }
+
+ return { success: true };
+ }
+
/**
* Extract API key from request headers
* @param apiKeyHeader X-API-Key header value
@@ -38,11 +156,11 @@ export class ApiKeyService {
}
/**
- * Validate an API key and return the organization ID
+ * Validate an API key and return the organization ID + scopes
* @param apiKey The API key to validate
- * @returns The organization ID if the API key is valid, null otherwise
+ * @returns The validation result if valid, null otherwise
*/
- async validateApiKey(apiKey: string): Promise {
+ async validateApiKey(apiKey: string): Promise {
if (!apiKey) {
return null;
}
@@ -56,10 +174,18 @@ export class ApiKeyService {
return null;
}
- // Look up the API key in the database
+ // Use key prefix for indexed lookup when available (new keys),
+ // fall back to full scan for legacy keys without prefix
+ const keyPrefix = apiKey.startsWith('comp_') ? this.extractPrefix(apiKey) : null;
+
const apiKeyRecords = await db.apiKey.findMany({
where: {
isActive: true,
+ OR: [
+ { expiresAt: null },
+ { expiresAt: { gt: new Date() } },
+ ],
+ ...(keyPrefix ? { keyPrefix } : {}),
},
select: {
id: true,
@@ -67,24 +193,57 @@ export class ApiKeyService {
salt: true,
organizationId: true,
expiresAt: true,
+ scopes: true,
},
});
- // Find the matching API key by hashing with each record's salt
+ // Find the matching API key by hashing with each candidate's salt
const matchingRecord = apiKeyRecords.find((record) => {
- // Hash the provided API key with the record's salt
const hashedKey = record.salt
? this.hashApiKey(apiKey, record.salt)
- : this.hashApiKey(apiKey); // For backward compatibility
-
+ : this.hashApiKey(apiKey);
return hashedKey === record.key;
});
- // If no matching key or the key is expired, return null
- if (
- !matchingRecord ||
- (matchingRecord.expiresAt && matchingRecord.expiresAt < new Date())
- ) {
+ if (!matchingRecord) {
+ // If prefix lookup found nothing, try legacy keys (no prefix set)
+ if (keyPrefix) {
+ const legacyRecords = await db.apiKey.findMany({
+ where: {
+ isActive: true,
+ keyPrefix: null,
+ OR: [
+ { expiresAt: null },
+ { expiresAt: { gt: new Date() } },
+ ],
+ },
+ select: {
+ id: true,
+ key: true,
+ salt: true,
+ organizationId: true,
+ expiresAt: true,
+ scopes: true,
+ },
+ });
+ const legacyMatch = legacyRecords.find((record) => {
+ const hashedKey = record.salt
+ ? this.hashApiKey(apiKey, record.salt)
+ : this.hashApiKey(apiKey);
+ return hashedKey === record.key;
+ });
+ if (legacyMatch) {
+ // Backfill the prefix for future lookups
+ await db.apiKey.update({
+ where: { id: legacyMatch.id },
+ data: { keyPrefix, lastUsedAt: new Date() },
+ });
+ return {
+ organizationId: legacyMatch.organizationId,
+ scopes: legacyMatch.scopes,
+ };
+ }
+ }
this.logger.warn('Invalid or expired API key attempted');
return null;
}
@@ -103,11 +262,39 @@ export class ApiKeyService {
`Valid API key used for organization: ${matchingRecord.organizationId}`,
);
- // Return the organization ID
- return matchingRecord.organizationId;
+ return {
+ organizationId: matchingRecord.organizationId,
+ scopes: matchingRecord.scopes,
+ };
} catch (error) {
this.logger.error('Error validating API key:', error);
return null;
}
}
+
+ /**
+ * Resources from better-auth that are not used by any API endpoint's @RequirePermission.
+ * These are handled internally by better-auth for session-based auth only.
+ */
+ private static readonly INTERNAL_ONLY_RESOURCES = [
+ 'invitation',
+ 'team',
+ ];
+
+ /**
+ * Returns all valid `resource:action` scope pairs derived from the permission statement.
+ * Excludes internal-only resources that no API endpoint uses via @RequirePermission.
+ */
+ getAvailableScopes(): string[] {
+ const scopes: string[] = [];
+ for (const [resource, actions] of Object.entries(statement)) {
+ if (ApiKeyService.INTERNAL_ONLY_RESOURCES.includes(resource)) {
+ continue;
+ }
+ for (const action of actions) {
+ scopes.push(`${resource}:${action}`);
+ }
+ }
+ return scopes;
+ }
}
diff --git a/apps/api/src/auth/auth-context.decorator.ts b/apps/api/src/auth/auth-context.decorator.ts
index 294b4d5f8f..a041e0d992 100644
--- a/apps/api/src/auth/auth-context.decorator.ts
+++ b/apps/api/src/auth/auth-context.decorator.ts
@@ -9,10 +9,21 @@ export const AuthContext = createParamDecorator(
(data: unknown, ctx: ExecutionContext): AuthContextType => {
const request = ctx.switchToHttp().getRequest();
- const { organizationId, authType, isApiKey, userId, userEmail, userRoles } =
- request;
+ const {
+ organizationId,
+ authType,
+ isApiKey,
+ isServiceToken,
+ serviceName,
+ isPlatformAdmin,
+ userId,
+ userEmail,
+ userRoles,
+ memberId,
+ memberDepartment,
+ } = request;
- if (!organizationId || !authType) {
+ if (organizationId === undefined || !authType) {
throw new Error(
'Authentication context not found. Ensure HybridAuthGuard is applied.',
);
@@ -22,9 +33,14 @@ export const AuthContext = createParamDecorator(
organizationId,
authType,
isApiKey,
+ isServiceToken,
+ serviceName,
+ isPlatformAdmin,
userId,
userEmail,
userRoles,
+ memberId,
+ memberDepartment,
};
},
);
@@ -69,6 +85,16 @@ export const UserId = createParamDecorator(
},
);
+/**
+ * Parameter decorator to extract the member ID (only available for session auth)
+ */
+export const MemberId = createParamDecorator(
+ (data: unknown, ctx: ExecutionContext): string | undefined => {
+ const request = ctx.switchToHttp().getRequest();
+ return request.memberId;
+ },
+);
+
/**
* Parameter decorator to check if the request is authenticated via API key
*/
diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts
new file mode 100644
index 0000000000..6ebe3bc52a
--- /dev/null
+++ b/apps/api/src/auth/auth.controller.ts
@@ -0,0 +1,119 @@
+import {
+ BadRequestException,
+ Controller,
+ Delete,
+ ForbiddenException,
+ Get,
+ NotFoundException,
+ Param,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiOperation, ApiParam, ApiSecurity, ApiTags } from '@nestjs/swagger';
+import { db } from '@trycompai/db';
+import { OrganizationId } from './auth-context.decorator';
+import { PermissionGuard } from './permission.guard';
+import { RequirePermission } from './require-permission.decorator';
+import { AuthContext } from './auth-context.decorator';
+import { HybridAuthGuard } from './hybrid-auth.guard';
+import { SkipOrgCheck } from './skip-org-check.decorator';
+import type { AuthContext as AuthContextType } from './types';
+
+@ApiTags('Auth')
+@Controller({ path: 'auth', version: '1' })
+@UseGuards(HybridAuthGuard)
+@ApiSecurity('apikey')
+export class AuthController {
+ @Get('me')
+ @SkipOrgCheck()
+ @ApiOperation({ summary: 'Get current user info, organizations, and pending invitations' })
+ async getMe(@AuthContext() authContext: AuthContextType) {
+ const userId = authContext.userId;
+ if (!userId) {
+ return { user: null, organizations: [], pendingInvitation: null };
+ }
+
+ const [user, memberships, pendingInvitation] = await Promise.all([
+ db.user.findUnique({
+ where: { id: userId },
+ select: {
+ id: true,
+ email: true,
+ name: true,
+ image: true,
+ isPlatformAdmin: true,
+ },
+ }),
+ db.member.findMany({
+ where: { userId, isActive: true, deactivated: false },
+ select: {
+ id: true,
+ role: true,
+ organizationId: true,
+ organization: {
+ select: {
+ id: true,
+ name: true,
+ logo: true,
+ onboardingCompleted: true,
+ hasAccess: true,
+ createdAt: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ }),
+ db.invitation.findFirst({
+ where: {
+ email: authContext.userEmail ?? '',
+ status: 'pending',
+ },
+ select: { id: true },
+ }),
+ ]);
+
+ return {
+ user,
+ organizations: memberships.map((m) => ({
+ ...m.organization,
+ memberRole: m.role,
+ memberId: m.id,
+ })),
+ pendingInvitation,
+ };
+ }
+
+ @Get('invitations')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('member', 'read')
+ @ApiOperation({ summary: 'List pending invitations for the organization' })
+ async listInvitations(@OrganizationId() organizationId: string) {
+ const invitations = await db.invitation.findMany({
+ where: { organizationId, status: 'pending' },
+ orderBy: { email: 'asc' },
+ });
+
+ return { data: invitations };
+ }
+
+ @Delete('invitations/:id')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('member', 'delete')
+ @ApiOperation({ summary: 'Revoke a pending invitation' })
+ @ApiParam({ name: 'id', description: 'Invitation ID' })
+ async deleteInvitation(
+ @Param('id') invitationId: string,
+ @OrganizationId() organizationId: string,
+ ) {
+ const invitation = await db.invitation.findFirst({
+ where: { id: invitationId, organizationId, status: 'pending' },
+ });
+
+ if (!invitation) {
+ throw new NotFoundException('Invitation not found or already accepted.');
+ }
+
+ await db.invitation.delete({ where: { id: invitationId } });
+
+ return { success: true };
+ }
+}
diff --git a/apps/api/src/auth/auth.module.ts b/apps/api/src/auth/auth.module.ts
index c687cadf47..4128f9f15d 100644
--- a/apps/api/src/auth/auth.module.ts
+++ b/apps/api/src/auth/auth.module.ts
@@ -1,11 +1,34 @@
import { Module } from '@nestjs/common';
+import { AuthModule as BetterAuthModule } from '@thallesp/nestjs-better-auth';
+import { auth } from './auth.server';
import { ApiKeyGuard } from './api-key.guard';
import { ApiKeyService } from './api-key.service';
+import { AuthController } from './auth.controller';
import { HybridAuthGuard } from './hybrid-auth.guard';
-import { InternalTokenGuard } from './internal-token.guard';
+import { PermissionGuard } from './permission.guard';
@Module({
- providers: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
- exports: [ApiKeyService, ApiKeyGuard, HybridAuthGuard, InternalTokenGuard],
+ imports: [
+ // Better Auth NestJS integration - handles /api/auth/* routes
+ BetterAuthModule.forRoot({
+ auth,
+ // Don't register global auth guard - we use HybridAuthGuard
+ disableGlobalAuthGuard: true,
+ }),
+ ],
+ controllers: [AuthController],
+ providers: [
+ ApiKeyService,
+ ApiKeyGuard,
+ HybridAuthGuard,
+ PermissionGuard,
+ ],
+ exports: [
+ ApiKeyService,
+ ApiKeyGuard,
+ HybridAuthGuard,
+ PermissionGuard,
+ BetterAuthModule,
+ ],
})
export class AuthModule {}
diff --git a/apps/api/src/auth/auth.server.ts b/apps/api/src/auth/auth.server.ts
new file mode 100644
index 0000000000..b45706ae97
--- /dev/null
+++ b/apps/api/src/auth/auth.server.ts
@@ -0,0 +1,331 @@
+import { MagicLinkEmail, OTPVerificationEmail } from '@trycompai/email';
+import { triggerEmail } from '../email/trigger-email';
+import { InviteEmail } from '../email/templates/invite-member';
+import { db } from '@trycompai/db';
+import { betterAuth } from 'better-auth';
+import { prismaAdapter } from 'better-auth/adapters/prisma';
+import {
+ emailOTP,
+ magicLink,
+ multiSession,
+ organization,
+} from 'better-auth/plugins';
+import { ac, allRoles } from '@comp/auth';
+
+const MAGIC_LINK_EXPIRES_IN_SECONDS = 60 * 60; // 1 hour
+
+/**
+ * Determine the cookie domain based on environment.
+ */
+function getCookieDomain(): string | undefined {
+ const baseUrl =
+ process.env.AUTH_BASE_URL || process.env.BETTER_AUTH_URL || '';
+
+ if (baseUrl.includes('staging.trycomp.ai')) {
+ return '.staging.trycomp.ai';
+ }
+ if (baseUrl.includes('trycomp.ai')) {
+ return '.trycomp.ai';
+ }
+ return undefined;
+}
+
+/**
+ * Get trusted origins for CORS/auth
+ */
+function getTrustedOrigins(): string[] {
+ const origins = process.env.AUTH_TRUSTED_ORIGINS;
+ if (origins) {
+ return origins.split(',').map((o) => o.trim());
+ }
+
+ return [
+ 'http://localhost:3000',
+ 'http://localhost:3002',
+ 'http://localhost:3333',
+ 'https://app.trycomp.ai',
+ 'https://portal.trycomp.ai',
+ 'https://api.trycomp.ai',
+ 'https://app.staging.trycomp.ai',
+ 'https://portal.staging.trycomp.ai',
+ 'https://api.staging.trycomp.ai',
+ 'https://dev.trycomp.ai',
+ ];
+}
+
+// Build social providers config
+const socialProviders: Record = {};
+
+if (process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_SECRET) {
+ socialProviders.google = {
+ clientId: process.env.AUTH_GOOGLE_ID,
+ clientSecret: process.env.AUTH_GOOGLE_SECRET,
+ };
+}
+
+if (process.env.AUTH_GITHUB_ID && process.env.AUTH_GITHUB_SECRET) {
+ socialProviders.github = {
+ clientId: process.env.AUTH_GITHUB_ID,
+ clientSecret: process.env.AUTH_GITHUB_SECRET,
+ };
+}
+
+if (
+ process.env.AUTH_MICROSOFT_CLIENT_ID &&
+ process.env.AUTH_MICROSOFT_CLIENT_SECRET
+) {
+ socialProviders.microsoft = {
+ clientId: process.env.AUTH_MICROSOFT_CLIENT_ID,
+ clientSecret: process.env.AUTH_MICROSOFT_CLIENT_SECRET,
+ tenantId: 'common',
+ prompt: 'select_account',
+ };
+}
+
+const cookieDomain = getCookieDomain();
+
+// =============================================================================
+// Security Validation
+// =============================================================================
+
+/**
+ * Validate required environment variables at startup.
+ * Throws an error if critical security configuration is missing.
+ */
+function validateSecurityConfig(): void {
+ if (!process.env.SECRET_KEY) {
+ throw new Error(
+ 'SECURITY ERROR: SECRET_KEY environment variable is required. ' +
+ 'Generate a secure secret with: openssl rand -base64 32',
+ );
+ }
+
+ if (process.env.SECRET_KEY.length < 16) {
+ throw new Error(
+ 'SECURITY ERROR: SECRET_KEY must be at least 16 characters long for security.',
+ );
+ }
+
+ // Warn about development defaults in production
+ if (process.env.NODE_ENV === 'production') {
+ const baseUrl =
+ process.env.AUTH_BASE_URL || process.env.BETTER_AUTH_URL || '';
+ if (baseUrl.includes('localhost')) {
+ console.warn(
+ 'SECURITY WARNING: AUTH_BASE_URL contains "localhost" in production. ' +
+ 'This may cause issues with OAuth callbacks and cookies.',
+ );
+ }
+ }
+}
+
+// Run validation at module load time
+validateSecurityConfig();
+
+/**
+ * The auth server instance - single source of truth for authentication.
+ *
+ * IMPORTANT: For OAuth to work correctly with the app's auth proxy:
+ * - Set AUTH_BASE_URL to the app's URL (e.g., http://localhost:3000 in dev)
+ * - This ensures OAuth callbacks point to the app, which proxies to this API
+ * - Cookies will be set for the app's domain, not the API's domain
+ *
+ * In production, use the app's public URL (e.g., https://app.trycomp.ai)
+ */
+export const auth = betterAuth({
+ database: prismaAdapter(db, {
+ provider: 'postgresql',
+ }),
+ // Use AUTH_BASE_URL pointing to the app (client), not the API itself
+ // This is critical for OAuth callbacks and cookie domains to work correctly
+ baseURL:
+ process.env.AUTH_BASE_URL ||
+ process.env.BETTER_AUTH_URL ||
+ 'http://localhost:3000',
+ trustedOrigins: getTrustedOrigins(),
+ emailAndPassword: {
+ enabled: true,
+ },
+ advanced: {
+ database: {
+ generateId: false,
+ },
+ ...(cookieDomain && {
+ crossSubDomainCookies: {
+ enabled: true,
+ domain: cookieDomain,
+ },
+ defaultCookieAttributes: {
+ sameSite: 'lax' as const,
+ secure: true,
+ },
+ }),
+ },
+ databaseHooks: {
+ session: {
+ create: {
+ before: async (session) => {
+ const isDev = process.env.NODE_ENV === 'development';
+ if (isDev) {
+ console.log(
+ '[Better Auth] Session creation hook called for user:',
+ session.userId,
+ );
+ }
+ try {
+ const userOrganization = await db.organization.findFirst({
+ where: {
+ members: {
+ some: {
+ userId: session.userId,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ select: {
+ id: true,
+ name: true,
+ },
+ });
+
+ if (userOrganization) {
+ if (isDev) {
+ console.log(
+ `[Better Auth] Setting activeOrganizationId to ${userOrganization.id} (${userOrganization.name}) for user ${session.userId}`,
+ );
+ }
+ return {
+ data: {
+ ...session,
+ activeOrganizationId: userOrganization.id,
+ },
+ };
+ } else {
+ if (isDev) {
+ console.log(
+ `[Better Auth] No organization found for user ${session.userId}`,
+ );
+ }
+ return {
+ data: session,
+ };
+ }
+ } catch (error) {
+ // Always log errors, even in production
+ console.error('[Better Auth] Session creation hook error:', error);
+ return {
+ data: session,
+ };
+ }
+ },
+ },
+ },
+ },
+ // SECRET_KEY is validated at startup via validateSecurityConfig()
+ secret: process.env.SECRET_KEY as string,
+ plugins: [
+ organization({
+ membershipLimit: 100000000000,
+ async sendInvitationEmail(data) {
+ if (process.env.NODE_ENV === 'development') {
+ console.log('[Auth] Sending invitation to:', data.email);
+ }
+ const appUrl =
+ process.env.NEXT_PUBLIC_APP_URL ??
+ process.env.BETTER_AUTH_URL ??
+ 'https://app.trycomp.ai';
+ const inviteLink = `${appUrl}/invite/${data.invitation.id}`;
+ await triggerEmail({
+ to: data.email,
+ subject: `You've been invited to join ${data.organization.name} on Comp AI`,
+ react: InviteEmail({
+ organizationName: data.organization.name,
+ inviteLink,
+ email: data.email,
+ }),
+ });
+ },
+ ac,
+ roles: allRoles,
+ // Enable dynamic access control for custom roles
+ // This allows organizations to create custom roles at runtime
+ // Roles are stored in better-auth's internal tables
+ dynamicAccessControl: {
+ enabled: true,
+ // Limit custom roles per organization to prevent abuse
+ maximumRolesPerOrganization: 100,
+ },
+ schema: {
+ organization: {
+ modelName: 'Organization',
+ },
+ // Custom roles table for dynamic access control
+ organizationRole: {
+ modelName: 'OrganizationRole',
+ fields: {
+ role: 'name',
+ permission: 'permissions',
+ },
+ },
+ },
+ }),
+ magicLink({
+ expiresIn: MAGIC_LINK_EXPIRES_IN_SECONDS,
+ sendMagicLink: async ({ email, url }) => {
+ if (process.env.NODE_ENV === 'development') {
+ console.log('[Auth] Sending magic link to:', email);
+ }
+ await triggerEmail({
+ to: email,
+ subject: 'Login to Comp AI',
+ react: MagicLinkEmail({ email, url }),
+ });
+ },
+ }),
+ emailOTP({
+ otpLength: 6,
+ expiresIn: 10 * 60,
+ async sendVerificationOTP({ email, otp }) {
+ if (process.env.NODE_ENV === 'development') {
+ console.log('[Auth] Sending OTP to:', email);
+ }
+ await triggerEmail({
+ to: email,
+ subject: 'One-Time Password for Comp AI',
+ react: OTPVerificationEmail({ email, otp }),
+ });
+ },
+ }),
+ multiSession(),
+ ],
+ socialProviders,
+ user: {
+ modelName: 'User',
+ },
+ organization: {
+ modelName: 'Organization',
+ },
+ member: {
+ modelName: 'Member',
+ },
+ invitation: {
+ modelName: 'Invitation',
+ },
+ session: {
+ modelName: 'Session',
+ },
+ account: {
+ modelName: 'Account',
+ accountLinking: {
+ enabled: true,
+ trustedProviders: ['google', 'github', 'microsoft'],
+ },
+ },
+ verification: {
+ modelName: 'Verification',
+ },
+});
+
+export type Auth = typeof auth;
diff --git a/apps/api/src/auth/hybrid-auth.guard.ts b/apps/api/src/auth/hybrid-auth.guard.ts
index 11655a0702..168f1032fb 100644
--- a/apps/api/src/auth/hybrid-auth.guard.ts
+++ b/apps/api/src/auth/hybrid-auth.guard.ts
@@ -2,36 +2,33 @@ import {
CanActivate,
ExecutionContext,
Injectable,
+ Logger,
UnauthorizedException,
} from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
+import { Reflector } from '@nestjs/core';
import { db } from '@trycompai/db';
-import { createRemoteJWKSet, jwtVerify } from 'jose';
import { ApiKeyService } from './api-key.service';
-import type { BetterAuthConfig } from '../config/better-auth.config';
+import { auth } from './auth.server';
+import { IS_PUBLIC_KEY } from './public.decorator';
+import { SKIP_ORG_CHECK_KEY } from './skip-org-check.decorator';
+import { resolveServiceByToken } from './service-token.config';
import { AuthenticatedRequest } from './types';
@Injectable()
export class HybridAuthGuard implements CanActivate {
- private readonly betterAuthUrl: string;
+ private readonly logger = new Logger(HybridAuthGuard.name);
constructor(
private readonly apiKeyService: ApiKeyService,
- private readonly configService: ConfigService,
- ) {
- const betterAuthConfig =
- this.configService.get('betterAuth');
- this.betterAuthUrl =
- betterAuthConfig?.url || process.env.BETTER_AUTH_URL || '';
-
- if (!this.betterAuthUrl) {
- console.warn(
- '[HybridAuthGuard] BETTER_AUTH_URL not configured. JWT authentication will fail.',
- );
- }
- }
+ private readonly reflector: Reflector,
+ ) {}
async canActivate(context: ExecutionContext): Promise {
+ const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ if (isPublic) return true;
const request = context.switchToHttp().getRequest();
// Try API Key authentication first (for external customers)
@@ -40,15 +37,18 @@ export class HybridAuthGuard implements CanActivate {
return this.handleApiKeyAuth(request, apiKey);
}
- // Try Bearer JWT token authentication (for internal frontend)
- const authHeader = request.headers['authorization'] as string;
- if (authHeader?.startsWith('Bearer ')) {
- return this.handleJwtAuth(request, authHeader);
+ // Try Service Token authentication (for internal services)
+ const serviceToken = request.headers['x-service-token'] as string;
+ if (serviceToken) {
+ return this.handleServiceTokenAuth(request, serviceToken);
}
- throw new UnauthorizedException(
- 'Authentication required: Provide either X-API-Key or Bearer JWT token',
- );
+ // Try session-based authentication (bearer token or cookies)
+ const skipOrgCheck = this.reflector.getAllAndOverride(SKIP_ORG_CHECK_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+ return this.handleSessionAuth(request, skipOrgCheck);
}
private async handleApiKeyAuth(
@@ -60,207 +60,163 @@ export class HybridAuthGuard implements CanActivate {
throw new UnauthorizedException('Invalid API key format');
}
- const organizationId =
- await this.apiKeyService.validateApiKey(extractedKey);
- if (!organizationId) {
+ const result = await this.apiKeyService.validateApiKey(extractedKey);
+ if (!result) {
throw new UnauthorizedException('Invalid or expired API key');
}
// Set request context for API key auth
- request.organizationId = organizationId;
+ request.organizationId = result.organizationId;
request.authType = 'api-key';
request.isApiKey = true;
+ request.isServiceToken = false;
+ request.isPlatformAdmin = false;
+ request.apiKeyScopes = result.scopes;
// API keys are organization-scoped and are not tied to a specific user/member.
request.userRoles = null;
return true;
}
- private async handleJwtAuth(
+ private async handleServiceTokenAuth(
request: AuthenticatedRequest,
- authHeader: string,
+ token: string,
): Promise {
- try {
- // Validate BETTER_AUTH_URL is configured
- if (!this.betterAuthUrl) {
- console.error(
- '[HybridAuthGuard] BETTER_AUTH_URL environment variable is not set',
- );
- throw new UnauthorizedException(
- 'Authentication configuration error: BETTER_AUTH_URL not configured',
- );
- }
-
- // Extract token from "Bearer "
- const token = authHeader.substring(7);
+ const service = resolveServiceByToken(token);
+ if (!service) {
+ throw new UnauthorizedException('Invalid service token');
+ }
- const jwksUrl = `${this.betterAuthUrl}/api/auth/jwks`;
+ const organizationId = request.headers['x-organization-id'] as string;
+ if (!organizationId) {
+ throw new UnauthorizedException(
+ 'x-organization-id header is required for service token auth',
+ );
+ }
- // Create JWKS for token verification using Better Auth endpoint
- // Use shorter cache time to handle key rotation better
- const JWKS = createRemoteJWKSet(new URL(jwksUrl), {
- cacheMaxAge: 60000, // 1 minute cache (default is 5 minutes)
- cooldownDuration: 10000, // 10 seconds cooldown before refetching
- });
+ const org = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { id: true },
+ });
+ if (!org) {
+ throw new UnauthorizedException(
+ 'Organization not found for the provided x-organization-id',
+ );
+ }
- // Verify JWT token with automatic retry on key mismatch
- let payload;
- try {
- payload = (
- await jwtVerify(token, JWKS, {
- issuer: this.betterAuthUrl,
- audience: this.betterAuthUrl,
- })
- ).payload;
- } catch (verifyError: any) {
- // If we get a key mismatch error, retry with a fresh JWKS fetch
- if (
- verifyError.code === 'ERR_JWKS_NO_MATCHING_KEY' ||
- verifyError.message?.includes('no applicable key found') ||
- verifyError.message?.includes('JWKSNoMatchingKey')
- ) {
- console.log(
- '[HybridAuthGuard] Key mismatch detected, fetching fresh JWKS and retrying...',
- );
+ request.organizationId = organizationId;
+ request.authType = 'service';
+ request.isApiKey = false;
+ request.isServiceToken = true;
+ request.serviceName = service.definition.name;
+ request.isPlatformAdmin = false;
+ request.userRoles = null;
- // Create a fresh JWKS instance with no cache to force immediate fetch
- const freshJWKS = createRemoteJWKSet(new URL(jwksUrl), {
- cacheMaxAge: 0, // No cache - force fresh fetch
- cooldownDuration: 0, // No cooldown - allow immediate retry
- });
+ this.logger.log(
+ `Service "${service.definition.name}" authenticated for org ${organizationId}`,
+ );
- // Retry verification with fresh keys
- payload = (
- await jwtVerify(token, freshJWKS, {
- issuer: this.betterAuthUrl,
- audience: this.betterAuthUrl,
- })
- ).payload;
+ return true;
+ }
- console.log(
- '[HybridAuthGuard] Successfully verified token with fresh JWKS',
- );
- } else {
- // Re-throw if it's not a key mismatch error
- throw verifyError;
- }
+ private async handleSessionAuth(
+ request: AuthenticatedRequest,
+ skipOrgCheck = false,
+ ): Promise {
+ try {
+ // Build headers for better-auth SDK
+ // Forwards both Authorization (bearer session token) and Cookie headers
+ const headers = new Headers();
+ const authHeader = request.headers['authorization'] as string;
+ if (authHeader) {
+ headers.set('authorization', authHeader);
+ }
+ const cookieHeader = request.headers['cookie'] as string;
+ if (cookieHeader) {
+ headers.set('cookie', cookieHeader);
}
- // Extract user information from JWT payload (user data is directly in payload for Better Auth JWT)
- const userId = payload.id as string;
- const userEmail = payload.email as string;
-
- if (!userId) {
+ if (!authHeader && !cookieHeader) {
throw new UnauthorizedException(
- 'Invalid JWT payload: missing user information',
+ 'Authentication required: Provide either X-API-Key, Bearer token, or session cookie',
);
}
- // JWT authentication REQUIRES explicit X-Organization-Id header
- const explicitOrgId = request.headers['x-organization-id'] as string;
+ // Use better-auth SDK to resolve session
+ // Works with both bearer session tokens and httpOnly cookies
+ const session = await auth.api.getSession({ headers });
+
+ if (!session) {
+ throw new UnauthorizedException('Invalid or expired session');
+ }
+
+ const { user, session: sessionData } = session;
- if (!explicitOrgId) {
+ if (!user?.id) {
throw new UnauthorizedException(
- 'Organization context required: X-Organization-Id header is mandatory for JWT authentication',
+ 'Invalid session: missing user information',
);
}
- // Verify user has access to the requested organization
- const hasAccess = await this.verifyUserOrgAccess(userId, explicitOrgId);
- if (!hasAccess) {
+ const organizationId = sessionData.activeOrganizationId;
+ if (!organizationId && !skipOrgCheck) {
throw new UnauthorizedException(
- `User does not have access to organization: ${explicitOrgId}`,
+ 'No active organization. Please select an organization.',
);
}
- const member = await db.member.findFirst({
- where: {
- userId,
- organizationId: explicitOrgId,
- deactivated: false,
- },
- select: {
- role: true,
- },
- });
+ // Fetch member data for role and department info
+ // Skip if no active org or if org check is skipped (e.g., during onboarding)
+ let userRoles: string[] | null = null;
+ if (organizationId && !skipOrgCheck) {
+ const member = await db.member.findFirst({
+ where: {
+ userId: user.id,
+ organizationId,
+ deactivated: false,
+ },
+ select: {
+ id: true,
+ role: true,
+ department: true,
+ user: {
+ select: {
+ isPlatformAdmin: true,
+ },
+ },
+ },
+ });
+
+ if (!member) {
+ throw new UnauthorizedException(
+ `User is not a member of the active organization`,
+ );
+ }
- const userRoles = member?.role ? member.role.split(',') : null;
+ userRoles = member.role ? member.role.split(',') : null;
+ request.memberId = member.id;
+ request.memberDepartment = member.department;
+ request.isPlatformAdmin = member.user?.isPlatformAdmin ?? false;
+ }
- // Set request context for JWT auth
- request.userId = userId;
- request.userEmail = userEmail;
+ // Set request context for session auth
+ request.userId = user.id;
+ request.userEmail = user.email;
request.userRoles = userRoles;
- request.organizationId = explicitOrgId;
- request.authType = 'jwt';
+ request.organizationId = organizationId || '';
+ request.authType = 'session';
request.isApiKey = false;
+ request.isServiceToken = false;
+ request.isPlatformAdmin = request.isPlatformAdmin ?? false;
return true;
} catch (error) {
- console.error('JWT verification failed:', error);
-
- // Provide more helpful error messages
- if (error instanceof Error) {
- // Connection errors
- if (
- error.message.includes('ECONNREFUSED') ||
- error.message.includes('fetch failed')
- ) {
- console.error(
- `[HybridAuthGuard] Cannot connect to Better Auth JWKS endpoint at ${this.betterAuthUrl}/api/auth/jwks`,
- );
- console.error(
- '[HybridAuthGuard] Make sure BETTER_AUTH_URL is set correctly and the Better Auth server is running',
- );
- throw new UnauthorizedException(
- `Cannot connect to authentication service. Please check BETTER_AUTH_URL configuration.`,
- );
- }
-
- // Key mismatch errors should have been handled by retry logic above
- // If we still get one here, it means the retry also failed (token truly invalid)
- if (
- (error as any).code === 'ERR_JWKS_NO_MATCHING_KEY' ||
- error.message.includes('no applicable key found') ||
- error.message.includes('JWKSNoMatchingKey')
- ) {
- console.error(
- '[HybridAuthGuard] Token key not found even after fetching fresh JWKS. Token may be from a different environment or truly invalid.',
- );
- throw new UnauthorizedException(
- 'Authentication token is invalid. Please log out and log back in to refresh your session.',
- );
- }
+ if (error instanceof UnauthorizedException) {
+ throw error;
}
- throw new UnauthorizedException('Invalid or expired JWT token');
- }
- }
-
- /**
- * Verify that a user has access to a specific organization
- */
- private async verifyUserOrgAccess(
- userId: string,
- organizationId: string,
- ): Promise {
- try {
- const member = await db.member.findFirst({
- where: {
- userId,
- organizationId,
- deactivated: false,
- },
- select: {
- id: true,
- role: true,
- },
- });
-
- // User must be a member of the organization
- return !!member;
- } catch (error: unknown) {
- console.error('Error verifying user organization access:', error);
- return false;
+ console.error('[HybridAuthGuard] Session verification failed:', error);
+ throw new UnauthorizedException('Invalid or expired session');
}
}
}
diff --git a/apps/api/src/auth/permission.guard.spec.ts b/apps/api/src/auth/permission.guard.spec.ts
new file mode 100644
index 0000000000..9926e2eb6f
--- /dev/null
+++ b/apps/api/src/auth/permission.guard.spec.ts
@@ -0,0 +1,180 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ExecutionContext, ForbiddenException } from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { PermissionGuard, PERMISSIONS_KEY } from './permission.guard';
+
+// Mock auth.server to provide auth.api.hasPermission
+const mockHasPermission = jest.fn();
+jest.mock('./auth.server', () => ({
+ auth: {
+ api: {
+ hasPermission: (...args) => mockHasPermission(...args),
+ },
+ },
+}));
+
+describe('PermissionGuard', () => {
+ let guard: PermissionGuard;
+ let reflector: Reflector;
+
+ const createMockExecutionContext = (
+ request: Partial<{
+ isApiKey: boolean;
+ userRoles: string[] | null;
+ headers: Record;
+ organizationId: string;
+ }>,
+ ): ExecutionContext => {
+ return {
+ switchToHttp: () => ({
+ getRequest: () => ({
+ isApiKey: false,
+ userRoles: null,
+ headers: {},
+ organizationId: 'org_123',
+ ...request,
+ }),
+ }),
+ getHandler: () => jest.fn(),
+ getClass: () => jest.fn(),
+ } as unknown as ExecutionContext;
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [PermissionGuard, Reflector],
+ }).compile();
+
+ guard = module.get(PermissionGuard);
+ reflector = module.get(Reflector);
+ mockHasPermission.mockReset();
+ });
+
+ describe('canActivate', () => {
+ it('should allow access when no permissions are required', async () => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined);
+
+ const context = createMockExecutionContext({});
+ const result = await guard.canActivate(context);
+
+ expect(result).toBe(true);
+ });
+
+ it('should allow access for API keys (with warning)', async () => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
+ { resource: 'control', actions: ['delete'] },
+ ]);
+
+ const context = createMockExecutionContext({ isApiKey: true });
+ const result = await guard.canActivate(context);
+
+ expect(result).toBe(true);
+ });
+
+ it('should deny access when no authorization or cookie header present', async () => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
+ { resource: 'control', actions: ['delete'] },
+ ]);
+
+ const context = createMockExecutionContext({
+ headers: {},
+ });
+
+ await expect(guard.canActivate(context)).rejects.toThrow(
+ ForbiddenException,
+ );
+ });
+
+ it('should allow access when SDK returns success', async () => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
+ { resource: 'control', actions: ['delete'] },
+ ]);
+
+ mockHasPermission.mockResolvedValue({ success: true, error: null });
+
+ const context = createMockExecutionContext({
+ headers: { authorization: 'Bearer token' },
+ });
+
+ const result = await guard.canActivate(context);
+ expect(result).toBe(true);
+ expect(mockHasPermission).toHaveBeenCalledWith({
+ headers: expect.any(Headers),
+ body: {
+ permissions: { control: ['delete'] },
+ },
+ });
+ });
+
+ it('should deny access when SDK returns failure', async () => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
+ { resource: 'control', actions: ['delete'] },
+ ]);
+
+ mockHasPermission.mockResolvedValue({
+ success: false,
+ error: 'Permission denied',
+ });
+
+ const context = createMockExecutionContext({
+ headers: { authorization: 'Bearer token' },
+ });
+
+ await expect(guard.canActivate(context)).rejects.toThrow(
+ ForbiddenException,
+ );
+ });
+
+ it('should deny access when SDK throws', async () => {
+ jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([
+ { resource: 'control', actions: ['delete'] },
+ ]);
+
+ mockHasPermission.mockRejectedValue(new Error('SDK error'));
+
+ const context = createMockExecutionContext({
+ headers: { authorization: 'Bearer token' },
+ });
+
+ await expect(guard.canActivate(context)).rejects.toThrow(
+ ForbiddenException,
+ );
+ });
+ });
+
+ describe('isRestrictedRole', () => {
+ it('should return true for employee role', () => {
+ expect(PermissionGuard.isRestrictedRole(['employee'])).toBe(true);
+ });
+
+ it('should return true for contractor role', () => {
+ expect(PermissionGuard.isRestrictedRole(['contractor'])).toBe(true);
+ });
+
+ it('should return false for admin role', () => {
+ expect(PermissionGuard.isRestrictedRole(['admin'])).toBe(false);
+ });
+
+ it('should return false for owner role', () => {
+ expect(PermissionGuard.isRestrictedRole(['owner'])).toBe(false);
+ });
+
+ it('should return false for auditor role', () => {
+ expect(PermissionGuard.isRestrictedRole(['auditor'])).toBe(false);
+ });
+
+ it('should return false if user has both employee and admin roles', () => {
+ expect(PermissionGuard.isRestrictedRole(['employee', 'admin'])).toBe(
+ false,
+ );
+ });
+
+ it('should return true for null roles', () => {
+ expect(PermissionGuard.isRestrictedRole(null)).toBe(true);
+ });
+
+ it('should return true for empty roles array', () => {
+ expect(PermissionGuard.isRestrictedRole([])).toBe(true);
+ });
+ });
+});
diff --git a/apps/api/src/auth/permission.guard.ts b/apps/api/src/auth/permission.guard.ts
new file mode 100644
index 0000000000..dc5247486e
--- /dev/null
+++ b/apps/api/src/auth/permission.guard.ts
@@ -0,0 +1,205 @@
+import {
+ CanActivate,
+ ExecutionContext,
+ Injectable,
+ ForbiddenException,
+ Logger,
+} from '@nestjs/common';
+import { Reflector } from '@nestjs/core';
+import { RESTRICTED_ROLES, PRIVILEGED_ROLES } from '@comp/auth';
+import { auth } from './auth.server';
+import { resolveServiceByName } from './service-token.config';
+import { AuthenticatedRequest } from './types';
+
+/**
+ * Represents a required permission for an endpoint
+ */
+export interface RequiredPermission {
+ resource: string;
+ actions: string[];
+}
+
+/**
+ * Metadata key for storing required permissions on route handlers
+ */
+export const PERMISSIONS_KEY = 'required_permissions';
+
+/**
+ * PermissionGuard - Validates user permissions using better-auth's SDK
+ *
+ * This guard:
+ * 1. Extracts required permissions from route metadata
+ * 2. Uses better-auth's hasPermission SDK to validate against role definitions
+ * 3. For restricted roles (employee/contractor), also checks assignment access
+ *
+ * Usage:
+ * ```typescript
+ * @UseGuards(HybridAuthGuard, PermissionGuard)
+ * @RequirePermission('control', 'delete')
+ * async deleteControl() { ... }
+ * ```
+ */
+@Injectable()
+export class PermissionGuard implements CanActivate {
+ private readonly logger = new Logger(PermissionGuard.name);
+
+ constructor(private reflector: Reflector) {}
+
+ async canActivate(context: ExecutionContext): Promise {
+ // Get required permissions from route metadata
+ const requiredPermissions =
+ this.reflector.getAllAndOverride(PERMISSIONS_KEY, [
+ context.getHandler(),
+ context.getClass(),
+ ]);
+
+ // No permissions required - allow access
+ if (!requiredPermissions || requiredPermissions.length === 0) {
+ return true;
+ }
+
+ const request = context.switchToHttp().getRequest();
+
+ // API key scope enforcement
+ if (request.isApiKey) {
+ const scopes = request.apiKeyScopes;
+
+ // Legacy keys (empty scopes) = full access for backward compatibility
+ if (!scopes || scopes.length === 0) {
+ return true;
+ }
+
+ // Scoped keys: enforce permissions
+ const hasAllPerms = requiredPermissions.every((perm) =>
+ perm.actions.every((action) =>
+ scopes.includes(`${perm.resource}:${action}`),
+ ),
+ );
+
+ if (!hasAllPerms) {
+ throw new ForbiddenException(
+ 'API key lacks required permission scope',
+ );
+ }
+ return true;
+ }
+
+ // Service tokens: check scoped permissions (NOT a blanket bypass)
+ if (request.isServiceToken) {
+ const service = resolveServiceByName(request.serviceName);
+ if (!service) {
+ throw new ForbiddenException('Unknown service');
+ }
+
+ const hasAllPerms = requiredPermissions.every((perm) =>
+ perm.actions.every((action) =>
+ service.permissions.includes(`${perm.resource}:${action}`),
+ ),
+ );
+
+ if (!hasAllPerms) {
+ this.logger.warn(
+ `[PermissionGuard] Service "${request.serviceName}" denied: missing permission for ${requiredPermissions.map((p) => `${p.resource}:${p.actions.join(',')}`).join('; ')}`,
+ );
+ throw new ForbiddenException(
+ 'Service token lacks required permission',
+ );
+ }
+
+ return true;
+ }
+
+ // Platform admins bypass permission checks (full access)
+ if (request.isPlatformAdmin) {
+ return true;
+ }
+
+ // Build required permissions map, merging actions for duplicate resources
+ const permissionBody: Record = {};
+ for (const perm of requiredPermissions) {
+ const existing = permissionBody[perm.resource];
+ permissionBody[perm.resource] = existing
+ ? [...new Set([...existing, ...perm.actions])]
+ : perm.actions;
+ }
+
+ try {
+ const hasPermission = await this.checkPermission(
+ request,
+ permissionBody,
+ );
+
+ if (!hasPermission) {
+ this.logger.warn(
+ `[PermissionGuard] Access denied for ${request.method} ${request.url}. Required: ${JSON.stringify(permissionBody)}`,
+ );
+ throw new ForbiddenException('Access denied');
+ }
+
+ return true;
+ } catch (error) {
+ if (error instanceof ForbiddenException) {
+ throw error;
+ }
+ this.logger.error(`[PermissionGuard] Error checking permissions for ${request.method} ${request.url}:`, error);
+ throw new ForbiddenException('Unable to verify permissions');
+ }
+ }
+
+ /**
+ * Check permissions using better-auth's hasPermission SDK.
+ * Forwards both authorization and cookie headers so better-auth
+ * can resolve the user session (and activeOrganizationId), then
+ * checks the required permissions against the role definitions
+ * (including dynamic/custom roles stored in the DB).
+ */
+ private async checkPermission(
+ request: AuthenticatedRequest,
+ permissions: Record,
+ ): Promise {
+ const headers = new Headers();
+
+ const authHeader = request.headers['authorization'] as string;
+ if (authHeader) {
+ headers.set('authorization', authHeader);
+ }
+
+ const cookieHeader = request.headers['cookie'] as string;
+ if (cookieHeader) {
+ headers.set('cookie', cookieHeader);
+ }
+
+ if (!authHeader && !cookieHeader) {
+ return false;
+ }
+
+ const result = await auth.api.hasPermission({
+ headers,
+ body: { permissions },
+ });
+
+ return result.success === true;
+ }
+
+ /**
+ * Check if user has restricted role that requires assignment filtering
+ */
+ static isRestrictedRole(roles: string[] | null): boolean {
+ if (!roles || roles.length === 0) {
+ return true; // No roles = restricted
+ }
+
+ // If user has any privileged role, they're not restricted
+ const privileged: readonly string[] = PRIVILEGED_ROLES;
+ const restricted: readonly string[] = RESTRICTED_ROLES;
+ const hasPrivilegedRole = roles.some((role) =>
+ privileged.includes(role),
+ );
+ if (hasPrivilegedRole) {
+ return false;
+ }
+
+ // Check if all roles are restricted
+ return roles.every((role) => restricted.includes(role));
+ }
+}
diff --git a/apps/api/src/auth/platform-admin.guard.ts b/apps/api/src/auth/platform-admin.guard.ts
index 53c5ee85e2..a4708e75f9 100644
--- a/apps/api/src/auth/platform-admin.guard.ts
+++ b/apps/api/src/auth/platform-admin.guard.ts
@@ -5,10 +5,8 @@ import {
UnauthorizedException,
ForbiddenException,
} from '@nestjs/common';
-import { ConfigService } from '@nestjs/config';
import { db } from '@trycompai/db';
-import { createRemoteJWKSet, jwtVerify } from 'jose';
-import type { BetterAuthConfig } from '../config/better-auth.config';
+import { auth } from './auth.server';
interface PlatformAdminRequest {
userId?: string;
@@ -16,46 +14,54 @@ interface PlatformAdminRequest {
isPlatformAdmin?: boolean;
headers: {
authorization?: string;
+ cookie?: string;
[key: string]: string | undefined;
};
}
@Injectable()
export class PlatformAdminGuard implements CanActivate {
- private readonly betterAuthUrl: string;
-
- constructor(private readonly configService: ConfigService) {
- const betterAuthConfig =
- this.configService.get('betterAuth');
- this.betterAuthUrl =
- betterAuthConfig?.url || process.env.BETTER_AUTH_URL || '';
-
- if (!this.betterAuthUrl) {
- console.warn(
- '[PlatformAdminGuard] BETTER_AUTH_URL not configured. Authentication will fail.',
- );
- }
- }
-
async canActivate(context: ExecutionContext): Promise {
const request = context.switchToHttp().getRequest();
- // Only accept JWT authentication for admin routes
+ // Build headers for better-auth SDK
+ const headers = new Headers();
const authHeader = request.headers['authorization'];
- if (!authHeader?.startsWith('Bearer ')) {
+ if (authHeader) {
+ headers.set('authorization', authHeader);
+ }
+ const cookieHeader = request.headers['cookie'];
+ if (cookieHeader) {
+ headers.set('cookie', cookieHeader);
+ }
+
+ if (!authHeader && !cookieHeader) {
throw new UnauthorizedException(
- 'Platform admin routes require JWT authentication',
+ 'Platform admin routes require authentication',
);
}
- // Verify JWT and get user
- const user = await this.verifyJwtAndGetUser(authHeader);
+ // Resolve session via better-auth SDK
+ const session = await auth.api.getSession({ headers });
+
+ if (!session?.user?.id) {
+ throw new UnauthorizedException('Invalid or expired session');
+ }
+
+ // Fetch user from database to check isPlatformAdmin
+ const user = await db.user.findUnique({
+ where: { id: session.user.id },
+ select: {
+ id: true,
+ email: true,
+ isPlatformAdmin: true,
+ },
+ });
if (!user) {
- throw new UnauthorizedException('Invalid or expired JWT token');
+ throw new UnauthorizedException('User not found');
}
- // Check if user is a platform admin
if (!user.isPlatformAdmin) {
throw new ForbiddenException(
'Access denied: Platform admin privileges required',
@@ -69,85 +75,4 @@ export class PlatformAdminGuard implements CanActivate {
return true;
}
-
- private async verifyJwtAndGetUser(authHeader: string): Promise<{
- id: string;
- email: string;
- isPlatformAdmin: boolean;
- } | null> {
- try {
- if (!this.betterAuthUrl) {
- console.error(
- '[PlatformAdminGuard] BETTER_AUTH_URL environment variable is not set',
- );
- return null;
- }
-
- const token = authHeader.substring(7);
- const jwksUrl = `${this.betterAuthUrl}/api/auth/jwks`;
-
- const JWKS = createRemoteJWKSet(new URL(jwksUrl), {
- cacheMaxAge: 60000,
- cooldownDuration: 10000,
- });
-
- let payload;
- try {
- payload = (
- await jwtVerify(token, JWKS, {
- issuer: this.betterAuthUrl,
- audience: this.betterAuthUrl,
- })
- ).payload;
- } catch (verifyError: unknown) {
- const error = verifyError as { code?: string; message?: string };
- if (
- error.code === 'ERR_JWKS_NO_MATCHING_KEY' ||
- error.message?.includes('no applicable key found')
- ) {
- const freshJWKS = createRemoteJWKSet(new URL(jwksUrl), {
- cacheMaxAge: 0,
- cooldownDuration: 0,
- });
-
- payload = (
- await jwtVerify(token, freshJWKS, {
- issuer: this.betterAuthUrl,
- audience: this.betterAuthUrl,
- })
- ).payload;
- } else {
- throw verifyError;
- }
- }
-
- const userId = payload.id as string;
- if (!userId) {
- return null;
- }
-
- // Fetch user from database to check isPlatformAdmin
- const user = await db.user.findUnique({
- where: { id: userId },
- select: {
- id: true,
- email: true,
- isPlatformAdmin: true,
- },
- });
-
- if (!user) {
- return null;
- }
-
- return {
- id: user.id,
- email: user.email,
- isPlatformAdmin: user.isPlatformAdmin,
- };
- } catch (error) {
- console.error('[PlatformAdminGuard] JWT verification failed:', error);
- return null;
- }
- }
}
diff --git a/apps/api/src/auth/public.decorator.ts b/apps/api/src/auth/public.decorator.ts
new file mode 100644
index 0000000000..b3845e122b
--- /dev/null
+++ b/apps/api/src/auth/public.decorator.ts
@@ -0,0 +1,4 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const IS_PUBLIC_KEY = 'isPublic';
+export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
diff --git a/apps/api/src/auth/require-permission.decorator.ts b/apps/api/src/auth/require-permission.decorator.ts
new file mode 100644
index 0000000000..c4326f9341
--- /dev/null
+++ b/apps/api/src/auth/require-permission.decorator.ts
@@ -0,0 +1,77 @@
+import { SetMetadata } from '@nestjs/common';
+import { PERMISSIONS_KEY, RequiredPermission } from './permission.guard';
+
+/**
+ * Decorator to require specific permissions on a controller or endpoint.
+ * Uses better-auth's hasPermission API under the hood via PermissionGuard.
+ *
+ * @param resource - The resource being accessed (e.g., 'control', 'policy', 'task')
+ * @param actions - The action(s) being performed (e.g., 'read', 'delete', ['create', 'update'])
+ *
+ * @example
+ * // Require single permission
+ * @RequirePermission('control', 'delete')
+ *
+ * @example
+ * // Require multiple actions on same resource
+ * @RequirePermission('control', ['read', 'update'])
+ *
+ * @example
+ * // Use with guards
+ * @UseGuards(HybridAuthGuard, PermissionGuard)
+ * @RequirePermission('policy', 'update')
+ * @Post(':id/publish')
+ * async publishPolicy(@Param('id') id: string) { ... }
+ */
+export const RequirePermission = (
+ resource: string,
+ actions: string | string[],
+) =>
+ SetMetadata(PERMISSIONS_KEY, [
+ { resource, actions: Array.isArray(actions) ? actions : [actions] },
+ ] as RequiredPermission[]);
+
+/**
+ * Decorator to require multiple permissions on different resources.
+ * All specified permissions must be satisfied for access to be granted.
+ *
+ * @param permissions - Array of permission requirements
+ *
+ * @example
+ * // Require permissions on multiple resources
+ * @RequirePermissions([
+ * { resource: 'control', actions: ['read'] },
+ * { resource: 'evidence', actions: ['create'] },
+ * ])
+ */
+export const RequirePermissions = (permissions: RequiredPermission[]) =>
+ SetMetadata(PERMISSIONS_KEY, permissions);
+
+/**
+ * Resource types available in the GRC permission system
+ */
+export type GRCResource =
+ | 'organization'
+ | 'member'
+ | 'invitation'
+ | 'control'
+ | 'evidence'
+ | 'policy'
+ | 'risk'
+ | 'vendor'
+ | 'task'
+ | 'framework'
+ | 'audit'
+ | 'finding'
+ | 'questionnaire'
+ | 'integration'
+ | 'apiKey'
+ | 'cloud-security'
+ | 'training'
+ | 'app'
+ | 'trust';
+
+/**
+ * Action types available for GRC resources — CRUD only
+ */
+export type GRCAction = 'create' | 'read' | 'update' | 'delete';
diff --git a/apps/api/src/auth/service-token.config.ts b/apps/api/src/auth/service-token.config.ts
new file mode 100644
index 0000000000..eea113f177
--- /dev/null
+++ b/apps/api/src/auth/service-token.config.ts
@@ -0,0 +1,82 @@
+import { timingSafeEqual } from 'crypto';
+
+export interface ServiceDefinition {
+ /** Environment variable holding the token */
+ envVar: string;
+ /** Human-readable name for audit logs */
+ name: string;
+ /** Allowed 'resource:action' pairs */
+ permissions: string[];
+}
+
+/**
+ * Service definitions for internal service-to-service authentication.
+ * Each service gets its own token with explicit scoped permissions.
+ */
+export const SERVICE_DEFINITIONS: Record = {
+ trigger: {
+ envVar: 'SERVICE_TOKEN_TRIGGER',
+ name: 'Trigger.dev Workers',
+ permissions: [
+ 'integration:read',
+ 'integration:update',
+ 'cloud-security:update',
+ 'vendor:update',
+ 'email:send',
+ ],
+ },
+ portal: {
+ envVar: 'SERVICE_TOKEN_PORTAL',
+ name: 'Portal App',
+ permissions: ['training:read', 'training:update'],
+ },
+ trust: {
+ envVar: 'SERVICE_TOKEN_TRUST',
+ name: 'Trust Portal',
+ permissions: [
+ 'trust:read',
+ 'organization:read',
+ 'questionnaire:read',
+ 'questionnaire:update',
+ ],
+ },
+};
+
+/**
+ * Resolve which service a token belongs to using timing-safe comparison.
+ * Returns the service key and definition, or null if no match.
+ */
+export function resolveServiceByToken(
+ token: string,
+): { key: string; definition: ServiceDefinition } | null {
+ const tokenBuffer = Buffer.from(token);
+
+ for (const [key, definition] of Object.entries(SERVICE_DEFINITIONS)) {
+ const expectedToken = process.env[definition.envVar];
+ if (!expectedToken) continue;
+
+ const expectedBuffer = Buffer.from(expectedToken);
+ if (
+ tokenBuffer.length === expectedBuffer.length &&
+ timingSafeEqual(tokenBuffer, expectedBuffer)
+ ) {
+ return { key, definition };
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Look up a service definition by its key name (e.g., 'trigger', 'portal').
+ */
+export function resolveServiceByName(
+ name: string | undefined,
+): ServiceDefinition | null {
+ if (!name) return null;
+ // Match by human-readable name (stored on request.serviceName)
+ for (const definition of Object.values(SERVICE_DEFINITIONS)) {
+ if (definition.name === name) return definition;
+ }
+ return null;
+}
diff --git a/apps/api/src/auth/skip-org-check.decorator.ts b/apps/api/src/auth/skip-org-check.decorator.ts
new file mode 100644
index 0000000000..eeb4f0e1f9
--- /dev/null
+++ b/apps/api/src/auth/skip-org-check.decorator.ts
@@ -0,0 +1,4 @@
+import { SetMetadata } from '@nestjs/common';
+
+export const SKIP_ORG_CHECK_KEY = 'skipOrgCheck';
+export const SkipOrgCheck = () => SetMetadata(SKIP_ORG_CHECK_KEY, true);
diff --git a/apps/api/src/auth/types.ts b/apps/api/src/auth/types.ts
index 0143395e41..7874ff475f 100644
--- a/apps/api/src/auth/types.ts
+++ b/apps/api/src/auth/types.ts
@@ -1,19 +1,33 @@
-// Types for API authentication - supports API keys and JWT tokens only
+// Types for API authentication - supports API keys and session-based auth
+
+import { Departments } from '@prisma/client';
export interface AuthenticatedRequest extends Request {
organizationId: string;
- authType: 'api-key' | 'jwt';
+ authType: 'api-key' | 'session' | 'service';
isApiKey: boolean;
+ isServiceToken?: boolean;
+ serviceName?: string;
+ isPlatformAdmin: boolean;
userId?: string;
userEmail?: string;
userRoles: string[] | null;
+ 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)
}
export interface AuthContext {
organizationId: string;
- authType: 'api-key' | 'jwt';
+ authType: 'api-key' | 'session' | 'service';
isApiKey: boolean;
- userId?: string; // Only available for JWT auth
- userEmail?: string; // Only available for JWT auth
+ isServiceToken?: boolean;
+ serviceName?: string;
+ isPlatformAdmin: boolean;
+ userId?: string; // Only available for session auth
+ userEmail?: string; // Only available for session auth
userRoles: string[] | null;
+ 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)
}
diff --git a/apps/api/src/browserbase/browserbase.controller.ts b/apps/api/src/browserbase/browserbase.controller.ts
index ad04ad17b5..cac2b7aef1 100644
--- a/apps/api/src/browserbase/browserbase.controller.ts
+++ b/apps/api/src/browserbase/browserbase.controller.ts
@@ -9,7 +9,6 @@ import {
UseGuards,
} from '@nestjs/common';
import {
- ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
@@ -18,6 +17,8 @@ import {
} from '@nestjs/swagger';
import { 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 { BrowserbaseService } from './browserbase.service';
import {
AuthStatusResponseDto,
@@ -36,19 +37,15 @@ import {
@ApiTags('Browserbase')
@Controller({ path: 'browserbase', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description: 'Organization ID (required for session auth)',
- required: true,
-})
export class BrowserbaseController {
constructor(private readonly browserbaseService: BrowserbaseService) {}
// ===== Organization Context =====
@Post('org-context')
+ @RequirePermission('integration', 'create')
@ApiOperation({
summary: 'Get or create organization browser context',
description:
@@ -66,6 +63,7 @@ export class BrowserbaseController {
}
@Get('org-context')
+ @RequirePermission('integration', 'read')
@ApiOperation({
summary: 'Get organization browser context status',
description: 'Gets the current browser context for the org if it exists',
@@ -87,6 +85,7 @@ export class BrowserbaseController {
// ===== Session Management =====
@Post('session')
+ @RequirePermission('integration', 'read')
@ApiOperation({
summary: 'Create a new browser session',
description: 'Creates a new browser session using the org context',
@@ -105,6 +104,7 @@ export class BrowserbaseController {
}
@Post('session/close')
+ @RequirePermission('integration', 'read')
@ApiOperation({
summary: 'Close a browser session',
})
@@ -122,6 +122,7 @@ export class BrowserbaseController {
// ===== Browser Navigation =====
@Post('navigate')
+ @RequirePermission('integration', 'read')
@ApiOperation({
summary: 'Navigate to a URL',
description: 'Navigates the browser session to the specified URL',
@@ -137,6 +138,7 @@ export class BrowserbaseController {
}
@Post('check-auth')
+ @RequirePermission('integration', 'read')
@ApiOperation({
summary: 'Check authentication status',
description: 'Checks if the user is logged in on the specified site',
@@ -156,6 +158,7 @@ export class BrowserbaseController {
// ===== Browser Automations CRUD =====
@Post('automations')
+ @RequirePermission('task', 'create')
@ApiOperation({
summary: 'Create a browser automation',
})
@@ -173,6 +176,7 @@ export class BrowserbaseController {
}
@Get('automations/task/:taskId')
+ @RequirePermission('task', 'read')
@ApiOperation({
summary: 'Get all browser automations for a task',
})
@@ -191,6 +195,7 @@ export class BrowserbaseController {
}
@Get('automations/:automationId')
+ @RequirePermission('task', 'read')
@ApiOperation({
summary: 'Get a browser automation by ID',
})
@@ -209,6 +214,7 @@ export class BrowserbaseController {
}
@Patch('automations/:automationId')
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Update a browser automation',
})
@@ -229,6 +235,7 @@ export class BrowserbaseController {
}
@Delete('automations/:automationId')
+ @RequirePermission('task', 'delete')
@ApiOperation({
summary: 'Delete a browser automation',
})
@@ -247,6 +254,7 @@ export class BrowserbaseController {
// ===== Automation Execution =====
@Post('automations/:automationId/start-live')
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Start automation with live view',
description:
@@ -274,6 +282,7 @@ export class BrowserbaseController {
}
@Post('automations/:automationId/execute')
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Execute automation on existing session',
description: 'Runs the automation on a pre-created session',
@@ -302,6 +311,7 @@ export class BrowserbaseController {
}
@Post('automations/:automationId/run')
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Run a browser automation',
description: 'Executes the automation and returns the result',
@@ -325,6 +335,7 @@ export class BrowserbaseController {
// ===== Run History =====
@Get('automations/:automationId/runs')
+ @RequirePermission('task', 'read')
@ApiOperation({
summary: 'Get run history for an automation',
})
@@ -343,6 +354,7 @@ export class BrowserbaseController {
}
@Get('runs/:runId')
+ @RequirePermission('task', 'read')
@ApiOperation({
summary: 'Get a specific run by ID',
})
diff --git a/apps/api/src/browserbase/browserbase.service.ts b/apps/api/src/browserbase/browserbase.service.ts
index b38f614008..ee208b3cd8 100644
--- a/apps/api/src/browserbase/browserbase.service.ts
+++ b/apps/api/src/browserbase/browserbase.service.ts
@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import Browserbase from '@browserbasehq/sdk';
-import { Stagehand } from '@browserbasehq/stagehand';
+// Lazy-imported in createStagehand() to avoid Node v25 crash
+// (SlowBuffer.prototype was removed — @browserbasehq/stagehand bundles buffer-equal-constant-time which uses it)
+type Stagehand = import('@browserbasehq/stagehand').Stagehand;
import { db } from '@trycompai/db';
import { z } from 'zod';
import {
@@ -196,6 +198,7 @@ export class BrowserbaseService {
// ===== Stagehand helpers =====
private async createStagehand(sessionId: string): Promise {
+ const { Stagehand } = await import('@browserbasehq/stagehand');
const stagehand = new Stagehand({
env: 'BROWSERBASE',
apiKey: process.env.BROWSERBASE_API_KEY,
diff --git a/apps/api/src/cloud-security/cloud-security-legacy.service.ts b/apps/api/src/cloud-security/cloud-security-legacy.service.ts
new file mode 100644
index 0000000000..28f9de3eb1
--- /dev/null
+++ b/apps/api/src/cloud-security/cloud-security-legacy.service.ts
@@ -0,0 +1,234 @@
+import {
+ Injectable,
+ Logger,
+ NotFoundException,
+ BadRequestException,
+} from '@nestjs/common';
+import { db } from '@db';
+import { Prisma } from '@prisma/client';
+import {
+ createCipheriv,
+ randomBytes,
+ scryptSync,
+} from 'crypto';
+import { DescribeRegionsCommand, EC2Client } from '@aws-sdk/client-ec2';
+import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts';
+
+const ALGORITHM = 'aes-256-gcm';
+const IV_LENGTH = 12;
+const SALT_LENGTH = 16;
+const KEY_LENGTH = 32;
+
+interface EncryptedData {
+ encrypted: string;
+ iv: string;
+ tag: string;
+ salt: string;
+}
+
+/** AWS region code to friendly name mapping */
+const REGION_NAMES: Record = {
+ 'us-east-1': 'US East (N. Virginia)',
+ 'us-east-2': 'US East (Ohio)',
+ 'us-west-1': 'US West (N. California)',
+ 'us-west-2': 'US West (Oregon)',
+ 'eu-west-1': 'Europe (Ireland)',
+ 'eu-west-2': 'Europe (London)',
+ 'eu-west-3': 'Europe (Paris)',
+ 'eu-central-1': 'Europe (Frankfurt)',
+ 'eu-north-1': 'Europe (Stockholm)',
+ 'eu-south-1': 'Europe (Milan)',
+ 'ap-southeast-1': 'Asia Pacific (Singapore)',
+ 'ap-southeast-2': 'Asia Pacific (Sydney)',
+ 'ap-northeast-1': 'Asia Pacific (Tokyo)',
+ 'ap-northeast-2': 'Asia Pacific (Seoul)',
+ 'ap-northeast-3': 'Asia Pacific (Osaka)',
+ 'ap-south-1': 'Asia Pacific (Mumbai)',
+ 'ap-east-1': 'Asia Pacific (Hong Kong)',
+ 'ca-central-1': 'Canada (Central)',
+ 'sa-east-1': 'South America (São Paulo)',
+ 'me-south-1': 'Middle East (Bahrain)',
+ 'af-south-1': 'Africa (Cape Town)',
+};
+
+@Injectable()
+export class CloudSecurityLegacyService {
+ private readonly logger = new Logger(CloudSecurityLegacyService.name);
+
+ /**
+ * Encrypt a string value using the same algorithm as the Next.js app.
+ * Produces EncryptedData compatible with @/lib/encryption.
+ */
+ private encrypt(text: string): EncryptedData {
+ const secretKey = process.env.ENCRYPTION_KEY;
+ if (!secretKey) {
+ throw new Error('ENCRYPTION_KEY environment variable is not set');
+ }
+
+ const salt = randomBytes(SALT_LENGTH);
+ const iv = randomBytes(IV_LENGTH);
+ const key = scryptSync(secretKey, salt, KEY_LENGTH, {
+ N: 16384,
+ r: 8,
+ p: 1,
+ });
+ const cipher = createCipheriv(ALGORITHM, key, iv);
+
+ const encrypted = Buffer.concat([
+ cipher.update(text, 'utf8'),
+ cipher.final(),
+ ]);
+ const tag = cipher.getAuthTag();
+
+ return {
+ encrypted: encrypted.toString('base64'),
+ iv: iv.toString('base64'),
+ tag: tag.toString('base64'),
+ salt: salt.toString('base64'),
+ };
+ }
+
+ /**
+ * Connect a legacy cloud provider (creates Integration record).
+ */
+ async connectLegacy(
+ organizationId: string,
+ provider: 'aws' | 'gcp' | 'azure',
+ credentials: Record,
+ ): Promise<{ integrationId: string }> {
+ // Encrypt all credential fields
+ const encryptedCredentials: Record = {};
+ for (const [key, value] of Object.entries(credentials)) {
+ if (typeof value === 'string') {
+ if (value.trim()) {
+ encryptedCredentials[key] = this.encrypt(value);
+ }
+ continue;
+ }
+ if (Array.isArray(value)) {
+ encryptedCredentials[key] = value
+ .filter(Boolean)
+ .map((item) => this.encrypt(item));
+ }
+ }
+
+ // Extract display settings
+ const connectionName =
+ typeof credentials.connectionName === 'string'
+ ? credentials.connectionName.trim()
+ : undefined;
+ const accountId =
+ typeof credentials.accountId === 'string'
+ ? credentials.accountId.trim()
+ : undefined;
+ const regionValues = Array.isArray(credentials.regions)
+ ? credentials.regions
+ : typeof credentials.region === 'string'
+ ? [credentials.region]
+ : [];
+
+ const settings =
+ provider === 'aws'
+ ? { accountId, connectionName, regions: regionValues }
+ : {};
+
+ const integration = await db.integration.create({
+ data: {
+ name: connectionName || provider.toUpperCase(),
+ integrationId: provider,
+ organizationId,
+ userSettings: encryptedCredentials as Prisma.JsonObject,
+ settings: settings as Prisma.JsonObject,
+ },
+ });
+
+ this.logger.log(
+ `Created legacy integration ${integration.id} for ${provider}`,
+ );
+ return { integrationId: integration.id };
+ }
+
+ /**
+ * Disconnect a legacy cloud provider (deletes Integration record).
+ */
+ async disconnectLegacy(
+ integrationId: string,
+ organizationId: string,
+ ): Promise {
+ const integration = await db.integration.findFirst({
+ where: { id: integrationId, organizationId },
+ });
+
+ if (!integration) {
+ throw new NotFoundException('Cloud provider not found');
+ }
+
+ // Cascade deletes results
+ await db.integration.delete({ where: { id: integration.id } });
+
+ this.logger.log(`Deleted legacy integration ${integrationId}`);
+ }
+
+ /**
+ * Validate AWS access key credentials (legacy flow using access key + secret).
+ * Returns account ID and available regions.
+ */
+ async validateAwsAccessKeys(
+ accessKeyId: string,
+ secretAccessKey: string,
+ ): Promise<{
+ accountId: string;
+ regions: Array<{ value: string; label: string }>;
+ }> {
+ if (!accessKeyId?.trim() || !secretAccessKey?.trim()) {
+ throw new BadRequestException('Access key ID and secret are required');
+ }
+
+ const awsCredentials = {
+ accessKeyId: accessKeyId.trim(),
+ secretAccessKey: secretAccessKey.trim(),
+ };
+
+ // Validate credentials via STS
+ const stsClient = new STSClient({
+ region: 'us-east-1',
+ credentials: awsCredentials,
+ });
+
+ let accountIdentity: string;
+ try {
+ const identity = await stsClient.send(
+ new GetCallerIdentityCommand({}),
+ );
+ accountIdentity = identity.Account || '';
+ } catch (error) {
+ const msg =
+ error instanceof Error ? error.message : 'Failed to validate';
+ throw new BadRequestException(`Invalid AWS credentials: ${msg}`);
+ }
+
+ // Get available regions
+ const ec2Client = new EC2Client({
+ region: 'us-east-1',
+ credentials: awsCredentials,
+ });
+
+ let regions: Array<{ value: string; label: string }>;
+ try {
+ const resp = await ec2Client.send(new DescribeRegionsCommand({}));
+ regions = (resp.Regions || [])
+ .filter((r) => r.RegionName)
+ .map((r) => {
+ const code = r.RegionName!;
+ const friendly = REGION_NAMES[code] || code;
+ return { value: code, label: `${friendly} (${code})` };
+ })
+ .sort((a, b) => a.value.localeCompare(b.value));
+ } catch {
+ // Regions fetch failed — return empty (credentials still valid)
+ regions = [];
+ }
+
+ return { accountId: accountIdentity, regions };
+ }
+}
diff --git a/apps/api/src/cloud-security/cloud-security-query.service.ts b/apps/api/src/cloud-security/cloud-security-query.service.ts
new file mode 100644
index 0000000000..5075feea67
--- /dev/null
+++ b/apps/api/src/cloud-security/cloud-security-query.service.ts
@@ -0,0 +1,326 @@
+import { Injectable } from '@nestjs/common';
+import { db } from '@db';
+import { getManifest } from '@comp/integration-platform';
+
+const CLOUD_PROVIDER_CATEGORY = 'Cloud';
+
+/** Scan window for filtering legacy results to latest scan only */
+const SCAN_WINDOW_MS = 10 * 60 * 1000; // 10 minutes
+
+export interface CloudProvider {
+ id: string;
+ integrationId: string;
+ name: string;
+ displayName?: string;
+ organizationId: string;
+ lastRunAt: Date | null;
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+ isLegacy: boolean;
+ variables: Record | null;
+ requiredVariables: string[];
+ accountId?: string;
+ regions?: string[];
+ tenantId?: string;
+ subscriptionId?: string;
+ supportsMultipleConnections?: boolean;
+}
+
+export interface CloudFinding {
+ id: string;
+ title: string | null;
+ description: string | null;
+ remediation: string | null;
+ status: string | null;
+ severity: string | null;
+ completedAt: Date | null;
+ connectionId: string;
+ providerSlug: string;
+ integration: { integrationId: string };
+}
+
+/** Get required variables from manifest (both manifest-level and check-level) */
+function getRequiredVariables(providerSlug: string): string[] {
+ const manifest = getManifest(providerSlug);
+ if (!manifest) return [];
+
+ const requiredVars = new Set();
+
+ if (manifest.variables) {
+ for (const variable of manifest.variables) {
+ if (variable.required) requiredVars.add(variable.id);
+ }
+ }
+
+ if (manifest.checks) {
+ for (const check of manifest.checks) {
+ if (check.variables) {
+ for (const variable of check.variables) {
+ if (variable.required) requiredVars.add(variable.id);
+ }
+ }
+ }
+ }
+
+ return Array.from(requiredVars);
+}
+
+@Injectable()
+export class CloudSecurityQueryService {
+ async getProviders(organizationId: string): Promise {
+ // Fetch from NEW integration platform
+ const newConnections = await db.integrationConnection.findMany({
+ where: {
+ organizationId,
+ status: 'active',
+ provider: { category: CLOUD_PROVIDER_CATEGORY },
+ },
+ include: { provider: true },
+ });
+
+ // Fetch from OLD integration table
+ const legacyIntegrations = await db.integration.findMany({
+ where: { organizationId },
+ });
+
+ const activeLegacy = legacyIntegrations.filter((i) => {
+ const manifest = getManifest(i.integrationId);
+ return manifest?.category === CLOUD_PROVIDER_CATEGORY;
+ });
+
+ // Map new connections
+ const newProviders: CloudProvider[] = newConnections.map((conn) => {
+ const metadata = (conn.metadata || {}) as Record;
+ const manifest = getManifest(conn.provider.slug);
+ return {
+ id: conn.id,
+ integrationId: conn.provider.slug,
+ name: conn.provider.name,
+ displayName:
+ typeof metadata.connectionName === 'string'
+ ? metadata.connectionName
+ : conn.provider.name,
+ organizationId: conn.organizationId,
+ lastRunAt: conn.lastSyncAt,
+ status: conn.status,
+ createdAt: conn.createdAt,
+ updatedAt: conn.updatedAt,
+ isLegacy: false,
+ variables: (conn.variables as Record) ?? null,
+ requiredVariables: getRequiredVariables(conn.provider.slug),
+ accountId:
+ typeof metadata.accountId === 'string'
+ ? metadata.accountId
+ : undefined,
+ regions: Array.isArray(metadata.regions)
+ ? metadata.regions.filter(
+ (r): r is string => typeof r === 'string',
+ )
+ : undefined,
+ tenantId:
+ typeof metadata.tenantId === 'string'
+ ? metadata.tenantId
+ : undefined,
+ subscriptionId:
+ typeof metadata.subscriptionId === 'string'
+ ? metadata.subscriptionId
+ : undefined,
+ supportsMultipleConnections:
+ manifest?.supportsMultipleConnections ?? false,
+ };
+ });
+
+ // Map legacy integrations
+ const legacyProviders: CloudProvider[] = activeLegacy.map((integration) => {
+ const settings = (integration.settings || {}) as Record;
+ const manifest = getManifest(integration.integrationId);
+ return {
+ id: integration.id,
+ integrationId: integration.integrationId,
+ name: integration.name,
+ displayName:
+ typeof settings.connectionName === 'string'
+ ? settings.connectionName
+ : integration.name,
+ organizationId: integration.organizationId,
+ lastRunAt: integration.lastRunAt,
+ status: 'active',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ isLegacy: true,
+ variables: null,
+ requiredVariables: getRequiredVariables(integration.integrationId),
+ accountId:
+ typeof settings.accountId === 'string'
+ ? settings.accountId
+ : undefined,
+ regions: Array.isArray(settings.regions)
+ ? settings.regions.filter(
+ (r): r is string => typeof r === 'string',
+ )
+ : undefined,
+ tenantId:
+ typeof settings.tenantId === 'string'
+ ? settings.tenantId
+ : undefined,
+ subscriptionId:
+ typeof settings.subscriptionId === 'string'
+ ? settings.subscriptionId
+ : undefined,
+ supportsMultipleConnections:
+ manifest?.supportsMultipleConnections ?? false,
+ };
+ });
+
+ return [...newProviders, ...legacyProviders];
+ }
+
+ async getFindings(organizationId: string): Promise {
+ const newFindings = await this.getNewPlatformFindings(organizationId);
+ const legacyFindings = await this.getLegacyFindings(organizationId);
+
+ return [...newFindings, ...legacyFindings].sort((a, b) => {
+ const dateA = a.completedAt ? new Date(a.completedAt).getTime() : 0;
+ const dateB = b.completedAt ? new Date(b.completedAt).getTime() : 0;
+ return dateB - dateA;
+ });
+ }
+
+ private async getNewPlatformFindings(
+ organizationId: string,
+ ): Promise {
+ const connections = await db.integrationConnection.findMany({
+ where: {
+ organizationId,
+ status: 'active',
+ provider: { category: CLOUD_PROVIDER_CATEGORY },
+ },
+ include: { provider: true },
+ });
+
+ const connectionIds = connections.map((c) => c.id);
+ if (connectionIds.length === 0) return [];
+
+ const connectionToSlug = Object.fromEntries(
+ connections.map((c) => [c.id, c.provider.slug]),
+ );
+
+ const latestRuns = await db.integrationCheckRun.findMany({
+ where: {
+ connectionId: { in: connectionIds },
+ status: { in: ['success', 'failed'] },
+ },
+ orderBy: { completedAt: 'desc' },
+ distinct: ['connectionId'],
+ select: { id: true, connectionId: true, status: true },
+ });
+
+ const latestRunIds = latestRuns.map((r) => r.id);
+ if (latestRunIds.length === 0) return [];
+
+ const checkRunMap = Object.fromEntries(
+ latestRuns.map((cr) => [cr.id, cr]),
+ );
+
+ const results = await db.integrationCheckResult.findMany({
+ where: { checkRunId: { in: latestRunIds } },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ remediation: true,
+ severity: true,
+ collectedAt: true,
+ checkRunId: true,
+ passed: true,
+ },
+ orderBy: { collectedAt: 'desc' },
+ });
+
+ return results.map((result) => {
+ const checkRun = checkRunMap[result.checkRunId];
+ const slug = checkRun
+ ? connectionToSlug[checkRun.connectionId] || 'unknown'
+ : 'unknown';
+ return {
+ id: result.id,
+ title: result.title,
+ description: result.description,
+ remediation: result.remediation,
+ status: result.passed ? 'passed' : 'failed',
+ severity: result.severity,
+ completedAt: result.collectedAt,
+ connectionId: checkRun?.connectionId ?? '',
+ providerSlug: slug,
+ integration: { integrationId: slug },
+ };
+ });
+ }
+
+ private async getLegacyFindings(
+ organizationId: string,
+ ): Promise {
+ const legacyIntegrations = await db.integration.findMany({
+ where: { organizationId },
+ });
+
+ const activeLegacy = legacyIntegrations.filter((i) => {
+ const manifest = getManifest(i.integrationId);
+ return manifest?.category === CLOUD_PROVIDER_CATEGORY;
+ });
+
+ const legacyIds = activeLegacy.map((i) => i.id);
+ if (legacyIds.length === 0) return [];
+
+ const lastRunMap = new Map(
+ activeLegacy
+ .filter((i) => i.lastRunAt)
+ .map((i) => [i.id, i.lastRunAt!]),
+ );
+
+ const results = await db.integrationResult.findMany({
+ where: { integrationId: { in: legacyIds } },
+ select: {
+ id: true,
+ title: true,
+ description: true,
+ remediation: true,
+ status: true,
+ severity: true,
+ completedAt: true,
+ integration: {
+ select: { integrationId: true, id: true, lastRunAt: true },
+ },
+ },
+ orderBy: { completedAt: 'desc' },
+ });
+
+ // Filter to only include results from the most recent scan
+ const filtered = results.filter((result) => {
+ const lastRunAt = lastRunMap.get(result.integration.id);
+ if (!lastRunAt) return result.completedAt !== null;
+ if (!result.completedAt) return false;
+
+ const lastRunTime = lastRunAt.getTime();
+ const completedTime = result.completedAt.getTime();
+ return (
+ completedTime <= lastRunTime &&
+ completedTime >= lastRunTime - SCAN_WINDOW_MS
+ );
+ });
+
+ return filtered.map((result) => ({
+ id: result.id,
+ title: result.title,
+ description: result.description,
+ remediation: result.remediation,
+ status: result.status,
+ severity: result.severity,
+ completedAt: result.completedAt,
+ connectionId: result.integration.id,
+ providerSlug: result.integration.integrationId,
+ integration: { integrationId: result.integration.integrationId },
+ }));
+ }
+}
diff --git a/apps/api/src/cloud-security/cloud-security.controller.ts b/apps/api/src/cloud-security/cloud-security.controller.ts
index 43695a0795..a3c90112e3 100644
--- a/apps/api/src/cloud-security/cloud-security.controller.ts
+++ b/apps/api/src/cloud-security/cloud-security.controller.ts
@@ -2,38 +2,59 @@ import {
Controller,
Post,
Get,
+ Delete,
Param,
Query,
- Headers,
+ Body,
Logger,
HttpException,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
import { OrganizationId } from '../auth/auth-context.decorator';
import {
CloudSecurityService,
ConnectionNotFoundError,
} from './cloud-security.service';
+import { CloudSecurityQueryService } from './cloud-security-query.service';
+import { CloudSecurityLegacyService } from './cloud-security-legacy.service';
@Controller({ path: 'cloud-security', version: '1' })
export class CloudSecurityController {
private readonly logger = new Logger(CloudSecurityController.name);
- constructor(private readonly cloudSecurityService: CloudSecurityService) {}
+ constructor(
+ private readonly cloudSecurityService: CloudSecurityService,
+ private readonly queryService: CloudSecurityQueryService,
+ private readonly legacyService: CloudSecurityLegacyService,
+ ) {}
+
+ @Get('providers')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'read')
+ async getProviders(@OrganizationId() organizationId: string) {
+ const providers = await this.queryService.getProviders(organizationId);
+ return { data: providers, count: providers.length };
+ }
+
+ @Get('findings')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'read')
+ async getFindings(@OrganizationId() organizationId: string) {
+ const findings = await this.queryService.getFindings(organizationId);
+ return { data: findings, count: findings.length };
+ }
@Post('scan/:connectionId')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'update')
async scan(
@Param('connectionId') connectionId: string,
- @Headers('x-organization-id') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
- if (!organizationId) {
- throw new HttpException(
- 'Organization ID required',
- HttpStatus.BAD_REQUEST,
- );
- }
this.logger.log(
`Cloud security scan requested for connection ${connectionId}`,
@@ -63,7 +84,8 @@ export class CloudSecurityController {
}
@Post('trigger/:connectionId')
- @UseGuards(HybridAuthGuard)
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'update')
async triggerScan(
@Param('connectionId') connectionId: string,
@OrganizationId() organizationId: string,
@@ -86,7 +108,8 @@ export class CloudSecurityController {
}
@Get('runs/:runId')
- @UseGuards(HybridAuthGuard)
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'read')
async getRunStatus(
@Param('runId') runId: string,
@Query('connectionId') connectionId: string,
@@ -114,4 +137,43 @@ export class CloudSecurityController {
throw new HttpException(message, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
+
+ @Post('legacy/connect')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'create')
+ async connectLegacy(
+ @OrganizationId() organizationId: string,
+ @Body() body: { provider: 'aws' | 'gcp' | 'azure'; credentials: Record },
+ ) {
+ const result = await this.legacyService.connectLegacy(
+ organizationId,
+ body.provider,
+ body.credentials,
+ );
+ return { success: true, integrationId: result.integrationId };
+ }
+
+ @Post('legacy/validate-aws')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'read')
+ async validateAwsCredentials(
+ @Body() body: { accessKeyId: string; secretAccessKey: string },
+ ) {
+ const result = await this.legacyService.validateAwsAccessKeys(
+ body.accessKeyId,
+ body.secretAccessKey,
+ );
+ return { success: true, accountId: result.accountId, regions: result.regions };
+ }
+
+ @Delete('legacy/:integrationId')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'delete')
+ async disconnectLegacy(
+ @Param('integrationId') integrationId: string,
+ @OrganizationId() organizationId: string,
+ ) {
+ await this.legacyService.disconnectLegacy(integrationId, organizationId);
+ return { success: true };
+ }
}
diff --git a/apps/api/src/cloud-security/cloud-security.module.ts b/apps/api/src/cloud-security/cloud-security.module.ts
index 88161c1c77..19f0137f34 100644
--- a/apps/api/src/cloud-security/cloud-security.module.ts
+++ b/apps/api/src/cloud-security/cloud-security.module.ts
@@ -1,6 +1,8 @@
import { Module } 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 { GCPSecurityService } from './providers/gcp-security.service';
import { AWSSecurityService } from './providers/aws-security.service';
import { AzureSecurityService } from './providers/azure-security.service';
@@ -12,6 +14,8 @@ import { AuthModule } from '../auth/auth.module';
controllers: [CloudSecurityController],
providers: [
CloudSecurityService,
+ CloudSecurityQueryService,
+ CloudSecurityLegacyService,
GCPSecurityService,
AWSSecurityService,
AzureSecurityService,
diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts
index 816a86664c..8f307df69d 100644
--- a/apps/api/src/comments/comment-mention-notifier.service.ts
+++ b/apps/api/src/comments/comment-mention-notifier.service.ts
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { db } from '@db';
import { isUserUnsubscribed } from '@trycompai/email';
-import { sendEmail } from '../email/resend';
+import { triggerEmail } from '../email/trigger-email';
import { CommentMentionedEmail } from '../email/templates/comment-mentioned';
import { NovuService } from '../notifications/novu.service';
// Reuse the extract mentions utility
@@ -240,12 +240,20 @@ export class CommentMentionNotifierService {
return;
}
- // Get all mentioned users
- const mentionedUsers = await db.user.findMany({
+ // Get mentioned users: exclude platform admins unless they are an owner of this org
+ const mentionedMembers = await db.member.findMany({
where: {
- id: { in: mentionedUserIds },
+ organizationId,
+ deactivated: false,
+ user: { id: { in: mentionedUserIds } },
+ OR: [
+ { user: { isPlatformAdmin: false } },
+ { role: { contains: 'owner' } },
+ ],
},
+ include: { user: true },
});
+ const mentionedUsers = mentionedMembers.map((m) => m.user);
const normalizedContextUrl = tryNormalizeContextUrl({
organizationId,
@@ -296,6 +304,7 @@ export class CommentMentionNotifierService {
db,
user.email,
'taskMentions',
+ organizationId,
);
if (isUnsubscribed) {
this.logger.log(
@@ -308,7 +317,7 @@ export class CommentMentionNotifierService {
// Send email notification via Resend
try {
- const { id } = await sendEmail({
+ const { id } = await triggerEmail({
to: user.email,
subject: `${mentionedByName} mentioned you in a comment`,
react: CommentMentionedEmail({
diff --git a/apps/api/src/comments/comments.controller.spec.ts b/apps/api/src/comments/comments.controller.spec.ts
new file mode 100644
index 0000000000..df16db3888
--- /dev/null
+++ b/apps/api/src/comments/comments.controller.spec.ts
@@ -0,0 +1,253 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { BadRequestException } from '@nestjs/common';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext } from '../auth/types';
+import { CommentsController } from './comments.controller';
+import { CommentsService } from './comments.service';
+
+// Mock auth.server to avoid importing better-auth ESM in Jest
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {},
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('CommentsController', () => {
+ let controller: CommentsController;
+ let commentsService: jest.Mocked;
+
+ const mockCommentsService = {
+ getComments: jest.fn(),
+ createComment: jest.fn(),
+ updateComment: jest.fn(),
+ deleteComment: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'usr_123',
+ userEmail: 'test@example.com',
+ userRoles: ['admin'],
+ };
+
+ const apiKeyAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'apiKey',
+ isApiKey: true,
+ isPlatformAdmin: false,
+ userId: undefined,
+ userEmail: undefined,
+ userRoles: ['admin'],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [CommentsController],
+ providers: [{ provide: CommentsService, useValue: mockCommentsService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(CommentsController);
+ commentsService = module.get(CommentsService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getComments', () => {
+ it('should call commentsService.getComments with correct parameters', async () => {
+ const comments = [{ id: 'cmt_1', content: 'Hello' }];
+ mockCommentsService.getComments.mockResolvedValue(comments);
+
+ const result = await controller.getComments('org_123', 'tsk_1', 'task' as never);
+
+ expect(commentsService.getComments).toHaveBeenCalledWith(
+ 'org_123',
+ 'tsk_1',
+ 'task',
+ );
+ expect(result).toEqual(comments);
+ });
+ });
+
+ describe('createComment', () => {
+ it('should use authContext.userId for session auth', async () => {
+ const dto = {
+ content: 'New comment',
+ entityId: 'tsk_1',
+ entityType: 'task' as never,
+ };
+ const created = { id: 'cmt_1', ...dto };
+ mockCommentsService.createComment.mockResolvedValue(created);
+
+ const result = await controller.createComment(
+ 'org_123',
+ mockAuthContext,
+ dto,
+ );
+
+ expect(commentsService.createComment).toHaveBeenCalledWith(
+ 'org_123',
+ 'usr_123',
+ dto,
+ );
+ expect(result).toEqual(created);
+ });
+
+ it('should use dto.userId for API key auth', async () => {
+ const dto = {
+ content: 'New comment',
+ entityId: 'tsk_1',
+ entityType: 'task' as never,
+ userId: 'usr_api_user',
+ };
+ const created = { id: 'cmt_1', ...dto };
+ mockCommentsService.createComment.mockResolvedValue(created);
+
+ await controller.createComment('org_123', apiKeyAuthContext, dto);
+
+ expect(commentsService.createComment).toHaveBeenCalledWith(
+ 'org_123',
+ 'usr_api_user',
+ dto,
+ );
+ });
+
+ it('should throw BadRequestException when API key auth without userId', async () => {
+ const dto = {
+ content: 'New comment',
+ entityId: 'tsk_1',
+ entityType: 'task' as never,
+ };
+
+ await expect(
+ controller.createComment('org_123', apiKeyAuthContext, dto),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('should throw BadRequestException when session auth without userId', async () => {
+ const noUserContext: AuthContext = {
+ ...mockAuthContext,
+ isApiKey: false,
+ userId: undefined,
+ };
+ const dto = {
+ content: 'New comment',
+ entityId: 'tsk_1',
+ entityType: 'task' as never,
+ };
+
+ await expect(
+ controller.createComment('org_123', noUserContext, dto),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+
+ describe('updateComment', () => {
+ it('should call commentsService.updateComment with correct parameters for session auth', async () => {
+ const dto = { content: 'Updated content', contextUrl: 'https://example.com' };
+ const updated = { id: 'cmt_1', content: 'Updated content' };
+ mockCommentsService.updateComment.mockResolvedValue(updated);
+
+ const result = await controller.updateComment(
+ 'org_123',
+ mockAuthContext,
+ 'cmt_1',
+ dto,
+ );
+
+ expect(commentsService.updateComment).toHaveBeenCalledWith(
+ 'org_123',
+ 'cmt_1',
+ 'usr_123',
+ 'Updated content',
+ 'https://example.com',
+ );
+ expect(result).toEqual(updated);
+ });
+
+ it('should use dto.userId for API key auth on update', async () => {
+ const dto = { content: 'Updated', userId: 'usr_api_user' };
+ mockCommentsService.updateComment.mockResolvedValue({ id: 'cmt_1' });
+
+ await controller.updateComment(
+ 'org_123',
+ apiKeyAuthContext,
+ 'cmt_1',
+ dto,
+ );
+
+ expect(commentsService.updateComment).toHaveBeenCalledWith(
+ 'org_123',
+ 'cmt_1',
+ 'usr_api_user',
+ 'Updated',
+ undefined,
+ );
+ });
+
+ it('should throw BadRequestException when API key auth without userId on update', async () => {
+ const dto = { content: 'Updated' };
+
+ await expect(
+ controller.updateComment('org_123', apiKeyAuthContext, 'cmt_1', dto),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+
+ describe('deleteComment', () => {
+ it('should call commentsService.deleteComment and return success for session auth', async () => {
+ mockCommentsService.deleteComment.mockResolvedValue(undefined);
+
+ const result = await controller.deleteComment(
+ 'org_123',
+ mockAuthContext,
+ 'cmt_1',
+ {},
+ );
+
+ expect(commentsService.deleteComment).toHaveBeenCalledWith(
+ 'org_123',
+ 'cmt_1',
+ 'usr_123',
+ );
+ expect(result).toEqual({
+ success: true,
+ deletedCommentId: 'cmt_1',
+ message: 'Comment deleted successfully',
+ });
+ });
+
+ it('should use dto.userId for API key auth on delete', async () => {
+ mockCommentsService.deleteComment.mockResolvedValue(undefined);
+
+ await controller.deleteComment('org_123', apiKeyAuthContext, 'cmt_1', {
+ userId: 'usr_api_user',
+ });
+
+ expect(commentsService.deleteComment).toHaveBeenCalledWith(
+ 'org_123',
+ 'cmt_1',
+ 'usr_api_user',
+ );
+ });
+
+ it('should throw BadRequestException when API key auth without userId on delete', async () => {
+ await expect(
+ controller.deleteComment('org_123', apiKeyAuthContext, 'cmt_1', {}),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+});
diff --git a/apps/api/src/comments/comments.controller.ts b/apps/api/src/comments/comments.controller.ts
index c2d9f5937b..7329aa6341 100644
--- a/apps/api/src/comments/comments.controller.ts
+++ b/apps/api/src/comments/comments.controller.ts
@@ -13,7 +13,6 @@ import {
} from '@nestjs/common';
import {
ApiBody,
- ApiHeader,
ApiOperation,
ApiParam,
ApiQuery,
@@ -23,6 +22,8 @@ import {
} from '@nestjs/swagger';
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 { CommentsService } from './comments.service';
import { CommentResponseDto } from './dto/comment-responses.dto';
@@ -32,18 +33,13 @@ import { UpdateCommentDto } from './dto/update-comment.dto';
@ApiTags('Comments')
@Controller({ path: 'comments', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class CommentsController {
constructor(private readonly commentsService: CommentsService) {}
@Get()
+ @RequirePermission('task', 'read')
@ApiOperation({
summary: 'Get comments for an entity',
description:
@@ -78,6 +74,7 @@ export class CommentsController {
}
@Post()
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Create a new comment',
description: 'Create a comment on an entity with optional file attachments',
@@ -119,6 +116,7 @@ export class CommentsController {
}
@Put(':commentId')
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Update a comment',
description: 'Update the content of an existing comment (author only)',
@@ -168,6 +166,7 @@ export class CommentsController {
}
@Delete(':commentId')
+ @RequirePermission('task', 'update')
@ApiOperation({
summary: 'Delete a comment',
description: 'Delete a comment and all its attachments (author only)',
diff --git a/apps/api/src/config/better-auth.config.ts b/apps/api/src/config/better-auth.config.ts
index 1df4de48a5..b7d87a7531 100644
--- a/apps/api/src/config/better-auth.config.ts
+++ b/apps/api/src/config/better-auth.config.ts
@@ -2,18 +2,29 @@ import { registerAs } from '@nestjs/config';
import { z } from 'zod';
const betterAuthConfigSchema = z.object({
- url: z.string().url('BETTER_AUTH_URL must be a valid URL'),
+ url: z.string().url('AUTH_BASE_URL must be a valid URL'),
});
export type BetterAuthConfig = z.infer;
+/**
+ * Better Auth configuration for the API.
+ *
+ * Since the API now runs the auth server, AUTH_BASE_URL should point to the API itself.
+ * For example:
+ * - Production: https://api.trycomp.ai
+ * - Staging: https://api.staging.trycomp.ai
+ * - Development: http://localhost:3333
+ */
export const betterAuthConfig = registerAs(
'betterAuth',
(): BetterAuthConfig => {
- const url = process.env.BETTER_AUTH_URL;
+ // AUTH_BASE_URL is the URL of the auth server (which is now the API)
+ // Fall back to BETTER_AUTH_URL for backwards compatibility during migration
+ const url = process.env.AUTH_BASE_URL || process.env.BETTER_AUTH_URL;
if (!url) {
- throw new Error('BETTER_AUTH_URL environment variable is required');
+ throw new Error('AUTH_BASE_URL or BETTER_AUTH_URL environment variable is required');
}
const config = { url };
diff --git a/apps/api/src/context/context.controller.spec.ts b/apps/api/src/context/context.controller.spec.ts
new file mode 100644
index 0000000000..6c48750e17
--- /dev/null
+++ b/apps/api/src/context/context.controller.spec.ts
@@ -0,0 +1,226 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext } from '../auth/types';
+import { ContextController } from './context.controller';
+import { ContextService } from './context.service';
+
+// Mock auth.server to avoid importing better-auth ESM in Jest
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {},
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('ContextController', () => {
+ let controller: ContextController;
+ let contextService: jest.Mocked;
+
+ const mockContextService = {
+ findAllByOrganization: jest.fn(),
+ findById: jest.fn(),
+ create: jest.fn(),
+ updateById: jest.fn(),
+ deleteById: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'usr_123',
+ userEmail: 'test@example.com',
+ userRoles: ['admin'],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [ContextController],
+ providers: [{ provide: ContextService, useValue: mockContextService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(ContextController);
+ contextService = module.get(ContextService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getAllContext', () => {
+ it('should call contextService.findAllByOrganization with organizationId and options', async () => {
+ const serviceResult = {
+ data: [{ id: 'ctx_1', question: 'What is SOC2?' }],
+ count: 1,
+ };
+ mockContextService.findAllByOrganization.mockResolvedValue(serviceResult);
+
+ const result = await controller.getAllContext(
+ 'org_123',
+ mockAuthContext,
+ 'SOC2',
+ '1',
+ '10',
+ );
+
+ expect(contextService.findAllByOrganization).toHaveBeenCalledWith(
+ 'org_123',
+ { search: 'SOC2', page: 1, perPage: 10 },
+ );
+ expect(result).toEqual({
+ ...serviceResult,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+
+ it('should pass undefined for optional query params when not provided', async () => {
+ mockContextService.findAllByOrganization.mockResolvedValue({
+ data: [],
+ count: 0,
+ });
+
+ await controller.getAllContext(
+ 'org_123',
+ mockAuthContext,
+ undefined,
+ undefined,
+ undefined,
+ );
+
+ expect(contextService.findAllByOrganization).toHaveBeenCalledWith(
+ 'org_123',
+ { search: undefined, page: undefined, perPage: undefined },
+ );
+ });
+
+ it('should not include authenticatedUser when userId is missing', async () => {
+ const noUserContext: AuthContext = {
+ ...mockAuthContext,
+ userId: undefined,
+ userEmail: undefined,
+ };
+ mockContextService.findAllByOrganization.mockResolvedValue({
+ data: [],
+ count: 0,
+ });
+
+ const result = await controller.getAllContext(
+ 'org_123',
+ noUserContext,
+ undefined,
+ undefined,
+ undefined,
+ );
+
+ expect(result).toEqual({
+ data: [],
+ count: 0,
+ authType: 'session',
+ });
+ });
+ });
+
+ describe('getContextById', () => {
+ it('should call contextService.findById with id and organizationId', async () => {
+ const contextEntry = {
+ id: 'ctx_1',
+ question: 'What is SOC2?',
+ answer: 'A compliance framework',
+ };
+ mockContextService.findById.mockResolvedValue(contextEntry);
+
+ const result = await controller.getContextById(
+ 'ctx_1',
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(contextService.findById).toHaveBeenCalledWith('ctx_1', 'org_123');
+ expect(result).toEqual({
+ ...contextEntry,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('createContext', () => {
+ it('should call contextService.create with organizationId and dto', async () => {
+ const dto = { question: 'New question', answer: 'New answer' };
+ const created = { id: 'ctx_2', ...dto };
+ mockContextService.create.mockResolvedValue(created);
+
+ const result = await controller.createContext(
+ dto as never,
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(contextService.create).toHaveBeenCalledWith('org_123', dto);
+ expect(result).toEqual({
+ ...created,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('updateContext', () => {
+ it('should call contextService.updateById with id, organizationId, and dto', async () => {
+ const dto = { answer: 'Updated answer' };
+ const updated = { id: 'ctx_1', question: 'What is SOC2?', answer: 'Updated answer' };
+ mockContextService.updateById.mockResolvedValue(updated);
+
+ const result = await controller.updateContext(
+ 'ctx_1',
+ dto as never,
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(contextService.updateById).toHaveBeenCalledWith(
+ 'ctx_1',
+ 'org_123',
+ dto,
+ );
+ expect(result).toEqual({
+ ...updated,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('deleteContext', () => {
+ it('should call contextService.deleteById with id and organizationId', async () => {
+ const deleteResult = { success: true, message: 'Context deleted' };
+ mockContextService.deleteById.mockResolvedValue(deleteResult);
+
+ const result = await controller.deleteContext(
+ 'ctx_1',
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(contextService.deleteById).toHaveBeenCalledWith(
+ 'ctx_1',
+ 'org_123',
+ );
+ expect(result).toEqual({
+ ...deleteResult,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+});
diff --git a/apps/api/src/context/context.controller.ts b/apps/api/src/context/context.controller.ts
index c423fb7808..13f1e2108a 100644
--- a/apps/api/src/context/context.controller.ts
+++ b/apps/api/src/context/context.controller.ts
@@ -6,19 +6,22 @@ import {
Delete,
Body,
Param,
+ Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBody,
- ApiHeader,
ApiOperation,
ApiParam,
+ ApiQuery,
ApiResponse,
ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
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 { CreateContextDto } from './dto/create-context.dto';
import { UpdateContextDto } from './dto/update-context.dto';
@@ -34,19 +37,17 @@ import { DELETE_CONTEXT_RESPONSES } from './schemas/delete-context.responses';
@ApiTags('Context')
@Controller({ path: 'context', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class ContextController {
constructor(private readonly contextService: ContextService) {}
@Get()
+ @RequirePermission('evidence', 'read')
@ApiOperation(CONTEXT_OPERATIONS.getAllContext)
+ @ApiQuery({ name: 'search', required: false, description: 'Search by question text' })
+ @ApiQuery({ name: 'page', required: false, description: 'Page number (1-based)' })
+ @ApiQuery({ name: 'perPage', required: false, description: 'Items per page' })
@ApiResponse(GET_ALL_CONTEXT_RESPONSES[200])
@ApiResponse(GET_ALL_CONTEXT_RESPONSES[401])
@ApiResponse(GET_ALL_CONTEXT_RESPONSES[404])
@@ -54,13 +55,17 @@ export class ContextController {
async getAllContext(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
+ @Query('search') search?: string,
+ @Query('page') page?: string,
+ @Query('perPage') perPage?: string,
) {
- const contextEntries =
- await this.contextService.findAllByOrganization(organizationId);
+ const result = await this.contextService.findAllByOrganization(
+ organizationId,
+ { search, page: page ? parseInt(page, 10) : undefined, perPage: perPage ? parseInt(perPage, 10) : undefined },
+ );
return {
- data: contextEntries,
- count: contextEntries.length,
+ ...result,
authType: authContext.authType,
...(authContext.userId &&
authContext.userEmail && {
@@ -73,6 +78,7 @@ export class ContextController {
}
@Get(':id')
+ @RequirePermission('evidence', 'read')
@ApiOperation(CONTEXT_OPERATIONS.getContextById)
@ApiParam(CONTEXT_PARAMS.contextId)
@ApiResponse(GET_CONTEXT_BY_ID_RESPONSES[200])
@@ -103,6 +109,7 @@ export class ContextController {
}
@Post()
+ @RequirePermission('evidence', 'create')
@ApiOperation(CONTEXT_OPERATIONS.createContext)
@ApiBody(CONTEXT_BODIES.createContext)
@ApiResponse(CREATE_CONTEXT_RESPONSES[201])
@@ -134,6 +141,7 @@ export class ContextController {
}
@Patch(':id')
+ @RequirePermission('evidence', 'update')
@ApiOperation(CONTEXT_OPERATIONS.updateContext)
@ApiParam(CONTEXT_PARAMS.contextId)
@ApiBody(CONTEXT_BODIES.updateContext)
@@ -168,6 +176,7 @@ export class ContextController {
}
@Delete(':id')
+ @RequirePermission('evidence', 'delete')
@ApiOperation(CONTEXT_OPERATIONS.deleteContext)
@ApiParam(CONTEXT_PARAMS.contextId)
@ApiResponse(DELETE_CONTEXT_RESPONSES[200])
diff --git a/apps/api/src/context/context.service.ts b/apps/api/src/context/context.service.ts
index 74f519b39a..6fb771552b 100644
--- a/apps/api/src/context/context.service.ts
+++ b/apps/api/src/context/context.service.ts
@@ -7,17 +7,52 @@ import { UpdateContextDto } from './dto/update-context.dto';
export class ContextService {
private readonly logger = new Logger(ContextService.name);
- async findAllByOrganization(organizationId: string) {
+ async findAllByOrganization(
+ organizationId: string,
+ options?: { search?: string; page?: number; perPage?: number },
+ ) {
try {
+ const where: any = {
+ organizationId,
+ ...(options?.search && {
+ question: { contains: options.search, mode: 'insensitive' },
+ }),
+ };
+
+ if (options?.page && options?.perPage) {
+ const skip = (options.page - 1) * options.perPage;
+ const [entries, total] = await Promise.all([
+ db.context.findMany({
+ where,
+ skip,
+ take: options.perPage,
+ orderBy: { createdAt: 'desc' },
+ }),
+ db.context.count({ where }),
+ ]);
+
+ const pageCount = Math.ceil(total / options.perPage);
+
+ // Resolve any legacy framework IDs in answers
+ const resolvedEntries = await this.resolveFrameworkIds(entries);
+
+ this.logger.log(
+ `Retrieved ${entries.length} context entries (page ${options.page}) for organization ${organizationId}`,
+ );
+ return { data: resolvedEntries, count: total, pageCount };
+ }
+
const contextEntries = await db.context.findMany({
- where: { organizationId },
+ where,
orderBy: { createdAt: 'desc' },
});
+ const resolvedEntries = await this.resolveFrameworkIds(contextEntries);
+
this.logger.log(
`Retrieved ${contextEntries.length} context entries for organization ${organizationId}`,
);
- return contextEntries;
+ return { data: resolvedEntries, count: resolvedEntries.length };
} catch (error) {
this.logger.error(
`Failed to retrieve context entries for organization ${organizationId}:`,
@@ -27,6 +62,36 @@ export class ContextService {
}
}
+ private readonly FRAMEWORK_ID_PATTERN = /\bfrk_[a-z0-9]+\b/g;
+
+ private async resolveFrameworkIds(
+ entries: T[],
+ ): Promise {
+ const allIds = new Set();
+ for (const entry of entries) {
+ const matches = entry.answer.match(this.FRAMEWORK_ID_PATTERN);
+ if (matches) {
+ for (const id of matches) allIds.add(id);
+ }
+ }
+ if (allIds.size === 0) return entries;
+
+ const frameworks = await db.frameworkEditorFramework.findMany({
+ where: { id: { in: Array.from(allIds) } },
+ select: { id: true, name: true },
+ });
+ const idToName = new Map(frameworks.map((f) => [f.id, f.name]));
+
+ return entries.map((entry) => {
+ const resolved = entry.answer.replace(
+ this.FRAMEWORK_ID_PATTERN,
+ (id) => idToName.get(id) ?? id,
+ );
+ if (resolved === entry.answer) return entry;
+ return { ...entry, answer: resolved };
+ });
+ }
+
async findById(id: string, organizationId: string) {
try {
const contextEntry = await db.context.findFirst({
diff --git a/apps/api/src/context/schemas/context-operations.ts b/apps/api/src/context/schemas/context-operations.ts
index b2e4883538..5dd4c9e9d0 100644
--- a/apps/api/src/context/schemas/context-operations.ts
+++ b/apps/api/src/context/schemas/context-operations.ts
@@ -4,26 +4,26 @@ export const CONTEXT_OPERATIONS: Record = {
getAllContext: {
summary: 'Get all context entries',
description:
- 'Returns all context entries for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns all context entries for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
getContextById: {
summary: 'Get context entry by ID',
description:
- 'Returns a specific context entry by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns a specific context entry by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
createContext: {
summary: 'Create a new context entry',
description:
- 'Creates a new context entry for the authenticated organization. All required fields must be provided. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Creates a new context entry for the authenticated organization. All required fields must be provided. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
updateContext: {
summary: 'Update context entry',
description:
- 'Partially updates a context entry. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Partially updates a context entry. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
deleteContext: {
summary: 'Delete context entry',
description:
- 'Permanently removes a context entry from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Permanently removes a context entry from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
};
diff --git a/apps/api/src/controls/controls.controller.spec.ts b/apps/api/src/controls/controls.controller.spec.ts
new file mode 100644
index 0000000000..e82bd8229b
--- /dev/null
+++ b/apps/api/src/controls/controls.controller.spec.ts
@@ -0,0 +1,181 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { NotFoundException } from '@nestjs/common';
+import { ControlsController } from './controls.controller';
+import { ControlsService } from './controls.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { CreateControlDto } from './dto/create-control.dto';
+
+// Mock auth.server to avoid importing better-auth ESM in Jest
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ control: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('ControlsController', () => {
+ let controller: ControlsController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ findAll: jest.fn(),
+ findOne: jest.fn(),
+ getOptions: jest.fn(),
+ create: jest.fn(),
+ delete: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [ControlsController],
+ providers: [{ provide: ControlsService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(ControlsController);
+ service = module.get(ControlsService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('findAll', () => {
+ it('should call service.findAll with default pagination', async () => {
+ const mockData = { data: [{ id: 'ctrl_1' }], count: 1 };
+ mockService.findAll.mockResolvedValue(mockData);
+
+ const result = await controller.findAll('org_1');
+
+ expect(result).toEqual(mockData);
+ expect(service.findAll).toHaveBeenCalledWith('org_1', {
+ page: 1,
+ perPage: 50,
+ name: undefined,
+ sortBy: undefined,
+ sortDesc: false,
+ });
+ });
+
+ it('should pass parsed pagination parameters', async () => {
+ const mockData = { data: [], count: 0 };
+ mockService.findAll.mockResolvedValue(mockData);
+
+ await controller.findAll('org_1', '2', '25');
+
+ expect(service.findAll).toHaveBeenCalledWith('org_1', {
+ page: 2,
+ perPage: 25,
+ name: undefined,
+ sortBy: undefined,
+ sortDesc: false,
+ });
+ });
+
+ it('should pass name filter and sort parameters', async () => {
+ const mockData = { data: [], count: 0 };
+ mockService.findAll.mockResolvedValue(mockData);
+
+ await controller.findAll('org_1', '1', '50', 'access', 'name', 'true');
+
+ expect(service.findAll).toHaveBeenCalledWith('org_1', {
+ page: 1,
+ perPage: 50,
+ name: 'access',
+ sortBy: 'name',
+ sortDesc: true,
+ });
+ });
+
+ it('should parse sortDesc as false when not "true"', async () => {
+ mockService.findAll.mockResolvedValue({ data: [], count: 0 });
+
+ await controller.findAll('org_1', undefined, undefined, undefined, undefined, 'false');
+
+ expect(service.findAll).toHaveBeenCalledWith('org_1', {
+ page: 1,
+ perPage: 50,
+ name: undefined,
+ sortBy: undefined,
+ sortDesc: false,
+ });
+ });
+ });
+
+ describe('getOptions', () => {
+ it('should call service.getOptions with organizationId', async () => {
+ const mockOptions = { frameworks: [], categories: [] };
+ mockService.getOptions.mockResolvedValue(mockOptions);
+
+ const result = await controller.getOptions('org_1');
+
+ expect(result).toEqual(mockOptions);
+ expect(service.getOptions).toHaveBeenCalledWith('org_1');
+ });
+ });
+
+ describe('findOne', () => {
+ it('should call service.findOne with id and organizationId', async () => {
+ const mockControl = { id: 'ctrl_1', name: 'Test Control' };
+ mockService.findOne.mockResolvedValue(mockControl);
+
+ const result = await controller.findOne('org_1', 'ctrl_1');
+
+ expect(result).toEqual(mockControl);
+ expect(service.findOne).toHaveBeenCalledWith('ctrl_1', 'org_1');
+ });
+
+ it('should propagate NotFoundException from service', async () => {
+ mockService.findOne.mockRejectedValue(
+ new NotFoundException('Control not found'),
+ );
+
+ await expect(controller.findOne('org_1', 'missing')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+
+ describe('create', () => {
+ it('should call service.create with organizationId and dto', async () => {
+ const dto: CreateControlDto = { name: 'New Control', description: 'A test control' };
+ const mockCreated = { id: 'ctrl_new', name: 'New Control', description: 'A test control' };
+ mockService.create.mockResolvedValue(mockCreated);
+
+ const result = await controller.create('org_1', dto);
+
+ expect(result).toEqual(mockCreated);
+ expect(service.create).toHaveBeenCalledWith('org_1', dto);
+ });
+ });
+
+ describe('delete', () => {
+ it('should call service.delete with id and organizationId', async () => {
+ mockService.delete.mockResolvedValue({ success: true });
+
+ const result = await controller.delete('org_1', 'ctrl_1');
+
+ expect(result).toEqual({ success: true });
+ expect(service.delete).toHaveBeenCalledWith('ctrl_1', 'org_1');
+ });
+
+ it('should propagate NotFoundException from service', async () => {
+ mockService.delete.mockRejectedValue(
+ new NotFoundException('Control not found'),
+ );
+
+ await expect(controller.delete('org_1', 'missing')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+});
diff --git a/apps/api/src/controls/controls.controller.ts b/apps/api/src/controls/controls.controller.ts
new file mode 100644
index 0000000000..605e814d4f
--- /dev/null
+++ b/apps/api/src/controls/controls.controller.ts
@@ -0,0 +1,87 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Query,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
+import { OrganizationId } from '../auth/auth-context.decorator';
+import { ControlsService } from './controls.service';
+import { CreateControlDto } from './dto/create-control.dto';
+
+@ApiTags('Controls')
+@ApiBearerAuth()
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@Controller({ path: 'controls', version: '1' })
+export class ControlsController {
+ constructor(private readonly controlsService: ControlsService) {}
+
+ @Get()
+ @RequirePermission('control', 'read')
+ @ApiOperation({ summary: 'List controls with relations' })
+ @ApiQuery({ name: 'page', required: false })
+ @ApiQuery({ name: 'perPage', required: false })
+ @ApiQuery({ name: 'name', required: false, description: 'Filter by name (case-insensitive contains)' })
+ @ApiQuery({ name: 'sortBy', required: false, description: 'Field to sort by (default: name)' })
+ @ApiQuery({ name: 'sortDesc', required: false, description: 'Sort descending (true/false)' })
+ async findAll(
+ @OrganizationId() organizationId: string,
+ @Query('page') page?: string,
+ @Query('perPage') perPage?: string,
+ @Query('name') name?: string,
+ @Query('sortBy') sortBy?: string,
+ @Query('sortDesc') sortDesc?: string,
+ ) {
+ return this.controlsService.findAll(organizationId, {
+ page: page ? parseInt(page, 10) : 1,
+ perPage: perPage ? parseInt(perPage, 10) : 50,
+ name,
+ sortBy,
+ sortDesc: sortDesc === 'true',
+ });
+ }
+
+ @Get('options')
+ @RequirePermission('control', 'read')
+ @ApiOperation({ summary: 'Get dropdown options for creating controls' })
+ async getOptions(@OrganizationId() organizationId: string) {
+ return this.controlsService.getOptions(organizationId);
+ }
+
+ @Get(':id')
+ @RequirePermission('control', 'read')
+ @ApiOperation({ summary: 'Get control detail with progress' })
+ async findOne(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ return this.controlsService.findOne(id, organizationId);
+ }
+
+ @Post()
+ @RequirePermission('control', 'create')
+ @ApiOperation({ summary: 'Create a new control' })
+ async create(
+ @OrganizationId() organizationId: string,
+ @Body() dto: CreateControlDto,
+ ) {
+ return this.controlsService.create(organizationId, dto);
+ }
+
+ @Delete(':id')
+ @RequirePermission('control', 'delete')
+ @ApiOperation({ summary: 'Delete a control' })
+ async delete(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ return this.controlsService.delete(id, organizationId);
+ }
+}
diff --git a/apps/api/src/controls/controls.module.ts b/apps/api/src/controls/controls.module.ts
new file mode 100644
index 0000000000..67f1e9115b
--- /dev/null
+++ b/apps/api/src/controls/controls.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { ControlsController } from './controls.controller';
+import { ControlsService } from './controls.service';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [ControlsController],
+ providers: [ControlsService],
+ exports: [ControlsService],
+})
+export class ControlsModule {}
diff --git a/apps/api/src/controls/controls.service.ts b/apps/api/src/controls/controls.service.ts
new file mode 100644
index 0000000000..7950c40416
--- /dev/null
+++ b/apps/api/src/controls/controls.service.ts
@@ -0,0 +1,216 @@
+import {
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { db, Prisma } from '@trycompai/db';
+import { CreateControlDto } from './dto/create-control.dto';
+
+const controlInclude = {
+ policies: {
+ select: { status: true, id: true, name: true },
+ },
+ tasks: {
+ select: { id: true, title: true, status: true },
+ },
+ requirementsMapped: {
+ include: {
+ frameworkInstance: {
+ include: { framework: true },
+ },
+ requirement: {
+ select: { name: true, identifier: true },
+ },
+ },
+ },
+} satisfies Prisma.ControlInclude;
+
+@Injectable()
+export class ControlsService {
+ async findAll(
+ organizationId: string,
+ options: {
+ page: number;
+ perPage: number;
+ name?: string;
+ sortBy?: string;
+ sortDesc?: boolean;
+ },
+ ) {
+ const where: Prisma.ControlWhereInput = {
+ organizationId,
+ ...(options.name && {
+ name: { contains: options.name, mode: Prisma.QueryMode.insensitive },
+ }),
+ };
+
+ const orderBy: any = options.sortBy
+ ? { [options.sortBy]: options.sortDesc ? 'desc' : 'asc' }
+ : { name: 'asc' };
+
+ const [controls, total] = await Promise.all([
+ db.control.findMany({
+ where,
+ orderBy,
+ skip: (options.page - 1) * options.perPage,
+ take: options.perPage,
+ include: controlInclude,
+ }),
+ db.control.count({ where }),
+ ]);
+
+ return {
+ data: controls,
+ pageCount: Math.ceil(total / options.perPage),
+ };
+ }
+
+ async findOne(controlId: string, organizationId: string) {
+ const control = await db.control.findUnique({
+ where: { id: controlId, organizationId },
+ include: {
+ policies: true,
+ tasks: true,
+ requirementsMapped: {
+ include: {
+ frameworkInstance: {
+ include: { framework: true },
+ },
+ requirement: true,
+ },
+ },
+ },
+ });
+
+ if (!control) {
+ throw new NotFoundException('Control not found');
+ }
+
+ // Compute progress
+ const policies = control.policies || [];
+ const tasks = control.tasks || [];
+ const totalItems = policies.length + tasks.length;
+
+ let policyCompleted = 0;
+ let taskCompleted = 0;
+
+ for (const p of policies) {
+ if (p.status === 'published') policyCompleted++;
+ }
+ for (const t of tasks) {
+ if (t.status === 'done' || t.status === 'not_relevant') taskCompleted++;
+ }
+
+ const completed = policyCompleted + taskCompleted;
+
+ return {
+ ...control,
+ progress: {
+ total: totalItems,
+ completed,
+ progress: totalItems > 0 ? Math.round((completed / totalItems) * 100) : 0,
+ byType: {
+ policy: { total: policies.length, completed: policyCompleted },
+ task: { total: tasks.length, completed: taskCompleted },
+ },
+ },
+ };
+ }
+
+ async getOptions(organizationId: string) {
+ const [policies, tasks, frameworkInstances] = await Promise.all([
+ db.policy.findMany({
+ where: { organizationId },
+ select: { id: true, name: true },
+ orderBy: { name: 'asc' },
+ }),
+ db.task.findMany({
+ where: { organizationId },
+ select: { id: true, title: true },
+ orderBy: { title: 'asc' },
+ }),
+ db.frameworkInstance.findMany({
+ where: { organizationId },
+ include: {
+ framework: {
+ include: {
+ requirements: {
+ select: { id: true, name: true, identifier: true },
+ },
+ },
+ },
+ },
+ }),
+ ]);
+
+ const requirements = frameworkInstances.flatMap((fi) =>
+ fi.framework.requirements.map((req) => ({
+ id: req.id,
+ name: req.name,
+ identifier: req.identifier,
+ frameworkInstanceId: fi.id,
+ frameworkName: fi.framework.name,
+ })),
+ );
+
+ return { policies, tasks, requirements };
+ }
+
+ async create(organizationId: string, dto: CreateControlDto) {
+ const { name, description, policyIds, taskIds, requirementMappings } = dto;
+
+ const control = await db.control.create({
+ data: {
+ name,
+ description,
+ organizationId,
+ ...(policyIds &&
+ policyIds.length > 0 && {
+ policies: {
+ connect: policyIds.map((id) => ({ id })),
+ },
+ }),
+ ...(taskIds &&
+ taskIds.length > 0 && {
+ tasks: {
+ connect: taskIds.map((id) => ({ id })),
+ },
+ }),
+ },
+ });
+
+ if (requirementMappings && requirementMappings.length > 0) {
+ await Promise.all(
+ requirementMappings.map((mapping) =>
+ db.requirementMap.create({
+ data: {
+ controlId: control.id,
+ requirementId: mapping.requirementId,
+ frameworkInstanceId: mapping.frameworkInstanceId,
+ },
+ }),
+ ),
+ );
+ }
+
+ return control;
+ }
+
+ async delete(controlId: string, organizationId: string) {
+ const control = await db.control.findUnique({
+ where: {
+ id: controlId,
+ organizationId,
+ },
+ });
+
+ if (!control) {
+ throw new NotFoundException('Control not found');
+ }
+
+ await db.control.delete({
+ where: { id: controlId },
+ });
+
+ return { success: true };
+ }
+}
diff --git a/apps/api/src/controls/dto/create-control.dto.ts b/apps/api/src/controls/dto/create-control.dto.ts
new file mode 100644
index 0000000000..b899ce6180
--- /dev/null
+++ b/apps/api/src/controls/dto/create-control.dto.ts
@@ -0,0 +1,63 @@
+import { ApiProperty } from '@nestjs/swagger';
+import {
+ IsString,
+ IsOptional,
+ IsArray,
+ IsNotEmpty,
+ ValidateNested,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+
+class RequirementMappingDto {
+ @ApiProperty({ description: 'Requirement ID' })
+ @IsString()
+ requirementId: string;
+
+ @ApiProperty({ description: 'Framework instance ID' })
+ @IsString()
+ frameworkInstanceId: string;
+}
+
+export class CreateControlDto {
+ @ApiProperty({ description: 'Control name', example: 'Access Control' })
+ @IsString()
+ @IsNotEmpty()
+ name: string;
+
+ @ApiProperty({
+ description: 'Control description',
+ example: 'Manages user access to systems',
+ })
+ @IsString()
+ @IsNotEmpty()
+ description: string;
+
+ @ApiProperty({
+ description: 'Policy IDs to connect',
+ required: false,
+ })
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ policyIds?: string[];
+
+ @ApiProperty({
+ description: 'Task IDs to connect',
+ required: false,
+ })
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ taskIds?: string[];
+
+ @ApiProperty({
+ description: 'Requirement mappings',
+ required: false,
+ type: [RequirementMappingDto],
+ })
+ @IsOptional()
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => RequirementMappingDto)
+ requirementMappings?: RequirementMappingDto[];
+}
diff --git a/apps/api/src/device-agent/device-agent.controller.spec.ts b/apps/api/src/device-agent/device-agent.controller.spec.ts
new file mode 100644
index 0000000000..97bcbb56d5
--- /dev/null
+++ b/apps/api/src/device-agent/device-agent.controller.spec.ts
@@ -0,0 +1,154 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { StreamableFile } from '@nestjs/common';
+import { DeviceAgentController } from './device-agent.controller';
+import { DeviceAgentService } from './device-agent.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext as AuthContextType } from '../auth/types';
+import { Readable } from 'stream';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ app: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('DeviceAgentController', () => {
+ let controller: DeviceAgentController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ downloadMacAgent: jest.fn(),
+ downloadWindowsAgent: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContextType = {
+ organizationId: 'org_1',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'user_1',
+ userEmail: 'test@example.com',
+ userRoles: ['admin'],
+ };
+
+ const mockRes = {
+ set: jest.fn(),
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [DeviceAgentController],
+ providers: [{ provide: DeviceAgentService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(DeviceAgentController);
+ service = module.get(DeviceAgentService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('downloadMacAgent', () => {
+ it('should call service.downloadMacAgent and return StreamableFile', async () => {
+ const mockStream = Readable.from(Buffer.from('binary-content'));
+ mockService.downloadMacAgent.mockResolvedValue({
+ stream: mockStream,
+ filename: 'comp-agent-mac.pkg',
+ contentType: 'application/octet-stream',
+ });
+
+ const result = await controller.downloadMacAgent(
+ 'org_1',
+ mockAuthContext,
+ mockRes as never,
+ );
+
+ expect(service.downloadMacAgent).toHaveBeenCalled();
+ expect(result).toBeInstanceOf(StreamableFile);
+ expect(mockRes.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'Content-Type': 'application/octet-stream',
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ Pragma: 'no-cache',
+ Expires: '0',
+ }),
+ );
+ expect(mockRes.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'Content-Disposition': expect.stringContaining('comp-agent-mac.pkg'),
+ }),
+ );
+ });
+
+ it('should propagate errors from service', async () => {
+ mockService.downloadMacAgent.mockRejectedValue(
+ new Error('Agent not found'),
+ );
+
+ await expect(
+ controller.downloadMacAgent('org_1', mockAuthContext, mockRes as never),
+ ).rejects.toThrow('Agent not found');
+ });
+ });
+
+ describe('downloadWindowsAgent', () => {
+ it('should call service.downloadWindowsAgent and return StreamableFile', async () => {
+ const mockStream = Readable.from(Buffer.from('binary-content'));
+ mockService.downloadWindowsAgent.mockResolvedValue({
+ stream: mockStream,
+ filename: 'comp-agent-windows.exe',
+ contentType: 'application/octet-stream',
+ });
+
+ const result = await controller.downloadWindowsAgent(
+ 'org_1',
+ mockAuthContext,
+ mockRes as never,
+ );
+
+ expect(service.downloadWindowsAgent).toHaveBeenCalled();
+ expect(result).toBeInstanceOf(StreamableFile);
+ expect(mockRes.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'Content-Type': 'application/octet-stream',
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
+ Pragma: 'no-cache',
+ Expires: '0',
+ }),
+ );
+ expect(mockRes.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ 'Content-Disposition': expect.stringContaining(
+ 'comp-agent-windows.exe',
+ ),
+ }),
+ );
+ });
+
+ it('should propagate errors from service', async () => {
+ mockService.downloadWindowsAgent.mockRejectedValue(
+ new Error('Agent not found'),
+ );
+
+ await expect(
+ controller.downloadWindowsAgent(
+ 'org_1',
+ mockAuthContext,
+ mockRes as never,
+ ),
+ ).rejects.toThrow('Agent not found');
+ });
+ });
+});
diff --git a/apps/api/src/device-agent/device-agent.controller.ts b/apps/api/src/device-agent/device-agent.controller.ts
index 1b54c1ef6b..7b24ff74a7 100644
--- a/apps/api/src/device-agent/device-agent.controller.ts
+++ b/apps/api/src/device-agent/device-agent.controller.ts
@@ -6,7 +6,6 @@ import {
Response,
} from '@nestjs/common';
import {
- ApiHeader,
ApiOperation,
ApiResponse,
ApiSecurity,
@@ -14,6 +13,8 @@ import {
} from '@nestjs/swagger';
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 { DeviceAgentService } from './device-agent.service';
import { DEVICE_AGENT_OPERATIONS } from './schemas/device-agent-operations';
@@ -23,14 +24,9 @@ import type { Response as ExpressResponse } from 'express';
@ApiTags('Device Agent')
@Controller({ path: 'device-agent', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@RequirePermission('app', 'read')
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class DeviceAgentController {
constructor(private readonly deviceAgentService: DeviceAgentService) {}
diff --git a/apps/api/src/device-agent/device-agent.service.spec.ts b/apps/api/src/device-agent/device-agent.service.spec.ts
new file mode 100644
index 0000000000..19df4eba1e
--- /dev/null
+++ b/apps/api/src/device-agent/device-agent.service.spec.ts
@@ -0,0 +1,158 @@
+import {
+ NotFoundException,
+ InternalServerErrorException,
+} from '@nestjs/common';
+import { Readable } from 'stream';
+
+const mockSend = jest.fn();
+
+jest.mock('@aws-sdk/client-s3', () => ({
+ S3Client: jest.fn().mockImplementation(() => ({ send: mockSend })),
+ GetObjectCommand: jest.fn().mockImplementation((input) => input),
+}));
+
+import { DeviceAgentService } from './device-agent.service';
+
+describe('DeviceAgentService', () => {
+ let service: DeviceAgentService;
+
+ beforeAll(() => {
+ process.env.APP_AWS_BUCKET_NAME = 'test-bucket';
+ process.env.APP_AWS_REGION = 'us-east-1';
+ process.env.APP_AWS_ACCESS_KEY_ID = 'test-key';
+ process.env.APP_AWS_SECRET_ACCESS_KEY = 'test-secret';
+ });
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ service = new DeviceAgentService();
+ });
+
+ describe('downloadMacAgent', () => {
+ it('should return stream, filename, and contentType on success', async () => {
+ const mockStream = new Readable({ read() {} });
+ mockSend.mockResolvedValue({ Body: mockStream });
+
+ const result = await service.downloadMacAgent();
+
+ expect(result.stream).toBe(mockStream);
+ expect(result.filename).toBe('Comp AI Agent-1.0.0-arm64.dmg');
+ expect(result.contentType).toBe('application/x-apple-diskimage');
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Bucket: 'test-bucket',
+ Key: 'macos/Comp AI Agent-1.0.0-arm64.dmg',
+ }),
+ );
+ });
+
+ it('should throw NotFoundException when S3 returns no body', async () => {
+ mockSend.mockResolvedValue({ Body: undefined });
+
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ NotFoundException,
+ );
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ 'macOS agent DMG file not found in S3',
+ );
+ });
+
+ it('should throw NotFoundException when S3 throws NoSuchKey', async () => {
+ const error = new Error('Not found');
+ error.name = 'NoSuchKey';
+ mockSend.mockRejectedValue(error);
+
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ NotFoundException,
+ );
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ 'macOS agent file not found',
+ );
+ });
+
+ it('should throw NotFoundException when S3 throws NotFound', async () => {
+ const error = new Error('Not found');
+ error.name = 'NotFound';
+ mockSend.mockRejectedValue(error);
+
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+
+ it('should throw InternalServerErrorException on other S3 errors', async () => {
+ mockSend.mockRejectedValue(new Error('Network failure'));
+
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ InternalServerErrorException,
+ );
+ await expect(service.downloadMacAgent()).rejects.toThrow(
+ 'Failed to download macOS agent',
+ );
+ });
+ });
+
+ describe('downloadWindowsAgent', () => {
+ it('should return stream, filename, and contentType on success', async () => {
+ const mockStream = new Readable({ read() {} });
+ mockSend.mockResolvedValue({ Body: mockStream });
+
+ const result = await service.downloadWindowsAgent();
+
+ expect(result.stream).toBe(mockStream);
+ expect(result.filename).toBe('Comp AI Agent 1.0.0.exe');
+ expect(result.contentType).toBe('application/octet-stream');
+ expect(mockSend).toHaveBeenCalledWith(
+ expect.objectContaining({
+ Bucket: 'test-bucket',
+ Key: 'windows/Comp AI Agent 1.0.0.exe',
+ }),
+ );
+ });
+
+ it('should throw NotFoundException when S3 returns no body', async () => {
+ mockSend.mockResolvedValue({ Body: undefined });
+
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ NotFoundException,
+ );
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ 'Windows agent executable file not found in S3',
+ );
+ });
+
+ it('should throw NotFoundException when S3 throws NoSuchKey', async () => {
+ const error = new Error('Not found');
+ error.name = 'NoSuchKey';
+ mockSend.mockRejectedValue(error);
+
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ NotFoundException,
+ );
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ 'Windows agent file not found',
+ );
+ });
+
+ it('should throw NotFoundException when S3 throws NotFound', async () => {
+ const error = new Error('Not found');
+ error.name = 'NotFound';
+ mockSend.mockRejectedValue(error);
+
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+
+ it('should throw InternalServerErrorException on other S3 errors', async () => {
+ mockSend.mockRejectedValue(new Error('Network failure'));
+
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ InternalServerErrorException,
+ );
+ await expect(service.downloadWindowsAgent()).rejects.toThrow(
+ 'Failed to download Windows agent',
+ );
+ });
+ });
+});
diff --git a/apps/api/src/device-agent/device-agent.service.ts b/apps/api/src/device-agent/device-agent.service.ts
index 2543401429..019e01c828 100644
--- a/apps/api/src/device-agent/device-agent.service.ts
+++ b/apps/api/src/device-agent/device-agent.service.ts
@@ -1,4 +1,9 @@
-import { Injectable, NotFoundException, Logger } from '@nestjs/common';
+import {
+ Injectable,
+ InternalServerErrorException,
+ NotFoundException,
+ Logger,
+} from '@nestjs/common';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { Readable } from 'stream';
@@ -62,7 +67,13 @@ export class DeviceAgentService {
throw error;
}
this.logger.error('Failed to download macOS agent from S3:', error);
- throw error;
+ const s3Error = error as { name?: string };
+ if (s3Error.name === 'NoSuchKey' || s3Error.name === 'NotFound') {
+ throw new NotFoundException('macOS agent file not found');
+ }
+ throw new InternalServerErrorException(
+ 'Failed to download macOS agent. The agent file may not be available in this environment.',
+ );
}
}
@@ -107,7 +118,13 @@ export class DeviceAgentService {
throw error;
}
this.logger.error('Failed to download Windows agent from S3:', error);
- throw error;
+ const s3Error = error as { name?: string };
+ if (s3Error.name === 'NoSuchKey' || s3Error.name === 'NotFound') {
+ throw new NotFoundException('Windows agent file not found');
+ }
+ throw new InternalServerErrorException(
+ 'Failed to download Windows agent. The agent file may not be available in this environment.',
+ );
}
}
}
diff --git a/apps/api/src/device-agent/schemas/device-agent-operations.ts b/apps/api/src/device-agent/schemas/device-agent-operations.ts
index 778efcbfc9..e6b67d4f08 100644
--- a/apps/api/src/device-agent/schemas/device-agent-operations.ts
+++ b/apps/api/src/device-agent/schemas/device-agent-operations.ts
@@ -4,11 +4,11 @@ export const DEVICE_AGENT_OPERATIONS: Record = {
downloadMacAgent: {
summary: 'Download macOS Device Agent',
description:
- 'Downloads the Comp AI Device Agent installer for macOS as a DMG file. The agent helps monitor device compliance and security policies. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Downloads the Comp AI Device Agent installer for macOS as a DMG file. The agent helps monitor device compliance and security policies. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
downloadWindowsAgent: {
summary: 'Download Windows Device Agent ZIP',
description:
- 'Downloads a ZIP package containing the Comp AI Device Agent installer for Windows, along with setup scripts and instructions. The package includes an MSI installer, setup batch script customized for the organization and user, and a README with installation instructions. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Downloads a ZIP package containing the Comp AI Device Agent installer for Windows, along with setup scripts and instructions. The package includes an MSI installer, setup batch script customized for the organization and user, and a README with installation instructions. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
};
diff --git a/apps/api/src/devices/devices.controller.spec.ts b/apps/api/src/devices/devices.controller.spec.ts
new file mode 100644
index 0000000000..be69031100
--- /dev/null
+++ b/apps/api/src/devices/devices.controller.spec.ts
@@ -0,0 +1,185 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { DevicesController } from './devices.controller';
+import { DevicesService } from './devices.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext as AuthContextType } from '../auth/types';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ app: ['read'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('DevicesController', () => {
+ let controller: DevicesController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ findAllByOrganization: jest.fn(),
+ findAllByMember: jest.fn(),
+ getMemberById: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContextType = {
+ authType: 'session' as const,
+ userId: 'usr_1',
+ userEmail: 'user@example.com',
+ organizationId: 'org_1',
+ memberId: 'mem_1',
+ permissions: [],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [DevicesController],
+ providers: [{ provide: DevicesService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(DevicesController);
+ service = module.get(DevicesService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getAllDevices', () => {
+ it('should return devices with count and auth info', async () => {
+ const mockDevices = [
+ { id: 'dev_1', name: 'MacBook Pro' },
+ { id: 'dev_2', name: 'iPhone 15' },
+ ];
+ mockService.findAllByOrganization.mockResolvedValue(mockDevices);
+
+ const result = await controller.getAllDevices('org_1', mockAuthContext);
+
+ expect(result).toEqual({
+ data: mockDevices,
+ count: 2,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_1', email: 'user@example.com' },
+ });
+ expect(service.findAllByOrganization).toHaveBeenCalledWith('org_1');
+ });
+
+ it('should return empty array when no devices found', async () => {
+ mockService.findAllByOrganization.mockResolvedValue([]);
+
+ const result = await controller.getAllDevices('org_1', mockAuthContext);
+
+ expect(result).toEqual({
+ data: [],
+ count: 0,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_1', email: 'user@example.com' },
+ });
+ });
+
+ it('should not include authenticatedUser when userId or email is absent', async () => {
+ mockService.findAllByOrganization.mockResolvedValue([]);
+
+ const authContextNoUser: AuthContextType = {
+ authType: 'api-key' as const,
+ organizationId: 'org_1',
+ permissions: [],
+ } as AuthContextType;
+
+ const result = await controller.getAllDevices('org_1', authContextNoUser);
+
+ expect(result).toEqual({
+ data: [],
+ count: 0,
+ authType: 'api-key',
+ });
+ });
+ });
+
+ describe('getDevicesByMember', () => {
+ it('should return devices and member info for given memberId', async () => {
+ const mockDevices = [{ id: 'dev_1', name: 'MacBook Pro' }];
+ const mockMember = { id: 'mem_1', name: 'John Doe' };
+ mockService.findAllByMember.mockResolvedValue(mockDevices);
+ mockService.getMemberById.mockResolvedValue(mockMember);
+
+ const result = await controller.getDevicesByMember(
+ 'mem_1',
+ 'org_1',
+ mockAuthContext,
+ );
+
+ expect(result).toEqual({
+ data: mockDevices,
+ count: 1,
+ member: mockMember,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_1', email: 'user@example.com' },
+ });
+ expect(service.findAllByMember).toHaveBeenCalledWith('org_1', 'mem_1');
+ expect(service.getMemberById).toHaveBeenCalledWith('org_1', 'mem_1');
+ });
+
+ it('should call both service methods in parallel', async () => {
+ const resolveOrder: string[] = [];
+
+ mockService.findAllByMember.mockImplementation(async () => {
+ resolveOrder.push('findAllByMember');
+ return [];
+ });
+ mockService.getMemberById.mockImplementation(async () => {
+ resolveOrder.push('getMemberById');
+ return { id: 'mem_1' };
+ });
+
+ await controller.getDevicesByMember('mem_1', 'org_1', mockAuthContext);
+
+ expect(service.findAllByMember).toHaveBeenCalledTimes(1);
+ expect(service.getMemberById).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not include authenticatedUser when userId or email is absent', async () => {
+ mockService.findAllByMember.mockResolvedValue([]);
+ mockService.getMemberById.mockResolvedValue({ id: 'mem_1' });
+
+ const authContextNoUser: AuthContextType = {
+ authType: 'api-key' as const,
+ organizationId: 'org_1',
+ permissions: [],
+ } as AuthContextType;
+
+ const result = await controller.getDevicesByMember(
+ 'mem_1',
+ 'org_1',
+ authContextNoUser,
+ );
+
+ expect(result).toEqual({
+ data: [],
+ count: 0,
+ member: { id: 'mem_1' },
+ authType: 'api-key',
+ });
+ });
+
+ it('should propagate service errors', async () => {
+ mockService.findAllByMember.mockRejectedValue(
+ new Error('FleetDM unavailable'),
+ );
+ mockService.getMemberById.mockResolvedValue({ id: 'mem_1' });
+
+ await expect(
+ controller.getDevicesByMember('mem_1', 'org_1', mockAuthContext),
+ ).rejects.toThrow('FleetDM unavailable');
+ });
+ });
+});
diff --git a/apps/api/src/devices/devices.controller.ts b/apps/api/src/devices/devices.controller.ts
index c7015baf04..f0f9fb8ab6 100644
--- a/apps/api/src/devices/devices.controller.ts
+++ b/apps/api/src/devices/devices.controller.ts
@@ -1,6 +1,5 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import {
- ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
@@ -9,20 +8,17 @@ import {
} from '@nestjs/swagger';
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 { DevicesByMemberResponseDto } from './dto/devices-by-member-response.dto';
import { DevicesService } from './devices.service';
@ApiTags('Devices')
@Controller({ path: 'devices', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@RequirePermission('app', 'read')
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class DevicesController {
constructor(private readonly devicesService: DevicesService) {}
@@ -30,7 +26,7 @@ export class DevicesController {
@ApiOperation({
summary: 'Get all devices',
description:
- 'Returns all devices for the authenticated organization from FleetDM. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns all devices for the authenticated organization from FleetDM. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
})
@ApiResponse({
status: 200,
@@ -151,7 +147,7 @@ export class DevicesController {
@ApiOperation({
summary: 'Get devices by member ID',
description:
- "Returns all devices assigned to a specific member within the authenticated organization. Devices are fetched from FleetDM using the member's dedicated fleetDmLabelId. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).",
+ "Returns all devices assigned to a specific member within the authenticated organization. Devices are fetched from FleetDM using the member's dedicated fleetDmLabelId. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).",
})
@ApiParam({
name: 'memberId',
diff --git a/apps/api/src/email/dto/send-email.dto.ts b/apps/api/src/email/dto/send-email.dto.ts
new file mode 100644
index 0000000000..1027ff1e21
--- /dev/null
+++ b/apps/api/src/email/dto/send-email.dto.ts
@@ -0,0 +1,66 @@
+import {
+ IsString,
+ IsOptional,
+ IsArray,
+ IsBoolean,
+ ValidateNested,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
+
+class EmailAttachmentDto {
+ @ApiProperty()
+ @IsString()
+ filename: string;
+
+ @ApiProperty()
+ @IsString()
+ content: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ contentType?: string;
+}
+
+export class SendEmailDto {
+ @ApiProperty({ description: 'Recipient email address' })
+ @IsString()
+ to: string;
+
+ @ApiProperty({ description: 'Email subject line' })
+ @IsString()
+ subject: string;
+
+ @ApiProperty({ description: 'Pre-rendered HTML content' })
+ @IsString()
+ html: string;
+
+ @ApiPropertyOptional({ description: 'Explicit FROM address override' })
+ @IsOptional()
+ @IsString()
+ from?: string;
+
+ @ApiPropertyOptional({
+ description: 'Use system sender address (RESEND_FROM_SYSTEM)',
+ })
+ @IsOptional()
+ @IsBoolean()
+ system?: boolean;
+
+ @ApiPropertyOptional({ description: 'CC recipients' })
+ @IsOptional()
+ cc?: string | string[];
+
+ @ApiPropertyOptional({ description: 'Schedule email for later delivery' })
+ @IsOptional()
+ @IsString()
+ scheduledAt?: string;
+
+ @ApiPropertyOptional({ description: 'File attachments' })
+ @IsOptional()
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => EmailAttachmentDto)
+ attachments?: EmailAttachmentDto[];
+}
diff --git a/apps/api/src/email/email.controller.ts b/apps/api/src/email/email.controller.ts
new file mode 100644
index 0000000000..c5cbb71b0b
--- /dev/null
+++ b/apps/api/src/email/email.controller.ts
@@ -0,0 +1,44 @@
+import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common';
+import {
+ ApiOperation,
+ ApiResponse,
+ ApiSecurity,
+ ApiTags,
+} from '@nestjs/swagger';
+import { tasks } from '@trigger.dev/sdk';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
+import { SendEmailDto } from './dto/send-email.dto';
+import type { sendEmailTask } from '../trigger/email/send-email';
+
+@ApiTags('Internal - Email')
+@Controller({ path: 'internal/email', version: '1' })
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
+export class EmailController {
+ @Post('send')
+ @HttpCode(200)
+ @RequirePermission('email', 'send')
+ @ApiOperation({
+ summary: 'Send an email via the centralized Trigger task (internal)',
+ })
+ @ApiResponse({ status: 200, description: 'Email task triggered' })
+ async sendEmail(@Body() dto: SendEmailDto) {
+ const fromAddress = dto.system
+ ? (process.env.RESEND_FROM_SYSTEM ?? process.env.RESEND_FROM_DEFAULT)
+ : (dto.from ?? process.env.RESEND_FROM_DEFAULT);
+
+ const handle = await tasks.trigger('send-email', {
+ to: dto.to,
+ subject: dto.subject,
+ html: dto.html,
+ from: fromAddress,
+ cc: dto.cc,
+ scheduledAt: dto.scheduledAt,
+ attachments: dto.attachments,
+ });
+
+ return { success: true, taskId: handle.id };
+ }
+}
diff --git a/apps/api/src/email/email.module.ts b/apps/api/src/email/email.module.ts
new file mode 100644
index 0000000000..5851bd274a
--- /dev/null
+++ b/apps/api/src/email/email.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { EmailController } from './email.controller';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [EmailController],
+})
+export class EmailModule {}
diff --git a/apps/api/src/email/templates/invite-member.tsx b/apps/api/src/email/templates/invite-member.tsx
new file mode 100644
index 0000000000..3f3f5524c2
--- /dev/null
+++ b/apps/api/src/email/templates/invite-member.tsx
@@ -0,0 +1,89 @@
+import {
+ Body,
+ Button,
+ Container,
+ Font,
+ Heading,
+ Html,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from '@react-email/components';
+import { Footer } from '../components/footer';
+import { Logo } from '../components/logo';
+
+interface Props {
+ organizationName: string;
+ inviteLink: string;
+ email?: string;
+}
+
+export const InviteEmail = ({ organizationName, inviteLink, email }: Props) => {
+ return (
+
+
+
+
+
+
+ You've been invited to join Comp AI
+
+
+
+
+
+ Join {organizationName} on Comp AI
+
+
+
+ You've been invited to join your team on Comp AI .
+
+
+
+
+ or copy and paste this URL into your browser{' '}
+
+ {inviteLink}
+
+
+
+
+ {email && (
+
+
+ this invitation was intended for{' '}
+ {email} .
+
+
+ )}
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/api/src/email/templates/unassigned-items-notification.tsx b/apps/api/src/email/templates/unassigned-items-notification.tsx
new file mode 100644
index 0000000000..28a34454cf
--- /dev/null
+++ b/apps/api/src/email/templates/unassigned-items-notification.tsx
@@ -0,0 +1,159 @@
+import {
+ Body,
+ Container,
+ Font,
+ Heading,
+ Html,
+ Link,
+ Preview,
+ Section,
+ Tailwind,
+ Text,
+} from '@react-email/components';
+import { getUnsubscribeUrl } from '@trycompai/email';
+import { Footer } from '../components/footer';
+import { Logo } from '../components/logo';
+
+interface UnassignedItem {
+ type: 'task' | 'policy' | 'risk' | 'vendor';
+ id: string;
+ name: string;
+}
+
+interface Props {
+ userName: string;
+ organizationName: string;
+ organizationId: string;
+ removedMemberName: string;
+ unassignedItems: UnassignedItem[];
+ email?: string;
+}
+
+const ITEM_TYPE_LABELS: Record = {
+ task: 'Task',
+ policy: 'Policy',
+ risk: 'Risk',
+ vendor: 'Vendor',
+};
+
+function getItemUrl(baseUrl: string, organizationId: string, item: UnassignedItem): string {
+ const paths: Record = {
+ task: 'tasks',
+ policy: 'policies',
+ risk: 'risk',
+ vendor: 'vendors',
+ };
+ return `${baseUrl}/${organizationId}/${paths[item.type]}/${item.id}`;
+}
+
+export const UnassignedItemsNotificationEmail = ({
+ userName,
+ organizationName,
+ organizationId,
+ removedMemberName,
+ unassignedItems,
+ email,
+}: Props) => {
+ const baseUrl = process.env.NEXT_PUBLIC_APP_URL
+ ?? process.env.BETTER_AUTH_URL
+ ?? 'https://app.trycomp.ai';
+ const link = `${baseUrl}/${organizationId}`;
+
+ const groupedItems = unassignedItems.reduce(
+ (acc, item) => {
+ if (!acc[item.type]) acc[item.type] = [];
+ acc[item.type].push(item);
+ return acc;
+ },
+ {} as Record,
+ );
+
+ return (
+
+
+
+
+
+
+
+ Member removed - items require reassignment
+
+
+
+
+
+ Member Removed - Items Require Reassignment
+
+
+ Hi {userName},
+
+
+ {removedMemberName} has been removed from{' '}
+ {organizationName} . As a result, the following items that were
+ previously assigned to them now require a new assignee:
+
+
+ {Object.entries(groupedItems).map(([type, items]) => (
+
+
+ {ITEM_TYPE_LABELS[type as UnassignedItem['type']]}s ({items.length})
+
+
+ {items.map((item) => (
+
+
+ {item.name}
+
+
+ ))}
+
+
+ ))}
+
+
+ Please log in to assign these items to appropriate team members.
+
+
+
+
+ {email && (
+
+ )}
+
+
+
+
+
+
+
+ );
+};
diff --git a/apps/api/src/email/trigger-email.ts b/apps/api/src/email/trigger-email.ts
new file mode 100644
index 0000000000..f95b61f013
--- /dev/null
+++ b/apps/api/src/email/trigger-email.ts
@@ -0,0 +1,47 @@
+import { render } from '@react-email/render';
+import { tasks } from '@trigger.dev/sdk';
+import type { ReactElement } from 'react';
+import type { sendEmailTask } from '../trigger/email/send-email';
+import type { EmailAttachment } from './resend';
+
+export async function triggerEmail(params: {
+ to: string;
+ subject: string;
+ react: ReactElement;
+ marketing?: boolean;
+ system?: boolean;
+ cc?: string | string[];
+ scheduledAt?: string;
+ attachments?: EmailAttachment[];
+}): Promise<{ id: string }> {
+ const html = await render(params.react);
+
+ const fromMarketing = process.env.RESEND_FROM_MARKETING;
+ const fromSystem = process.env.RESEND_FROM_SYSTEM;
+ const fromDefault = process.env.RESEND_FROM_DEFAULT;
+
+ const fromAddress = params.marketing
+ ? fromMarketing
+ : params.system
+ ? fromSystem
+ : fromDefault;
+
+ const handle = await tasks.trigger('send-email', {
+ to: params.to,
+ subject: params.subject,
+ html,
+ from: fromAddress ?? undefined,
+ cc: params.cc,
+ scheduledAt: params.scheduledAt,
+ attachments: params.attachments?.map((att) => ({
+ filename: att.filename,
+ content:
+ typeof att.content === 'string'
+ ? att.content
+ : att.content.toString('base64'),
+ contentType: att.contentType,
+ })),
+ });
+
+ return { id: handle.id };
+}
diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts b/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts
new file mode 100644
index 0000000000..fac27e5bf7
--- /dev/null
+++ b/apps/api/src/evidence-forms/evidence-forms.controller.spec.ts
@@ -0,0 +1,351 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { EvidenceFormsController } from './evidence-forms.controller';
+import { EvidenceFormsService } from './evidence-forms.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext as AuthContextType } from '../auth/types';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ evidence: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('EvidenceFormsController', () => {
+ let controller: EvidenceFormsController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ listForms: jest.fn(),
+ getFormStatuses: jest.fn(),
+ getMySubmissions: jest.fn(),
+ getPendingSubmissionCount: jest.fn(),
+ getFormWithSubmissions: jest.fn(),
+ getSubmission: jest.fn(),
+ deleteSubmission: jest.fn(),
+ submitForm: jest.fn(),
+ uploadSubmission: jest.fn(),
+ reviewSubmission: jest.fn(),
+ uploadFile: jest.fn(),
+ exportCsv: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContextType = {
+ organizationId: 'org_1',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'user_1',
+ userEmail: 'test@example.com',
+ userRoles: ['admin'],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [EvidenceFormsController],
+ providers: [{ provide: EvidenceFormsService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(EvidenceFormsController);
+ service = module.get(EvidenceFormsService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('listForms', () => {
+ it('should call service.listForms and return result', () => {
+ const mockForms = [{ type: 'security-awareness' }];
+ mockService.listForms.mockReturnValue(mockForms);
+
+ const result = controller.listForms();
+
+ expect(result).toEqual(mockForms);
+ expect(service.listForms).toHaveBeenCalled();
+ });
+ });
+
+ describe('getFormStatuses', () => {
+ it('should call service.getFormStatuses with organizationId', async () => {
+ const mockStatuses = { 'security-awareness': '2024-01-01' };
+ mockService.getFormStatuses.mockResolvedValue(mockStatuses);
+
+ const result = await controller.getFormStatuses('org_1');
+
+ expect(result).toEqual(mockStatuses);
+ expect(service.getFormStatuses).toHaveBeenCalledWith('org_1');
+ });
+ });
+
+ describe('getMySubmissions', () => {
+ it('should call service.getMySubmissions with correct params', async () => {
+ const mockSubmissions = [{ id: 'sub_1' }];
+ mockService.getMySubmissions.mockResolvedValue(mockSubmissions);
+
+ const result = await controller.getMySubmissions(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ );
+
+ expect(result).toEqual(mockSubmissions);
+ expect(service.getMySubmissions).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: 'security-awareness',
+ });
+ });
+
+ it('should pass undefined formType when not provided', async () => {
+ mockService.getMySubmissions.mockResolvedValue([]);
+
+ await controller.getMySubmissions('org_1', mockAuthContext);
+
+ expect(service.getMySubmissions).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: undefined,
+ });
+ });
+ });
+
+ describe('getPendingSubmissionCount', () => {
+ it('should call service.getPendingSubmissionCount with correct params', async () => {
+ const mockCount = { count: 3 };
+ mockService.getPendingSubmissionCount.mockResolvedValue(mockCount);
+
+ const result = await controller.getPendingSubmissionCount(
+ 'org_1',
+ mockAuthContext,
+ );
+
+ expect(result).toEqual(mockCount);
+ expect(service.getPendingSubmissionCount).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ });
+ });
+ });
+
+ describe('getFormWithSubmissions', () => {
+ it('should call service.getFormWithSubmissions with all params', async () => {
+ const mockData = { form: {}, submissions: [] };
+ mockService.getFormWithSubmissions.mockResolvedValue(mockData);
+
+ const result = await controller.getFormWithSubmissions(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ 'search-term',
+ '10',
+ '0',
+ );
+
+ expect(result).toEqual(mockData);
+ expect(service.getFormWithSubmissions).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: 'security-awareness',
+ search: 'search-term',
+ limit: '10',
+ offset: '0',
+ });
+ });
+
+ it('should pass undefined for optional query params', async () => {
+ mockService.getFormWithSubmissions.mockResolvedValue({});
+
+ await controller.getFormWithSubmissions(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ );
+
+ expect(service.getFormWithSubmissions).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: 'security-awareness',
+ search: undefined,
+ limit: undefined,
+ offset: undefined,
+ });
+ });
+ });
+
+ describe('getSubmission', () => {
+ it('should call service.getSubmission with correct params', async () => {
+ const mockSubmission = { id: 'sub_1', data: {} };
+ mockService.getSubmission.mockResolvedValue(mockSubmission);
+
+ const result = await controller.getSubmission(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ 'sub_1',
+ );
+
+ expect(result).toEqual(mockSubmission);
+ expect(service.getSubmission).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: 'security-awareness',
+ submissionId: 'sub_1',
+ });
+ });
+ });
+
+ describe('deleteSubmission', () => {
+ it('should call service.deleteSubmission with correct params', async () => {
+ const mockResult = { success: true };
+ mockService.deleteSubmission.mockResolvedValue(mockResult);
+
+ const result = await controller.deleteSubmission(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ 'sub_1',
+ );
+
+ expect(result).toEqual(mockResult);
+ expect(service.deleteSubmission).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: 'security-awareness',
+ submissionId: 'sub_1',
+ });
+ });
+ });
+
+ describe('submitForm', () => {
+ it('should call service.submitForm with correct params', async () => {
+ const body = { field1: 'value1' };
+ const mockResult = { id: 'sub_new' };
+ mockService.submitForm.mockResolvedValue(mockResult);
+
+ const result = await controller.submitForm(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ body,
+ );
+
+ expect(result).toEqual(mockResult);
+ expect(service.submitForm).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ formType: 'security-awareness',
+ payload: body,
+ authContext: mockAuthContext,
+ });
+ });
+ });
+
+ describe('uploadSubmission', () => {
+ it('should call service.uploadSubmission with correct params', async () => {
+ const body = { fileUrl: 'https://example.com/file.pdf' };
+ const mockResult = { id: 'sub_upload' };
+ mockService.uploadSubmission.mockResolvedValue(mockResult);
+
+ const result = await controller.uploadSubmission(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ body,
+ );
+
+ expect(result).toEqual(mockResult);
+ expect(service.uploadSubmission).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ formType: 'security-awareness',
+ authContext: mockAuthContext,
+ payload: body,
+ });
+ });
+ });
+
+ describe('reviewSubmission', () => {
+ it('should call service.reviewSubmission with correct params', async () => {
+ const body = { status: 'approved' };
+ const mockResult = { id: 'sub_1', status: 'approved' };
+ mockService.reviewSubmission.mockResolvedValue(mockResult);
+
+ const result = await controller.reviewSubmission(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ 'sub_1',
+ body,
+ );
+
+ expect(result).toEqual(mockResult);
+ expect(service.reviewSubmission).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ formType: 'security-awareness',
+ submissionId: 'sub_1',
+ payload: body,
+ authContext: mockAuthContext,
+ });
+ });
+ });
+
+ describe('uploadFile', () => {
+ it('should call service.uploadFile with correct params', async () => {
+ const body = { fileName: 'test.pdf', contentType: 'application/pdf' };
+ const mockResult = { uploadUrl: 'https://s3.example.com/upload' };
+ mockService.uploadFile.mockResolvedValue(mockResult);
+
+ const result = await controller.uploadFile(
+ 'org_1',
+ mockAuthContext,
+ body,
+ );
+
+ expect(result).toEqual(mockResult);
+ expect(service.uploadFile).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ payload: body,
+ });
+ });
+ });
+
+ describe('exportCsv', () => {
+ it('should call service.exportCsv and set response headers', async () => {
+ const csvContent = 'col1,col2\nval1,val2';
+ mockService.exportCsv.mockResolvedValue(csvContent);
+
+ const mockRes = {
+ setHeader: jest.fn(),
+ send: jest.fn(),
+ };
+
+ await controller.exportCsv(
+ 'org_1',
+ mockAuthContext,
+ 'security-awareness',
+ mockRes as never,
+ );
+
+ expect(service.exportCsv).toHaveBeenCalledWith({
+ organizationId: 'org_1',
+ authContext: mockAuthContext,
+ formType: 'security-awareness',
+ });
+ expect(mockRes.setHeader).toHaveBeenCalledWith(
+ 'Content-Disposition',
+ expect.stringContaining('security-awareness-submissions-'),
+ );
+ expect(mockRes.send).toHaveBeenCalledWith(csvContent);
+ });
+ });
+});
diff --git a/apps/api/src/evidence-forms/evidence-forms.controller.ts b/apps/api/src/evidence-forms/evidence-forms.controller.ts
index a9421c09b0..414864e100 100644
--- a/apps/api/src/evidence-forms/evidence-forms.controller.ts
+++ b/apps/api/src/evidence-forms/evidence-forms.controller.ts
@@ -1,6 +1,9 @@
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 { AuditRead } from '@/audit/skip-audit-log.decorator';
import {
Body,
Controller,
@@ -20,7 +23,7 @@ import { EvidenceFormsService } from './evidence-forms.service';
@ApiTags('Evidence Forms')
@Controller({ path: 'evidence-forms', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
@@ -32,6 +35,7 @@ export class EvidenceFormsController {
constructor(private readonly evidenceFormsService: EvidenceFormsService) {}
@Get()
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'List evidence forms',
description: 'List all available pre-built evidence forms',
@@ -41,6 +45,7 @@ export class EvidenceFormsController {
}
@Get('statuses')
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'Get submission statuses for all forms',
description:
@@ -51,6 +56,7 @@ export class EvidenceFormsController {
}
@Get('my-submissions')
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'Get current user submissions',
description:
@@ -69,6 +75,7 @@ export class EvidenceFormsController {
}
@Get('my-submissions/pending-count')
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'Get pending submission count for current user',
description:
@@ -85,6 +92,7 @@ export class EvidenceFormsController {
}
@Get(':formType')
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'Get form definition and submissions',
description:
@@ -109,6 +117,7 @@ export class EvidenceFormsController {
}
@Get(':formType/submissions/:submissionId')
+ @RequirePermission('evidence', 'read')
@ApiOperation({
summary: 'Get a single submission',
description:
@@ -149,6 +158,7 @@ export class EvidenceFormsController {
}
@Post(':formType/submissions')
+ @RequirePermission('evidence', 'create')
@ApiOperation({
summary: 'Submit evidence form entry',
description:
@@ -169,6 +179,7 @@ export class EvidenceFormsController {
}
@Post(':formType/upload-submission')
+ @RequirePermission('evidence', 'create')
@ApiOperation({
summary: 'Upload a file as an evidence submission',
description:
@@ -189,6 +200,7 @@ export class EvidenceFormsController {
}
@Patch(':formType/submissions/:submissionId/review')
+ @RequirePermission('evidence', 'update')
@ApiOperation({
summary: 'Review a submission',
description:
@@ -211,6 +223,7 @@ export class EvidenceFormsController {
}
@Post('uploads')
+ @RequirePermission('evidence', 'create')
@ApiOperation({
summary: 'Upload evidence form file',
description:
@@ -229,6 +242,8 @@ export class EvidenceFormsController {
}
@Get(':formType/export.csv')
+ @RequirePermission('evidence', 'read')
+ @AuditRead()
@ApiOperation({
summary: 'Export form submissions to CSV',
description: 'Export all form submissions for an organization as CSV',
diff --git a/apps/api/src/evidence-forms/evidence-forms.service.spec.ts b/apps/api/src/evidence-forms/evidence-forms.service.spec.ts
index 35884445b1..d80a918270 100644
--- a/apps/api/src/evidence-forms/evidence-forms.service.spec.ts
+++ b/apps/api/src/evidence-forms/evidence-forms.service.spec.ts
@@ -46,8 +46,8 @@ type MockDb = {
describe('EvidenceFormsService', () => {
const authContext: AuthContext = {
organizationId: 'org_123',
- authType: 'jwt',
- isApiKey: false,
+ authType: 'session',
+ isApiKey: false, isPlatformAdmin: false,
userRoles: ['admin'],
userId: 'usr_reviewer',
userEmail: 'reviewer@example.com',
diff --git a/apps/api/src/findings/finding-notifier.service.spec.ts b/apps/api/src/findings/finding-notifier.service.spec.ts
index 25c7fb2a7e..9ba8a4e873 100644
--- a/apps/api/src/findings/finding-notifier.service.spec.ts
+++ b/apps/api/src/findings/finding-notifier.service.spec.ts
@@ -1,5 +1,5 @@
import { isUserUnsubscribed } from '@trycompai/email';
-import { sendEmail } from '../email/resend';
+import { triggerEmail } from '../email/trigger-email';
import { NovuService } from '../notifications/novu.service';
import { FindingNotifierService } from './finding-notifier.service';
@@ -41,8 +41,8 @@ jest.mock('@trycompai/email', () => ({
isUserUnsubscribed: jest.fn(),
}));
-jest.mock('../email/resend', () => ({
- sendEmail: jest.fn(),
+jest.mock('../email/trigger-email', () => ({
+ triggerEmail: jest.fn(),
}));
jest.mock(
@@ -80,7 +80,7 @@ const { db, FindingType } = mockDbModule;
describe('FindingNotifierService', () => {
const mockedDb = db;
- const mockedSendEmail = sendEmail as jest.MockedFunction;
+ const mockedTriggerEmail = triggerEmail as jest.MockedFunction;
const mockedIsUserUnsubscribed = isUserUnsubscribed as jest.MockedFunction<
typeof isUserUnsubscribed
>;
@@ -120,7 +120,7 @@ describe('FindingNotifierService', () => {
});
mockedIsUserUnsubscribed.mockResolvedValue(false);
- mockedSendEmail.mockResolvedValue({ id: 'email_123', message: 'queued' });
+ mockedTriggerEmail.mockResolvedValue({ id: 'email_123' });
novuTriggerMock.mockResolvedValue(undefined);
});
diff --git a/apps/api/src/findings/finding-notifier.service.ts b/apps/api/src/findings/finding-notifier.service.ts
index 5176b2837a..7c5792f0fe 100644
--- a/apps/api/src/findings/finding-notifier.service.ts
+++ b/apps/api/src/findings/finding-notifier.service.ts
@@ -1,7 +1,7 @@
import { db, FindingStatus, FindingType } from '@db';
import { Injectable, Logger } from '@nestjs/common';
import { isUserUnsubscribed } from '@trycompai/email';
-import { sendEmail } from '../email/resend';
+import { triggerEmail } from '../email/trigger-email';
import { FindingNotificationEmail } from '../email/templates/finding-notification';
import { NovuService } from '../notifications/novu.service';
@@ -116,7 +116,7 @@ export class FindingNotifierService {
/**
* Notify when a new finding is created.
- * Recipients: Task assignee + Organization admins/owners
+ * Recipients: All org members (filtered by notification matrix)
*/
async notifyFindingCreated(params: NotificationParams): Promise {
const {
@@ -214,7 +214,7 @@ export class FindingNotifierService {
/**
* Notify when status changes to Needs Revision.
- * Recipients: Task assignee + Organization admins/owners
+ * Recipients: All org members (filtered by notification matrix)
*/
async notifyNeedsRevision(params: NotificationParams): Promise {
const {
@@ -259,7 +259,7 @@ export class FindingNotifierService {
/**
* Notify when finding is closed.
- * Recipients: Task assignee + Organization admins/owners
+ * Recipients: All org members (filtered by notification matrix)
*/
async notifyFindingClosed(params: NotificationParams): Promise {
const {
@@ -410,11 +410,7 @@ export class FindingNotifierService {
try {
// Check unsubscribe preferences
- const isUnsubscribed = await isUserUnsubscribed(
- db,
- recipient.email,
- 'findingNotifications',
- );
+ const isUnsubscribed = await isUserUnsubscribed(db, recipient.email, 'findingNotifications', organizationId);
if (isUnsubscribed) {
this.logger.log(
@@ -498,7 +494,7 @@ export class FindingNotifierService {
} = params;
try {
- const { id } = await sendEmail({
+ const { id } = await triggerEmail({
to: recipient.email,
subject,
react: FindingNotificationEmail({
@@ -594,8 +590,9 @@ export class FindingNotifierService {
// ==========================================================================
/**
- * Get task assignee and organization admins/owners as recipients.
+ * Get all organization members as potential recipients.
* Excludes the actor (person who triggered the action).
+ * The notification matrix (isUserUnsubscribed) handles role-based filtering.
*/
private async getTaskAssigneeAndAdmins(
organizationId: string,
@@ -619,40 +616,23 @@ export class FindingNotifierService {
where: {
organizationId,
deactivated: false,
+ OR: [
+ { user: { isPlatformAdmin: false } },
+ { role: { contains: 'owner' } },
+ ],
},
select: {
- role: true,
user: { select: { id: true, email: true, name: true } },
},
}),
]);
- // Filter for admins/owners (roles can be comma-separated, e.g., "admin,auditor")
- const adminMembers = allMembers.filter(
- (member) =>
- member.role.includes('admin') || member.role.includes('owner'),
- );
-
+ // Build recipient list: all members excluding actor.
+ // The isUserUnsubscribed check handles role-based filtering via the notification matrix.
const recipients: Recipient[] = [];
const addedUserIds = new Set();
- // Add task assignee
- const assigneeUser = task?.assignee?.user;
- if (
- assigneeUser &&
- assigneeUser.id !== excludeUserId &&
- assigneeUser.email
- ) {
- recipients.push({
- userId: assigneeUser.id,
- email: assigneeUser.email,
- name: assigneeUser.name || assigneeUser.email,
- });
- addedUserIds.add(assigneeUser.id);
- }
-
- // Add org admins/owners (deduplicated)
- for (const member of adminMembers) {
+ for (const member of allMembers) {
const user = member.user;
if (
user.id !== excludeUserId &&
diff --git a/apps/api/src/findings/findings.controller.spec.ts b/apps/api/src/findings/findings.controller.spec.ts
index 510203116c..0fa9a69467 100644
--- a/apps/api/src/findings/findings.controller.spec.ts
+++ b/apps/api/src/findings/findings.controller.spec.ts
@@ -53,8 +53,8 @@ import { FindingsController } from './findings.controller';
describe('FindingsController', () => {
const authContext: AuthContext = {
organizationId: 'org_123',
- authType: 'jwt',
- isApiKey: false,
+ authType: 'session',
+ isApiKey: false, isPlatformAdmin: false,
userRoles: ['admin'],
userId: 'usr_123',
userEmail: 'admin@example.com',
diff --git a/apps/api/src/findings/findings.controller.ts b/apps/api/src/findings/findings.controller.ts
index 03441462ee..874a8e36cf 100644
--- a/apps/api/src/findings/findings.controller.ts
+++ b/apps/api/src/findings/findings.controller.ts
@@ -20,12 +20,12 @@ import {
ApiQuery,
ApiResponse,
ApiTags,
- ApiHeader,
ApiSecurity,
} from '@nestjs/swagger';
import { FindingStatus } from '@trycompai/db';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
-import { RequireRoles } from '../auth/role-validator.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
import { AuthContext } from '../auth/auth-context.decorator';
import type { AuthContext as AuthContextType } from '../auth/types';
import { FindingsService } from './findings.service';
@@ -39,16 +39,12 @@ import { evidenceFormTypeSchema } from '@/evidence-forms/evidence-forms.definiti
@Controller({ path: 'findings', version: '1' })
@UseGuards(HybridAuthGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class FindingsController {
constructor(private readonly findingsService: FindingsService) {}
@Get()
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'read')
@ApiOperation({
summary: 'Get findings for a task',
description: 'Retrieve all findings for a specific task',
@@ -134,6 +130,8 @@ export class FindingsController {
}
@Get('organization')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'read')
@ApiOperation({
summary: 'Get all findings for organization',
description: 'Retrieve all findings for the organization',
@@ -178,6 +176,8 @@ export class FindingsController {
}
@Get(':id')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'read')
@ApiOperation({
summary: 'Get finding by ID',
description: 'Retrieve a specific finding by its ID',
@@ -207,7 +207,8 @@ export class FindingsController {
}
@Post()
- @UseGuards(RequireRoles('auditor', 'admin', 'owner'))
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'create')
@ApiOperation({
summary: 'Create a finding',
description:
@@ -283,7 +284,8 @@ export class FindingsController {
}
@Patch(':id')
- @UseGuards(RequireRoles('auditor', 'admin', 'owner'))
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'update')
@ApiOperation({
summary: 'Update a finding',
description:
@@ -360,7 +362,8 @@ export class FindingsController {
}
@Delete(':id')
- @UseGuards(RequireRoles('auditor', 'admin', 'owner'))
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'delete')
@ApiOperation({
summary: 'Delete a finding',
description: 'Delete a finding (Auditor or Platform Admin only)',
@@ -429,6 +432,8 @@ export class FindingsController {
}
@Get(':id/history')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('finding', 'read')
@ApiOperation({
summary: 'Get finding history',
description: 'Retrieve the activity history for a specific finding',
diff --git a/apps/api/src/framework-editor/task-template/task-template.controller.ts b/apps/api/src/framework-editor/task-template/task-template.controller.ts
index c0d9b43394..6c784dcf9f 100644
--- a/apps/api/src/framework-editor/task-template/task-template.controller.ts
+++ b/apps/api/src/framework-editor/task-template/task-template.controller.ts
@@ -11,7 +11,6 @@ import {
} from '@nestjs/common';
import {
ApiBody,
- ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
@@ -20,6 +19,8 @@ import {
} from '@nestjs/swagger';
import { AuthContext } 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 { UpdateTaskTemplateDto } from './dto/update-task-template.dto';
import { TaskTemplateService } from './task-template.service';
@@ -34,18 +35,13 @@ import { DELETE_TASK_TEMPLATE_RESPONSES } from './schemas/delete-task-template.r
@ApiTags('Framework Editor Task Templates')
@Controller({ path: 'framework-editor/task-template', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class TaskTemplateController {
constructor(private readonly taskTemplateService: TaskTemplateService) {}
@Get()
+ @RequirePermission('framework', 'read')
@ApiOperation(TASK_TEMPLATE_OPERATIONS.getAllTaskTemplates)
@ApiResponse(GET_ALL_TASK_TEMPLATES_RESPONSES[200])
@ApiResponse(GET_ALL_TASK_TEMPLATES_RESPONSES[401])
@@ -55,6 +51,7 @@ export class TaskTemplateController {
}
@Get(':id')
+ @RequirePermission('framework', 'read')
@ApiOperation(TASK_TEMPLATE_OPERATIONS.getTaskTemplateById)
@ApiParam(TASK_TEMPLATE_PARAMS.taskTemplateId)
@ApiResponse(GET_TASK_TEMPLATE_BY_ID_RESPONSES[200])
@@ -82,6 +79,7 @@ export class TaskTemplateController {
}
@Patch(':id')
+ @RequirePermission('framework', 'update')
@ApiOperation(TASK_TEMPLATE_OPERATIONS.updateTaskTemplate)
@ApiParam(TASK_TEMPLATE_PARAMS.taskTemplateId)
@ApiBody(TASK_TEMPLATE_BODIES.updateTaskTemplate)
@@ -121,6 +119,7 @@ export class TaskTemplateController {
}
@Delete(':id')
+ @RequirePermission('framework', 'delete')
@ApiOperation(TASK_TEMPLATE_OPERATIONS.deleteTaskTemplate)
@ApiParam(TASK_TEMPLATE_PARAMS.taskTemplateId)
@ApiResponse(DELETE_TASK_TEMPLATE_RESPONSES[200])
diff --git a/apps/api/src/frameworks/dto/add-frameworks.dto.ts b/apps/api/src/frameworks/dto/add-frameworks.dto.ts
new file mode 100644
index 0000000000..d74e49bdf0
--- /dev/null
+++ b/apps/api/src/frameworks/dto/add-frameworks.dto.ts
@@ -0,0 +1,14 @@
+import { IsArray, IsString, ArrayMinSize } from 'class-validator';
+import { ApiProperty } from '@nestjs/swagger';
+
+export class AddFrameworksDto {
+ @ApiProperty({
+ description: 'Array of framework editor framework IDs to add',
+ type: [String],
+ minItems: 1,
+ })
+ @IsArray()
+ @ArrayMinSize(1)
+ @IsString({ each: true })
+ frameworkIds: string[];
+}
diff --git a/apps/api/src/frameworks/frameworks-scores.helper.ts b/apps/api/src/frameworks/frameworks-scores.helper.ts
new file mode 100644
index 0000000000..4401fefb32
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks-scores.helper.ts
@@ -0,0 +1,215 @@
+import {
+ evidenceFormDefinitionList,
+ meetingSubTypeValues,
+ toDbEvidenceFormType,
+} from '@comp/company';
+import { db } from '@trycompai/db';
+import { filterComplianceMembers } from '../utils/compliance-filters';
+
+const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000;
+
+const TRAINING_VIDEO_IDS = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
+
+export async function getOverviewScores(organizationId: string) {
+ const [allPolicies, allTasks, employees, onboarding] = await Promise.all([
+ db.policy.findMany({ where: { organizationId } }),
+ db.task.findMany({ where: { organizationId } }),
+ db.member.findMany({
+ where: { organizationId, deactivated: false },
+ include: { user: true },
+ }),
+ db.onboarding.findUnique({
+ where: { organizationId },
+ select: { triggerJobId: true },
+ }),
+ ]);
+
+ // Policy breakdown
+ const publishedPolicies = allPolicies.filter((p) => p.status === 'published');
+ const draftPolicies = allPolicies.filter((p) => p.status === 'draft');
+ const policiesInReview = allPolicies.filter(
+ (p) => p.status === 'needs_review',
+ );
+ const unpublishedPolicies = allPolicies.filter(
+ (p) => p.status === 'draft' || p.status === 'needs_review',
+ );
+
+ // Task breakdown
+ const doneTasks = allTasks.filter(
+ (t) => t.status === 'done' || t.status === 'not_relevant',
+ );
+ const incompleteTasks = allTasks.filter(
+ (t) => t.status === 'todo' || t.status === 'in_progress',
+ );
+
+ // People score — filter to members with compliance:required permission
+ const activeEmployees = await filterComplianceMembers(employees, organizationId);
+
+ let completedMembers = 0;
+
+ if (activeEmployees.length > 0) {
+ const requiredPolicies = allPolicies.filter(
+ (p) =>
+ p.isRequiredToSign && p.status === 'published' && !p.isArchived,
+ );
+
+ const trainingCompletions =
+ await db.employeeTrainingVideoCompletion.findMany({
+ where: { memberId: { in: activeEmployees.map((e) => e.id) } },
+ });
+
+ for (const emp of activeEmployees) {
+ const hasAcceptedAllPolicies =
+ requiredPolicies.length === 0 ||
+ requiredPolicies.every((p) => p.signedBy.includes(emp.id));
+
+ const empCompletions = trainingCompletions.filter(
+ (c) => c.memberId === emp.id,
+ );
+ const completedVideoIds = empCompletions
+ .filter((c) => c.completedAt !== null)
+ .map((c) => c.videoId);
+ const hasCompletedAllTraining = TRAINING_VIDEO_IDS.every((vid) =>
+ completedVideoIds.includes(vid),
+ );
+
+ if (hasAcceptedAllPolicies && hasCompletedAllTraining) {
+ completedMembers++;
+ }
+ }
+ }
+
+ return {
+ policies: {
+ total: allPolicies.length,
+ published: publishedPolicies.length,
+ draftPolicies,
+ policiesInReview,
+ unpublishedPolicies,
+ },
+ tasks: {
+ total: allTasks.length,
+ done: doneTasks.length,
+ incompleteTasks,
+ },
+ people: {
+ total: activeEmployees.length,
+ completed: completedMembers,
+ },
+ onboardingTriggerJobId: onboarding?.triggerJobId ?? null,
+ documents: await computeDocumentsScore(organizationId),
+ findings: await getOrganizationFindings(organizationId),
+ };
+}
+
+async function computeDocumentsScore(organizationId: string) {
+ const groupedStatuses = await db.evidenceSubmission.groupBy({
+ by: ['formType'],
+ where: { organizationId },
+ _max: { submittedAt: true },
+ });
+
+ const statuses: Record = {};
+ for (const form of evidenceFormDefinitionList) {
+ const match = groupedStatuses.find(
+ (entry) => entry.formType === toDbEvidenceFormType(form.type),
+ );
+ statuses[form.type] = {
+ lastSubmittedAt: match?._max.submittedAt?.toISOString() ?? null,
+ };
+ }
+
+ const includedForms = evidenceFormDefinitionList.filter((f) => !f.hidden && !f.optional);
+ const totalDocuments = includedForms.length;
+ const outstandingDocuments = includedForms.reduce((count, form) => {
+ if (form.type === 'meeting') {
+ const allMeetingsOutstanding = meetingSubTypeValues.every((subType) => {
+ const lastSubmitted = statuses[subType]?.lastSubmittedAt;
+ return !lastSubmitted || Date.now() - new Date(lastSubmitted).getTime() > SIX_MONTHS_MS;
+ });
+ return allMeetingsOutstanding ? count + 1 : count;
+ }
+ const lastSubmitted = statuses[form.type]?.lastSubmittedAt;
+ const isOutstanding = !lastSubmitted || Date.now() - new Date(lastSubmitted).getTime() > SIX_MONTHS_MS;
+ return isOutstanding ? count + 1 : count;
+ }, 0);
+
+ return {
+ totalDocuments,
+ completedDocuments: totalDocuments - outstandingDocuments,
+ outstandingDocuments,
+ };
+}
+
+async function getOrganizationFindings(organizationId: string) {
+ return db.finding.findMany({
+ where: { organizationId },
+ include: {
+ task: { select: { id: true, title: true } },
+ evidenceSubmission: { select: { id: true, formType: true } },
+ },
+ orderBy: [{ status: 'asc' }, { createdAt: 'desc' }],
+ });
+}
+
+export async function getCurrentMember(
+ organizationId: string,
+ userId: string,
+) {
+ const member = await db.member.findFirst({
+ where: { userId, organizationId, deactivated: false },
+ select: { id: true, role: true },
+ });
+ return member;
+}
+
+interface FrameworkWithControlsForScoring {
+ controls: {
+ id: string;
+ policies: { id: string; status: string }[];
+ }[];
+}
+
+interface TaskWithControls {
+ id: string;
+ status: string;
+ controls: { id: string }[];
+}
+
+export function computeFrameworkComplianceScore(
+ framework: FrameworkWithControlsForScoring,
+ tasks: TaskWithControls[],
+): number {
+ const controls = framework.controls ?? [];
+
+ // Deduplicate policies by id across all controls
+ const uniquePoliciesMap = new Map();
+ for (const c of controls) {
+ for (const p of c.policies || []) {
+ uniquePoliciesMap.set(p.id, p);
+ }
+ }
+ const uniquePolicies = Array.from(uniquePoliciesMap.values());
+
+ const totalPolicies = uniquePolicies.length;
+ const publishedPolicies = uniquePolicies.filter(
+ (p) => p.status === 'published',
+ ).length;
+ const policyRatio = totalPolicies > 0 ? publishedPolicies / totalPolicies : 0;
+
+ const controlIds = controls.map((c) => c.id);
+ const uniqueTaskMap = new Map();
+ for (const t of tasks) {
+ if (t.controls.some((c) => controlIds.includes(c.id))) {
+ uniqueTaskMap.set(t.id, t);
+ }
+ }
+ const uniqueTasks = Array.from(uniqueTaskMap.values());
+ const totalTasks = uniqueTasks.length;
+ const doneTasks = uniqueTasks.filter(
+ (t) => t.status === 'done' || t.status === 'not_relevant',
+ ).length;
+ const taskRatio = totalTasks > 0 ? doneTasks / totalTasks : 1;
+
+ return Math.round(((policyRatio + taskRatio) / 2) * 100);
+}
diff --git a/apps/api/src/frameworks/frameworks-upsert.helper.ts b/apps/api/src/frameworks/frameworks-upsert.helper.ts
new file mode 100644
index 0000000000..e1c86049bf
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks-upsert.helper.ts
@@ -0,0 +1,340 @@
+import { Prisma } from '@trycompai/db';
+
+type FrameworkEditorFrameworkWithRequirements =
+ Prisma.FrameworkEditorFrameworkGetPayload<{
+ include: { requirements: true };
+ }>;
+
+export interface UpsertOrgFrameworkStructureInput {
+ organizationId: string;
+ targetFrameworkEditorIds: string[];
+ frameworkEditorFrameworks: FrameworkEditorFrameworkWithRequirements[];
+ tx: Prisma.TransactionClient;
+}
+
+export async function upsertOrgFrameworkStructure({
+ organizationId,
+ targetFrameworkEditorIds,
+ frameworkEditorFrameworks,
+ tx,
+}: UpsertOrgFrameworkStructureInput) {
+ // Get all template entities based on input frameworks
+ const requirementIds = frameworkEditorFrameworks.flatMap((framework) =>
+ framework.requirements.map((req) => req.id),
+ );
+
+ const controlTemplates = await tx.frameworkEditorControlTemplate.findMany({
+ where: {
+ requirements: { some: { id: { in: requirementIds } } },
+ },
+ });
+ const controlTemplateIds = controlTemplates.map((c) => c.id);
+
+ const policyTemplates = await tx.frameworkEditorPolicyTemplate.findMany({
+ where: {
+ controlTemplates: { some: { id: { in: controlTemplateIds } } },
+ },
+ });
+ const policyTemplateIds = policyTemplates.map((p) => p.id);
+
+ const taskTemplates = await tx.frameworkEditorTaskTemplate.findMany({
+ where: {
+ controlTemplates: { some: { id: { in: controlTemplateIds } } },
+ },
+ });
+ const taskTemplateIds = taskTemplates.map((t) => t.id);
+
+ // Get all template relations
+ const controlRelations = await tx.frameworkEditorControlTemplate.findMany({
+ where: { id: { in: controlTemplateIds } },
+ select: {
+ id: true,
+ requirements: { where: { id: { in: requirementIds } } },
+ policyTemplates: { where: { id: { in: policyTemplateIds } } },
+ taskTemplates: { where: { id: { in: taskTemplateIds } } },
+ },
+ });
+
+ const groupedRelations = controlRelations.map((ct) => ({
+ controlTemplateId: ct.id,
+ requirementTemplateIds: ct.requirements.map((r) => r.id),
+ policyTemplateIds: ct.policyTemplates.map((p) => p.id),
+ taskTemplateIds: ct.taskTemplates.map((t) => t.id),
+ }));
+
+ // Upsert framework instances
+ const existingInstances = await tx.frameworkInstance.findMany({
+ where: {
+ organizationId,
+ frameworkId: { in: targetFrameworkEditorIds },
+ },
+ select: { frameworkId: true },
+ });
+ const existingFrameworkIds = new Set(
+ existingInstances.map((fi) => fi.frameworkId),
+ );
+
+ const instancesToCreate = frameworkEditorFrameworks
+ .filter(
+ (f) =>
+ targetFrameworkEditorIds.includes(f.id) &&
+ !existingFrameworkIds.has(f.id),
+ )
+ .map((framework) => ({
+ organizationId,
+ frameworkId: framework.id,
+ }));
+
+ if (instancesToCreate.length > 0) {
+ await tx.frameworkInstance.createMany({ data: instancesToCreate });
+ }
+
+ const allOrgInstances = await tx.frameworkInstance.findMany({
+ where: {
+ organizationId,
+ frameworkId: { in: targetFrameworkEditorIds },
+ },
+ select: { id: true, frameworkId: true },
+ });
+ const editorToInstanceMap = new Map(
+ allOrgInstances.map((inst) => [inst.frameworkId, inst.id]),
+ );
+
+ // Upsert control instances
+ const existingControls = await tx.control.findMany({
+ where: {
+ organizationId,
+ controlTemplateId: { in: controlTemplateIds },
+ },
+ select: { controlTemplateId: true },
+ });
+ const existingControlTemplateIds = new Set(
+ existingControls
+ .map((c) => c.controlTemplateId)
+ .filter((id): id is string => id !== null),
+ );
+
+ const controlsToCreate = controlTemplates.filter(
+ (t) => !existingControlTemplateIds.has(t.id),
+ );
+ if (controlsToCreate.length > 0) {
+ await tx.control.createMany({
+ data: controlsToCreate.map((ct) => ({
+ name: ct.name,
+ description: ct.description,
+ organizationId,
+ controlTemplateId: ct.id,
+ })),
+ });
+ }
+
+ // Upsert policy instances
+ const existingPolicies = await tx.policy.findMany({
+ where: {
+ organizationId,
+ policyTemplateId: { in: policyTemplateIds },
+ },
+ select: { policyTemplateId: true },
+ });
+ const existingPolicyTemplateIds = new Set(
+ existingPolicies
+ .map((p) => p.policyTemplateId)
+ .filter((id): id is string => id !== null),
+ );
+
+ const policiesToCreate = policyTemplates.filter(
+ (t) => !existingPolicyTemplateIds.has(t.id),
+ );
+ if (policiesToCreate.length > 0) {
+ await tx.policy.createMany({
+ data: policiesToCreate.map((pt) => ({
+ name: pt.name,
+ description: pt.description,
+ department: pt.department,
+ frequency: pt.frequency,
+ content:
+ pt.content as Prisma.PolicyCreateInput['content'],
+ organizationId,
+ policyTemplateId: pt.id,
+ })),
+ });
+
+ const newPolicies = await tx.policy.findMany({
+ where: {
+ organizationId,
+ policyTemplateId: { in: policiesToCreate.map((t) => t.id) },
+ },
+ select: { id: true, policyTemplateId: true, content: true },
+ });
+
+ if (newPolicies.length > 0) {
+ await tx.policyVersion.createMany({
+ data: newPolicies.map((p) => ({
+ policyId: p.id,
+ version: 1,
+ content: p.content as Prisma.InputJsonValue[],
+ changelog: 'Initial version from template',
+ })),
+ });
+
+ const createdVersions = await tx.policyVersion.findMany({
+ where: {
+ policyId: { in: newPolicies.map((p) => p.id) },
+ version: 1,
+ },
+ select: { id: true, policyId: true },
+ });
+
+ for (const version of createdVersions) {
+ await tx.policy.update({
+ where: { id: version.policyId },
+ data: { currentVersionId: version.id },
+ });
+ }
+ }
+ }
+
+ // Upsert task instances
+ const existingTasks = await tx.task.findMany({
+ where: {
+ organizationId,
+ taskTemplateId: { in: taskTemplateIds },
+ },
+ select: { taskTemplateId: true },
+ });
+ const existingTaskTemplateIds = new Set(
+ existingTasks
+ .map((t) => t.taskTemplateId)
+ .filter((id): id is string => id !== null),
+ );
+
+ const tasksToCreate = taskTemplates.filter(
+ (t) => !existingTaskTemplateIds.has(t.id),
+ );
+ if (tasksToCreate.length > 0) {
+ await tx.task.createMany({
+ data: tasksToCreate.map((tt) => ({
+ title: tt.name,
+ description: tt.description,
+ automationStatus: tt.automationStatus,
+ organizationId,
+ taskTemplateId: tt.id,
+ })),
+ });
+ }
+
+ // Establish relations
+ const allControls = await tx.control.findMany({
+ where: {
+ organizationId,
+ controlTemplateId: { in: controlTemplateIds },
+ },
+ select: { id: true, controlTemplateId: true },
+ });
+ const allPolicies = await tx.policy.findMany({
+ where: {
+ organizationId,
+ policyTemplateId: { in: policyTemplateIds },
+ },
+ select: { id: true, policyTemplateId: true },
+ });
+ const allTasks = await tx.task.findMany({
+ where: {
+ organizationId,
+ taskTemplateId: { in: taskTemplateIds },
+ },
+ select: { id: true, taskTemplateId: true },
+ });
+
+ const controlMap = new Map(
+ allControls
+ .filter((c) => c.controlTemplateId != null)
+ .map((c) => [c.controlTemplateId!, c.id]),
+ );
+ const policyMap = new Map(
+ allPolicies
+ .filter((p) => p.policyTemplateId != null)
+ .map((p) => [p.policyTemplateId!, p.id]),
+ );
+ const taskMap = new Map(
+ allTasks
+ .filter((t) => t.taskTemplateId != null)
+ .map((t) => [t.taskTemplateId!, t.id]),
+ );
+
+ const requirementMapEntries: Prisma.RequirementMapCreateManyInput[] = [];
+
+ for (const relation of groupedRelations) {
+ const controlId = controlMap.get(relation.controlTemplateId);
+ if (!controlId) continue;
+
+ const updateData: Prisma.ControlUpdateInput = {};
+ let needsUpdate = false;
+
+ // Process requirements for RequirementMap
+ for (const reqTemplateId of relation.requirementTemplateIds) {
+ let frameworkEditorId: string | undefined;
+ for (const fw of frameworkEditorFrameworks) {
+ if (fw.requirements.some((r) => r.id === reqTemplateId)) {
+ frameworkEditorId = fw.id;
+ break;
+ }
+ }
+ const frameworkInstanceId = frameworkEditorId
+ ? editorToInstanceMap.get(frameworkEditorId)
+ : undefined;
+
+ if (frameworkInstanceId) {
+ requirementMapEntries.push({
+ controlId,
+ requirementId: reqTemplateId,
+ frameworkInstanceId,
+ });
+ }
+ }
+
+ // Connect policies
+ const policiesToConnect = relation.policyTemplateIds
+ .map((ptId) => policyMap.get(ptId))
+ .filter((id): id is string => !!id)
+ .map((id) => ({ id }));
+
+ if (policiesToConnect.length > 0) {
+ updateData.policies = { connect: policiesToConnect };
+ needsUpdate = true;
+ }
+
+ // Connect tasks
+ const tasksToConnect = relation.taskTemplateIds
+ .map((ttId) => taskMap.get(ttId))
+ .filter((id): id is string => !!id)
+ .map((id) => ({ id }));
+
+ if (tasksToConnect.length > 0) {
+ updateData.tasks = { connect: tasksToConnect };
+ needsUpdate = true;
+ }
+
+ if (needsUpdate) {
+ await tx.control.update({
+ where: { id: controlId },
+ data: updateData,
+ });
+ }
+ }
+
+ // Create RequirementMap entries
+ if (requirementMapEntries.length > 0) {
+ await tx.requirementMap.createMany({
+ data: requirementMapEntries,
+ skipDuplicates: true,
+ });
+ }
+
+ return {
+ processedFrameworks: frameworkEditorFrameworks,
+ controlTemplates,
+ policyTemplates,
+ taskTemplates,
+ };
+}
diff --git a/apps/api/src/frameworks/frameworks.controller.spec.ts b/apps/api/src/frameworks/frameworks.controller.spec.ts
new file mode 100644
index 0000000000..01554ddd53
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks.controller.spec.ts
@@ -0,0 +1,83 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { NotFoundException } from '@nestjs/common';
+import { FrameworksController } from './frameworks.controller';
+import { FrameworksService } from './frameworks.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+describe('FrameworksController', () => {
+ let controller: FrameworksController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ findAll: jest.fn(),
+ delete: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [FrameworksController],
+ providers: [{ provide: FrameworksService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(FrameworksController);
+ service = module.get(FrameworksService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('findAll', () => {
+ it('should return framework instances with count', async () => {
+ const mockData = [
+ { id: 'fi1', frameworkId: 'f1', framework: { id: 'f1', name: 'ISO 27001' } },
+ { id: 'fi2', frameworkId: 'f2', framework: { id: 'f2', name: 'SOC 2' } },
+ ];
+ mockService.findAll.mockResolvedValue(mockData);
+
+ const result = await controller.findAll('org_1');
+
+ expect(result).toEqual({ data: mockData, count: 2 });
+ expect(service.findAll).toHaveBeenCalledWith('org_1');
+ });
+
+ it('should return empty list when no frameworks', async () => {
+ mockService.findAll.mockResolvedValue([]);
+
+ const result = await controller.findAll('org_1');
+
+ expect(result).toEqual({ data: [], count: 0 });
+ });
+ });
+
+ describe('delete', () => {
+ it('should delegate to service and return result', async () => {
+ mockService.delete.mockResolvedValue({ success: true });
+
+ const result = await controller.delete('org_1', 'fi1');
+
+ expect(result).toEqual({ success: true });
+ expect(service.delete).toHaveBeenCalledWith('fi1', 'org_1');
+ });
+
+ it('should propagate NotFoundException from service', async () => {
+ mockService.delete.mockRejectedValue(
+ new NotFoundException('Framework instance not found'),
+ );
+
+ await expect(controller.delete('org_1', 'missing')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+});
diff --git a/apps/api/src/frameworks/frameworks.controller.ts b/apps/api/src/frameworks/frameworks.controller.ts
new file mode 100644
index 0000000000..e3e03cb9ea
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks.controller.ts
@@ -0,0 +1,110 @@
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Post,
+ Query,
+ UseGuards,
+} from '@nestjs/common';
+import { ApiTags, ApiBearerAuth, ApiOperation, ApiQuery } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
+import { SkipOrgCheck } from '../auth/skip-org-check.decorator';
+import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
+import type { AuthContext as AuthContextType } from '../auth/types';
+import { FrameworksService } from './frameworks.service';
+import { AddFrameworksDto } from './dto/add-frameworks.dto';
+
+@ApiTags('Frameworks')
+@ApiBearerAuth()
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@Controller({ path: 'frameworks', version: '1' })
+export class FrameworksController {
+ constructor(private readonly frameworksService: FrameworksService) {}
+
+ @Get()
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'List framework instances for the organization' })
+ @ApiQuery({ name: 'includeControls', required: false, type: Boolean })
+ @ApiQuery({ name: 'includeScores', required: false, type: Boolean })
+ async findAll(
+ @OrganizationId() organizationId: string,
+ @Query('includeControls') includeControls?: string,
+ @Query('includeScores') includeScores?: string,
+ ) {
+ const data = await this.frameworksService.findAll(organizationId, {
+ includeControls: includeControls === 'true',
+ includeScores: includeScores === 'true',
+ });
+ return { data, count: data.length };
+ }
+
+ @Get('available')
+ @SkipOrgCheck()
+ @ApiOperation({ summary: 'List available frameworks (requires session, no active org needed — used during onboarding)' })
+ async findAvailable() {
+ const data = await this.frameworksService.findAvailable();
+ return { data, count: data.length };
+ }
+
+ @Get('scores')
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'Get overview compliance scores' })
+ async getScores(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ return this.frameworksService.getScores(organizationId, authContext.userId);
+ }
+
+ @Get(':id')
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'Get a single framework instance with full detail' })
+ async findOne(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ return this.frameworksService.findOne(id, organizationId);
+ }
+
+ @Get(':id/requirements/:requirementKey')
+ @RequirePermission('framework', 'read')
+ @ApiOperation({ summary: 'Get a specific requirement with related controls' })
+ async findRequirement(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ @Param('requirementKey') requirementKey: string,
+ ) {
+ return this.frameworksService.findRequirement(
+ id,
+ requirementKey,
+ organizationId,
+ );
+ }
+
+ @Post()
+ @RequirePermission('framework', 'create')
+ @ApiOperation({ summary: 'Add frameworks to the organization' })
+ async addFrameworks(
+ @OrganizationId() organizationId: string,
+ @Body() dto: AddFrameworksDto,
+ ) {
+ return this.frameworksService.addFrameworks(
+ organizationId,
+ dto.frameworkIds,
+ );
+ }
+
+ @Delete(':id')
+ @RequirePermission('framework', 'delete')
+ @ApiOperation({ summary: 'Delete a framework instance' })
+ async delete(
+ @OrganizationId() organizationId: string,
+ @Param('id') id: string,
+ ) {
+ return this.frameworksService.delete(id, organizationId);
+ }
+}
diff --git a/apps/api/src/frameworks/frameworks.module.ts b/apps/api/src/frameworks/frameworks.module.ts
new file mode 100644
index 0000000000..b6d956e7fd
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks.module.ts
@@ -0,0 +1,12 @@
+import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
+import { FrameworksController } from './frameworks.controller';
+import { FrameworksService } from './frameworks.service';
+
+@Module({
+ imports: [AuthModule],
+ controllers: [FrameworksController],
+ providers: [FrameworksService],
+ exports: [FrameworksService],
+})
+export class FrameworksModule {}
diff --git a/apps/api/src/frameworks/frameworks.service.spec.ts b/apps/api/src/frameworks/frameworks.service.spec.ts
new file mode 100644
index 0000000000..dcf6047354
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks.service.spec.ts
@@ -0,0 +1,136 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { NotFoundException } from '@nestjs/common';
+import { FrameworksService } from './frameworks.service';
+
+jest.mock('@trycompai/db', () => ({
+ db: {
+ frameworkInstance: {
+ findMany: jest.fn(),
+ findUnique: jest.fn(),
+ delete: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('./frameworks-scores.helper', () => ({
+ getOverviewScores: jest.fn(),
+ getCurrentMember: jest.fn(),
+ computeFrameworkComplianceScore: jest.fn(),
+}));
+
+import { db } from '@trycompai/db';
+import {
+ getOverviewScores,
+ getCurrentMember,
+} from './frameworks-scores.helper';
+
+const mockDb = db as jest.Mocked;
+
+describe('FrameworksService', () => {
+ let service: FrameworksService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [FrameworksService],
+ }).compile();
+
+ service = module.get(FrameworksService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('findAll', () => {
+ it('should return framework instances with framework relation', async () => {
+ const mockInstances = [
+ {
+ id: 'fi1',
+ organizationId: 'org_1',
+ frameworkId: 'f1',
+ framework: { id: 'f1', name: 'ISO 27001' },
+ },
+ ];
+ (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue(
+ mockInstances,
+ );
+
+ const result = await service.findAll('org_1');
+
+ expect(result).toEqual(mockInstances);
+ expect(mockDb.frameworkInstance.findMany).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ include: { framework: true },
+ });
+ });
+
+ it('should return empty array when no instances exist', async () => {
+ (mockDb.frameworkInstance.findMany as jest.Mock).mockResolvedValue([]);
+
+ const result = await service.findAll('org_1');
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('delete', () => {
+ it('should delete framework instance and return success', async () => {
+ (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue({
+ id: 'fi1',
+ organizationId: 'org_1',
+ });
+ (mockDb.frameworkInstance.delete as jest.Mock).mockResolvedValue({});
+
+ const result = await service.delete('fi1', 'org_1');
+
+ expect(result).toEqual({ success: true });
+ expect(mockDb.frameworkInstance.findUnique).toHaveBeenCalledWith({
+ where: { id: 'fi1', organizationId: 'org_1' },
+ });
+ expect(mockDb.frameworkInstance.delete).toHaveBeenCalledWith({
+ where: { id: 'fi1' },
+ });
+ });
+
+ it('should throw NotFoundException when instance not found', async () => {
+ (mockDb.frameworkInstance.findUnique as jest.Mock).mockResolvedValue(null);
+
+ await expect(service.delete('missing', 'org_1')).rejects.toThrow(
+ NotFoundException,
+ );
+ expect(mockDb.frameworkInstance.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('getScores', () => {
+ it('should call getOverviewScores and getCurrentMember when userId is provided', async () => {
+ const mockScores = { policies: 10, tasks: 5 };
+ const mockMember = { id: 'mem_1', userId: 'user_1' };
+
+ (getOverviewScores as jest.Mock).mockResolvedValue(mockScores);
+ (getCurrentMember as jest.Mock).mockResolvedValue(mockMember);
+
+ const result = await service.getScores('org_1', 'user_1');
+
+ expect(getOverviewScores).toHaveBeenCalledWith('org_1');
+ expect(getCurrentMember).toHaveBeenCalledWith('org_1', 'user_1');
+ expect(result).toEqual({
+ ...mockScores,
+ currentMember: mockMember,
+ });
+ });
+
+ it('should call getOverviewScores but NOT getCurrentMember when userId is undefined', async () => {
+ const mockScores = { policies: 10, tasks: 5 };
+
+ (getOverviewScores as jest.Mock).mockResolvedValue(mockScores);
+
+ const result = await service.getScores('org_1');
+
+ expect(getOverviewScores).toHaveBeenCalledWith('org_1');
+ expect(getCurrentMember).not.toHaveBeenCalled();
+ expect(result).toEqual({
+ ...mockScores,
+ currentMember: null,
+ });
+ });
+ });
+});
diff --git a/apps/api/src/frameworks/frameworks.service.ts b/apps/api/src/frameworks/frameworks.service.ts
new file mode 100644
index 0000000000..4b69e33ec9
--- /dev/null
+++ b/apps/api/src/frameworks/frameworks.service.ts
@@ -0,0 +1,263 @@
+import {
+ BadRequestException,
+ Injectable,
+ NotFoundException,
+} from '@nestjs/common';
+import { db } from '@trycompai/db';
+import {
+ getOverviewScores,
+ getCurrentMember,
+ computeFrameworkComplianceScore,
+} from './frameworks-scores.helper';
+import { upsertOrgFrameworkStructure } from './frameworks-upsert.helper';
+
+@Injectable()
+export class FrameworksService {
+ async findAll(
+ organizationId: string,
+ options?: { includeControls?: boolean; includeScores?: boolean },
+ ) {
+ const includeControls = options?.includeControls ?? false;
+ const includeScores = options?.includeScores ?? false;
+
+ const frameworkInstances = await db.frameworkInstance.findMany({
+ where: { organizationId },
+ include: {
+ framework: true,
+ ...(includeControls && {
+ requirementsMapped: {
+ include: {
+ control: {
+ include: {
+ policies: {
+ select: { id: true, name: true, status: true },
+ },
+ requirementsMapped: true,
+ },
+ },
+ },
+ },
+ }),
+ },
+ });
+
+ if (!includeControls) {
+ return frameworkInstances;
+ }
+
+ // Deduplicate controls from requirementsMapped
+ const frameworksWithControls = frameworkInstances.map((fi: any) => {
+ const controlsMap = new Map();
+ for (const rm of fi.requirementsMapped || []) {
+ if (rm.control && !controlsMap.has(rm.control.id)) {
+ const { requirementsMapped: _, ...controlData } = rm.control;
+ controlsMap.set(rm.control.id, {
+ ...controlData,
+ policies: rm.control.policies || [],
+ requirementsMapped: rm.control.requirementsMapped || [],
+ });
+ }
+ }
+ const { requirementsMapped: _, ...rest } = fi;
+ return { ...rest, controls: Array.from(controlsMap.values()) };
+ });
+
+ if (!includeScores) {
+ return frameworksWithControls;
+ }
+
+ // Fetch tasks for scoring
+ const tasks = await db.task.findMany({
+ where: {
+ organizationId,
+ controls: { some: { organizationId } },
+ },
+ include: { controls: true },
+ });
+
+ return frameworksWithControls.map((fw: any) => ({
+ ...fw,
+ complianceScore: computeFrameworkComplianceScore(fw, tasks),
+ }));
+ }
+
+ async findOne(frameworkInstanceId: string, organizationId: string) {
+ const fi = await db.frameworkInstance.findUnique({
+ where: { id: frameworkInstanceId, organizationId },
+ include: {
+ framework: true,
+ requirementsMapped: {
+ include: {
+ control: {
+ include: {
+ policies: {
+ select: { id: true, name: true, status: true },
+ },
+ requirementsMapped: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ if (!fi) {
+ throw new NotFoundException('Framework instance not found');
+ }
+
+ // Deduplicate controls
+ const controlsMap = new Map();
+ for (const rm of fi.requirementsMapped) {
+ if (rm.control && !controlsMap.has(rm.control.id)) {
+ const { requirementsMapped: _, ...controlData } = rm.control;
+ controlsMap.set(rm.control.id, {
+ ...controlData,
+ policies: rm.control.policies || [],
+ requirementsMapped: rm.control.requirementsMapped || [],
+ });
+ }
+ }
+ const { requirementsMapped: _, ...rest } = fi;
+
+ // Fetch additional data
+ const [requirementDefinitions, tasks, requirementMaps] =
+ await Promise.all([
+ db.frameworkEditorRequirement.findMany({
+ where: { frameworkId: fi.frameworkId },
+ orderBy: { name: 'asc' },
+ }),
+ db.task.findMany({
+ where: { organizationId, controls: { some: { organizationId } } },
+ include: { controls: true },
+ }),
+ db.requirementMap.findMany({
+ where: { frameworkInstanceId },
+ include: { control: true },
+ }),
+ ]);
+
+ return {
+ ...rest,
+ controls: Array.from(controlsMap.values()),
+ requirementDefinitions,
+ tasks,
+ requirementMaps,
+ };
+ }
+
+ async findAvailable() {
+ const frameworks = await db.frameworkEditorFramework.findMany({
+ where: { visible: true },
+ include: { requirements: true },
+ });
+ return frameworks;
+ }
+
+ async getScores(organizationId: string, userId?: string) {
+ const [scores, currentMember] = await Promise.all([
+ getOverviewScores(organizationId),
+ userId ? getCurrentMember(organizationId, userId) : Promise.resolve(null),
+ ]);
+ return { ...scores, currentMember };
+ }
+
+ async addFrameworks(
+ organizationId: string,
+ frameworkIds: string[],
+ ) {
+ const result = await db.$transaction(async (tx) => {
+ const frameworks = await tx.frameworkEditorFramework.findMany({
+ where: { id: { in: frameworkIds }, visible: true },
+ include: { requirements: true },
+ });
+
+ if (frameworks.length === 0) {
+ throw new BadRequestException(
+ 'No valid or visible frameworks found for the provided IDs.',
+ );
+ }
+
+ const finalIds = frameworks.map((f) => f.id);
+
+ await upsertOrgFrameworkStructure({
+ organizationId,
+ targetFrameworkEditorIds: finalIds,
+ frameworkEditorFrameworks: frameworks,
+ tx,
+ });
+
+ return { success: true, frameworksAdded: finalIds.length };
+ });
+
+ return result;
+ }
+
+ async findRequirement(
+ frameworkInstanceId: string,
+ requirementKey: string,
+ organizationId: string,
+ ) {
+ const fi = await db.frameworkInstance.findUnique({
+ where: { id: frameworkInstanceId, organizationId },
+ select: { id: true, frameworkId: true },
+ });
+
+ if (!fi) {
+ throw new NotFoundException('Framework instance not found');
+ }
+
+ const [allReqDefs, relatedControls, tasks] = await Promise.all([
+ db.frameworkEditorRequirement.findMany({
+ where: { frameworkId: fi.frameworkId },
+ }),
+ db.requirementMap.findMany({
+ where: { frameworkInstanceId, requirementId: requirementKey },
+ include: {
+ control: {
+ include: {
+ policies: {
+ select: { id: true, name: true, status: true },
+ },
+ },
+ },
+ },
+ }),
+ db.task.findMany({
+ where: { organizationId },
+ include: { controls: true },
+ }),
+ ]);
+
+ const requirement = allReqDefs.find((r) => r.id === requirementKey);
+ if (!requirement) {
+ throw new NotFoundException('Requirement not found');
+ }
+
+ const siblingRequirements = allReqDefs
+ .filter((r) => r.id !== requirementKey)
+ .map((r) => ({ id: r.id, name: r.name }));
+
+ return {
+ requirement,
+ relatedControls,
+ tasks,
+ siblingRequirements,
+ };
+ }
+
+ async delete(frameworkInstanceId: string, organizationId: string) {
+ const frameworkInstance = await db.frameworkInstance.findUnique({
+ where: { id: frameworkInstanceId, organizationId },
+ });
+
+ if (!frameworkInstance) {
+ throw new NotFoundException('Framework instance not found');
+ }
+
+ await db.frameworkInstance.delete({
+ where: { id: frameworkInstanceId },
+ });
+
+ return { success: true };
+ }
+}
diff --git a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts
index 443dd33d78..c5929379d8 100644
--- a/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts
+++ b/apps/api/src/integration-platform/controllers/admin-integrations.controller.ts
@@ -66,6 +66,12 @@ export class AdminIntegrationsController {
hasCredentials: configuredProviders.has(manifest.id),
credentialConfiguredAt: credential?.createdAt,
credentialUpdatedAt: credential?.updatedAt,
+ // Encrypted credential data (decrypted client-side)
+ encryptedClientId: credential?.encryptedClientId,
+ encryptedClientSecret: credential?.encryptedClientSecret,
+ existingCustomSettings:
+ (credential as { customSettings?: Record } | undefined)
+ ?.customSettings || undefined,
// OAuth-specific info
...(manifest.auth.type === 'oauth2' && {
setupInstructions: manifest.auth.config.setupInstructions,
diff --git a/apps/api/src/integration-platform/controllers/checks.controller.ts b/apps/api/src/integration-platform/controllers/checks.controller.ts
index f97e98b1bc..e6c3fffc1a 100644
--- a/apps/api/src/integration-platform/controllers/checks.controller.ts
+++ b/apps/api/src/integration-platform/controllers/checks.controller.ts
@@ -7,7 +7,12 @@ import {
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
import {
getManifest,
getAvailableChecks,
@@ -24,6 +29,9 @@ interface RunChecksDto {
}
@Controller({ path: 'integrations/checks', version: '1' })
+@ApiTags('Integrations')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class ChecksController {
private readonly logger = new Logger(ChecksController.name);
@@ -38,6 +46,7 @@ export class ChecksController {
* List available checks for a provider
*/
@Get('providers/:providerSlug')
+ @RequirePermission('integration', 'read')
async listProviderChecks(@Param('providerSlug') providerSlug: string) {
const manifest = getManifest(providerSlug);
if (!manifest) {
@@ -58,6 +67,7 @@ export class ChecksController {
* List available checks for a connection
*/
@Get('connections/:connectionId')
+ @RequirePermission('integration', 'read')
async listConnectionChecks(@Param('connectionId') connectionId: string) {
const connection = await this.connectionRepository.findById(connectionId);
if (!connection) {
@@ -92,6 +102,7 @@ export class ChecksController {
* Run checks for a connection
*/
@Post('connections/:connectionId/run')
+ @RequirePermission('integration', 'update')
async runConnectionChecks(
@Param('connectionId') connectionId: string,
@Body() body: RunChecksDto,
@@ -291,6 +302,7 @@ export class ChecksController {
* Run a specific check for a connection
*/
@Post('connections/:connectionId/run/:checkId')
+ @RequirePermission('integration', 'update')
async runSingleCheck(
@Param('connectionId') connectionId: string,
@Param('checkId') checkId: string,
diff --git a/apps/api/src/integration-platform/controllers/connections.controller.spec.ts b/apps/api/src/integration-platform/controllers/connections.controller.spec.ts
new file mode 100644
index 0000000000..4f88334b23
--- /dev/null
+++ b/apps/api/src/integration-platform/controllers/connections.controller.spec.ts
@@ -0,0 +1,600 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { HttpException, HttpStatus } from '@nestjs/common';
+import { ConnectionsController } from './connections.controller';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { ConnectionService } from '../services/connection.service';
+import { CredentialVaultService } from '../services/credential-vault.service';
+import { OAuthCredentialsService } from '../services/oauth-credentials.service';
+import { AutoCheckRunnerService } from '../services/auto-check-runner.service';
+import { ProviderRepository } from '../repositories/provider.repository';
+
+jest.mock('../../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ integration: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+jest.mock('@comp/integration-platform', () => ({
+ getManifest: jest.fn(),
+ getAllManifests: jest.fn(),
+ getActiveManifests: jest.fn(),
+ TASK_TEMPLATE_INFO: {},
+}));
+
+import {
+ getManifest,
+ getAllManifests,
+ getActiveManifests,
+} from '@comp/integration-platform';
+
+const mockedGetManifest = getManifest as jest.MockedFunction<
+ typeof getManifest
+>;
+const mockedGetAllManifests = getAllManifests as jest.MockedFunction<
+ typeof getAllManifests
+>;
+const mockedGetActiveManifests = getActiveManifests as jest.MockedFunction<
+ typeof getActiveManifests
+>;
+
+describe('ConnectionsController', () => {
+ let controller: ConnectionsController;
+
+ const mockConnectionService = {
+ getOrganizationConnections: jest.fn(),
+ getConnection: jest.fn(),
+ createConnection: jest.fn(),
+ activateConnection: jest.fn(),
+ pauseConnection: jest.fn(),
+ disconnectConnection: jest.fn(),
+ deleteConnection: jest.fn(),
+ setConnectionError: jest.fn(),
+ updateConnectionMetadata: jest.fn(),
+ };
+
+ const mockCredentialVaultService = {
+ storeApiKeyCredentials: jest.fn(),
+ getDecryptedCredentials: jest.fn(),
+ needsRefresh: jest.fn(),
+ refreshOAuthTokens: jest.fn(),
+ };
+
+ const mockOAuthCredentialsService = {
+ checkAvailability: jest.fn(),
+ getCredentials: jest.fn(),
+ };
+
+ const mockAutoCheckRunnerService = {
+ tryAutoRunChecks: jest.fn().mockResolvedValue(false),
+ };
+
+ const mockProviderRepository = {
+ upsert: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [ConnectionsController],
+ providers: [
+ { provide: ConnectionService, useValue: mockConnectionService },
+ {
+ provide: CredentialVaultService,
+ useValue: mockCredentialVaultService,
+ },
+ {
+ provide: OAuthCredentialsService,
+ useValue: mockOAuthCredentialsService,
+ },
+ {
+ provide: AutoCheckRunnerService,
+ useValue: mockAutoCheckRunnerService,
+ },
+ { provide: ProviderRepository, useValue: mockProviderRepository },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(ConnectionsController);
+
+ jest.clearAllMocks();
+ mockAutoCheckRunnerService.tryAutoRunChecks.mockResolvedValue(false);
+ });
+
+ describe('listProviders', () => {
+ it('should return all manifests when activeOnly is not set', async () => {
+ const manifests = [
+ {
+ id: 'github',
+ name: 'GitHub',
+ description: 'GitHub integration',
+ category: 'dev',
+ logoUrl: '/github.svg',
+ auth: { type: 'oauth2' },
+ capabilities: ['checks'],
+ isActive: true,
+ docsUrl: 'https://docs.example.com',
+ credentialFields: [],
+ checks: [],
+ variables: [],
+ },
+ ];
+ mockedGetAllManifests.mockReturnValue(manifests as never);
+ mockOAuthCredentialsService.checkAvailability.mockResolvedValue({
+ hasPlatformCredentials: true,
+ });
+
+ const result = await controller.listProviders();
+
+ expect(mockedGetAllManifests).toHaveBeenCalled();
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('github');
+ });
+
+ it('should return active manifests when activeOnly is true', async () => {
+ mockedGetActiveManifests.mockReturnValue([]);
+
+ await controller.listProviders('true');
+
+ expect(mockedGetActiveManifests).toHaveBeenCalled();
+ });
+ });
+
+ describe('getProvider', () => {
+ it('should return provider details', async () => {
+ const manifest = {
+ id: 'github',
+ name: 'GitHub',
+ description: 'GitHub integration',
+ category: 'dev',
+ logoUrl: '/github.svg',
+ auth: { type: 'oauth2' },
+ capabilities: ['checks'],
+ isActive: true,
+ docsUrl: 'https://docs.example.com',
+ credentialFields: [],
+ checks: [],
+ variables: [],
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+
+ const result = await controller.getProvider('github');
+
+ expect(result.id).toBe('github');
+ expect(result.name).toBe('GitHub');
+ });
+
+ it('should throw NOT_FOUND when provider does not exist', async () => {
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ await expect(controller.getProvider('nonexistent')).rejects.toThrow(
+ HttpException,
+ );
+ });
+ });
+
+ describe('listConnections', () => {
+ it('should call service.getOrganizationConnections', async () => {
+ const connections = [
+ {
+ id: 'conn_1',
+ providerId: 'prov_1',
+ provider: { slug: 'github', name: 'GitHub' },
+ status: 'active',
+ authStrategy: 'oauth2',
+ lastSyncAt: null,
+ nextSyncAt: null,
+ errorMessage: null,
+ variables: {},
+ metadata: {},
+ createdAt: new Date(),
+ },
+ ];
+ mockConnectionService.getOrganizationConnections.mockResolvedValue(
+ connections,
+ );
+
+ const result = await controller.listConnections('org_1');
+
+ expect(
+ mockConnectionService.getOrganizationConnections,
+ ).toHaveBeenCalledWith('org_1');
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe('conn_1');
+ expect(result[0].providerSlug).toBe('github');
+ });
+ });
+
+ describe('getConnection', () => {
+ it('should return connection details', async () => {
+ const connection = {
+ id: 'conn_1',
+ providerId: 'prov_1',
+ provider: { slug: 'github', name: 'GitHub' },
+ status: 'active',
+ authStrategy: 'oauth2',
+ lastSyncAt: null,
+ nextSyncAt: null,
+ syncCadence: null,
+ metadata: {},
+ variables: {},
+ errorMessage: null,
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+ mockConnectionService.getConnection.mockResolvedValue(connection);
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ const result = await controller.getConnection('conn_1');
+
+ expect(mockConnectionService.getConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ expect(result.id).toBe('conn_1');
+ expect(result.providerSlug).toBe('github');
+ });
+ });
+
+ describe('createConnection', () => {
+ it('should create a connection for non-OAuth provider', async () => {
+ const manifest = {
+ id: 'datadog',
+ name: 'Datadog',
+ category: 'monitoring',
+ auth: { type: 'api_key', config: { name: 'api_key' } },
+ capabilities: ['checks'],
+ isActive: true,
+ credentialFields: [],
+ checks: [],
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+ mockProviderRepository.upsert.mockResolvedValue(undefined);
+ mockConnectionService.createConnection.mockResolvedValue({
+ id: 'conn_new',
+ providerId: 'prov_dd',
+ authStrategy: 'api_key',
+ createdAt: new Date(),
+ });
+ mockConnectionService.activateConnection.mockResolvedValue(undefined);
+
+ const result = await controller.createConnection('org_1', {
+ providerSlug: 'datadog',
+ credentials: { api_key: 'test-key' },
+ });
+
+ expect(mockProviderRepository.upsert).toHaveBeenCalled();
+ expect(mockConnectionService.createConnection).toHaveBeenCalledWith({
+ providerSlug: 'datadog',
+ organizationId: 'org_1',
+ authStrategy: 'api_key',
+ metadata: undefined,
+ });
+ expect(
+ mockCredentialVaultService.storeApiKeyCredentials,
+ ).toHaveBeenCalledWith('conn_new', { api_key: 'test-key' });
+ expect(mockConnectionService.activateConnection).toHaveBeenCalledWith(
+ 'conn_new',
+ );
+ expect(result.id).toBe('conn_new');
+ expect(result.status).toBe('active');
+ });
+
+ it('should throw NOT_FOUND when provider does not exist', async () => {
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ await expect(
+ controller.createConnection('org_1', {
+ providerSlug: 'nonexistent',
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw BAD_REQUEST for OAuth providers', async () => {
+ const manifest = {
+ id: 'github',
+ name: 'GitHub',
+ auth: { type: 'oauth2' },
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+
+ await expect(
+ controller.createConnection('org_1', {
+ providerSlug: 'github',
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+ });
+
+ describe('testConnection', () => {
+ it('should throw NOT_FOUND when provider slug is missing', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ provider: undefined,
+ });
+
+ await expect(controller.testConnection('conn_1')).rejects.toThrow(
+ HttpException,
+ );
+ });
+
+ it('should throw BAD_REQUEST when no credentials found', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ provider: { slug: 'datadog' },
+ });
+ mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue(
+ null,
+ );
+
+ await expect(controller.testConnection('conn_1')).rejects.toThrow(
+ HttpException,
+ );
+ });
+
+ it('should activate connection when no handler is defined', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ provider: { slug: 'custom-provider' },
+ });
+ mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue({
+ api_key: 'test',
+ });
+ mockedGetManifest.mockReturnValue({
+ auth: { type: 'api_key' },
+ handler: undefined,
+ } as never);
+ mockConnectionService.activateConnection.mockResolvedValue(undefined);
+
+ const result = await controller.testConnection('conn_1');
+
+ expect(mockConnectionService.activateConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('pauseConnection', () => {
+ it('should call service.pauseConnection', async () => {
+ mockConnectionService.pauseConnection.mockResolvedValue({
+ id: 'conn_1',
+ status: 'paused',
+ });
+
+ const result = await controller.pauseConnection('conn_1');
+
+ expect(mockConnectionService.pauseConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ expect(result).toEqual({ id: 'conn_1', status: 'paused' });
+ });
+ });
+
+ describe('resumeConnection', () => {
+ it('should call service.activateConnection', async () => {
+ mockConnectionService.activateConnection.mockResolvedValue({
+ id: 'conn_1',
+ status: 'active',
+ });
+
+ const result = await controller.resumeConnection('conn_1');
+
+ expect(mockConnectionService.activateConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ expect(result).toEqual({ id: 'conn_1', status: 'active' });
+ });
+ });
+
+ describe('disconnectConnection', () => {
+ it('should call service.disconnectConnection', async () => {
+ mockConnectionService.disconnectConnection.mockResolvedValue({
+ id: 'conn_1',
+ status: 'disconnected',
+ });
+
+ const result = await controller.disconnectConnection('conn_1');
+
+ expect(mockConnectionService.disconnectConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ expect(result).toEqual({ id: 'conn_1', status: 'disconnected' });
+ });
+ });
+
+ describe('deleteConnection', () => {
+ it('should call service.deleteConnection', async () => {
+ mockConnectionService.deleteConnection.mockResolvedValue(undefined);
+
+ const result = await controller.deleteConnection('conn_1');
+
+ expect(mockConnectionService.deleteConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ expect(result).toEqual({ success: true });
+ });
+ });
+
+ describe('updateConnection', () => {
+ it('should merge metadata and update', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ metadata: { existing: 'value' },
+ });
+ mockConnectionService.updateConnectionMetadata.mockResolvedValue(
+ undefined,
+ );
+
+ const result = await controller.updateConnection('conn_1', 'org_1', {
+ metadata: { newField: 'newValue' },
+ });
+
+ expect(
+ mockConnectionService.updateConnectionMetadata,
+ ).toHaveBeenCalledWith('conn_1', {
+ existing: 'value',
+ newField: 'newValue',
+ });
+ expect(result).toEqual({ success: true });
+ });
+
+ it('should throw FORBIDDEN when org does not match', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_other',
+ metadata: {},
+ });
+
+ await expect(
+ controller.updateConnection('conn_1', 'org_1', {
+ metadata: { key: 'val' },
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+ });
+
+ describe('ensureValidCredentials', () => {
+ it('should throw NOT_FOUND when org does not match', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_other',
+ status: 'active',
+ });
+
+ await expect(
+ controller.ensureValidCredentials('conn_1', 'org_1'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw BAD_REQUEST when connection is not active', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ status: 'paused',
+ });
+
+ await expect(
+ controller.ensureValidCredentials('conn_1', 'org_1'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should return credentials for api_key auth', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ status: 'active',
+ provider: { slug: 'datadog' },
+ });
+ mockedGetManifest.mockReturnValue({
+ auth: { type: 'api_key', config: { name: 'api_key' } },
+ } as never);
+ mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue({
+ api_key: 'test-key',
+ });
+
+ const result = await controller.ensureValidCredentials(
+ 'conn_1',
+ 'org_1',
+ );
+
+ expect(result.success).toBe(true);
+ expect(result.credentials).toEqual({ api_key: 'test-key' });
+ });
+ });
+
+ describe('updateCredentials', () => {
+ it('should throw NOT_FOUND when org does not match', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_other',
+ provider: { slug: 'datadog' },
+ });
+
+ await expect(
+ controller.updateCredentials('conn_1', 'org_1', {
+ credentials: { api_key: 'new' },
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw BAD_REQUEST for OAuth integrations', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ provider: { slug: 'github' },
+ });
+ mockedGetManifest.mockReturnValue({
+ auth: { type: 'oauth2' },
+ } as never);
+
+ await expect(
+ controller.updateCredentials('conn_1', 'org_1', {
+ credentials: { token: 'new' },
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should merge and store credentials', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ status: 'active',
+ provider: { slug: 'datadog' },
+ });
+ mockedGetManifest.mockReturnValue({
+ id: 'datadog',
+ auth: { type: 'api_key', config: { name: 'api_key' } },
+ } as never);
+ mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue({
+ api_key: 'old-key',
+ app_key: 'existing',
+ });
+
+ const result = await controller.updateCredentials('conn_1', 'org_1', {
+ credentials: { api_key: 'new-key' },
+ });
+
+ expect(
+ mockCredentialVaultService.storeApiKeyCredentials,
+ ).toHaveBeenCalledWith('conn_1', {
+ api_key: 'new-key',
+ app_key: 'existing',
+ });
+ expect(result).toEqual({ success: true });
+ });
+
+ it('should activate connection if it was in error state', async () => {
+ mockConnectionService.getConnection.mockResolvedValue({
+ id: 'conn_1',
+ organizationId: 'org_1',
+ status: 'error',
+ provider: { slug: 'datadog' },
+ });
+ mockedGetManifest.mockReturnValue({
+ id: 'datadog',
+ auth: { type: 'api_key', config: { name: 'api_key' } },
+ } as never);
+ mockCredentialVaultService.getDecryptedCredentials.mockResolvedValue({});
+
+ await controller.updateCredentials('conn_1', 'org_1', {
+ credentials: { api_key: 'new-key' },
+ });
+
+ expect(mockConnectionService.activateConnection).toHaveBeenCalledWith(
+ 'conn_1',
+ );
+ });
+ });
+});
diff --git a/apps/api/src/integration-platform/controllers/connections.controller.ts b/apps/api/src/integration-platform/controllers/connections.controller.ts
index 944164606e..b50542bf75 100644
--- a/apps/api/src/integration-platform/controllers/connections.controller.ts
+++ b/apps/api/src/integration-platform/controllers/connections.controller.ts
@@ -11,12 +11,18 @@ import {
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts';
import {
DescribeHubCommand,
SecurityHubClient,
} from '@aws-sdk/client-securityhub';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
+import { OrganizationId } from '../../auth/auth-context.decorator';
import { ConnectionService } from '../services/connection.service';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
@@ -34,14 +40,9 @@ import {
interface CreateConnectionDto {
providerSlug: string;
- organizationId: string;
credentials?: Record;
}
-interface ListConnectionsQuery {
- organizationId: string;
-}
-
const hasCredentialValue = (value?: string | string[]): boolean => {
if (Array.isArray(value)) {
return value.length > 0;
@@ -50,6 +51,9 @@ const hasCredentialValue = (value?: string | string[]): boolean => {
};
@Controller({ path: 'integrations/connections', version: '1' })
+@ApiTags('Integrations')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class ConnectionsController {
private readonly logger = new Logger(ConnectionsController.name);
@@ -65,6 +69,7 @@ export class ConnectionsController {
* List all available integration providers
*/
@Get('providers')
+ @RequirePermission('integration', 'read')
async listProviders(@Query('activeOnly') activeOnly?: string) {
const manifests =
activeOnly === 'true' ? getActiveManifests() : getAllManifests();
@@ -154,6 +159,7 @@ export class ConnectionsController {
* Get a specific provider's details
*/
@Get('providers/:slug')
+ @RequirePermission('integration', 'read')
async getProvider(@Param('slug') slug: string) {
const manifest = getManifest(slug);
if (!manifest) {
@@ -228,16 +234,8 @@ export class ConnectionsController {
* List connections for an organization
*/
@Get()
- async listConnections(@Query() query: ListConnectionsQuery) {
- const { organizationId } = query;
-
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
-
+ @RequirePermission('integration', 'read')
+ async listConnections(@OrganizationId() organizationId: string) {
const connections =
await this.connectionService.getOrganizationConnections(organizationId);
@@ -261,6 +259,7 @@ export class ConnectionsController {
* Get a specific connection
*/
@Get(':id')
+ @RequirePermission('integration', 'read')
async getConnection(@Param('id') id: string) {
const connection = await this.connectionService.getConnection(id);
const providerSlug = (connection as { provider?: { slug: string } })
@@ -311,8 +310,12 @@ export class ConnectionsController {
* Create a new connection with API key credentials
*/
@Post()
- async createConnection(@Body() body: CreateConnectionDto) {
- const { providerSlug, organizationId, credentials } = body;
+ @RequirePermission('integration', 'create')
+ async createConnection(
+ @OrganizationId() organizationId: string,
+ @Body() body: CreateConnectionDto,
+ ) {
+ const { providerSlug, credentials } = body;
// Validate provider
const manifest = getManifest(providerSlug);
@@ -650,6 +653,7 @@ export class ConnectionsController {
* Test a connection's credentials
*/
@Post(':id/test')
+ @RequirePermission('integration', 'update')
async testConnection(@Param('id') id: string) {
const connection = await this.connectionService.getConnection(id);
const providerSlug = (connection as any).provider?.slug;
@@ -739,6 +743,7 @@ export class ConnectionsController {
* Pause a connection
*/
@Post(':id/pause')
+ @RequirePermission('integration', 'update')
async pauseConnection(@Param('id') id: string) {
const connection = await this.connectionService.pauseConnection(id);
return { id: connection.id, status: connection.status };
@@ -748,6 +753,7 @@ export class ConnectionsController {
* Resume a paused connection
*/
@Post(':id/resume')
+ @RequirePermission('integration', 'update')
async resumeConnection(@Param('id') id: string) {
const connection = await this.connectionService.activateConnection(id);
return { id: connection.id, status: connection.status };
@@ -757,6 +763,7 @@ export class ConnectionsController {
* Disconnect (soft delete) a connection
*/
@Post(':id/disconnect')
+ @RequirePermission('integration', 'delete')
async disconnectConnection(@Param('id') id: string) {
const connection = await this.connectionService.disconnectConnection(id);
return { id: connection.id, status: connection.status };
@@ -766,6 +773,7 @@ export class ConnectionsController {
* Delete a connection permanently
*/
@Delete(':id')
+ @RequirePermission('integration', 'delete')
async deleteConnection(@Param('id') id: string) {
await this.connectionService.deleteConnection(id);
return { success: true };
@@ -775,18 +783,12 @@ export class ConnectionsController {
* Update connection metadata (connectionName, regions, etc.)
*/
@Patch(':id')
+ @RequirePermission('integration', 'update')
async updateConnection(
@Param('id') id: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
@Body() body: { metadata?: Record },
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId query parameter is required',
- HttpStatus.BAD_REQUEST,
- );
- }
-
const connection = await this.connectionService.getConnection(id);
if (connection.organizationId !== organizationId) {
throw new HttpException(
@@ -817,17 +819,11 @@ export class ConnectionsController {
* Used by scheduled jobs to ensure tokens are valid before running checks.
*/
@Post(':id/ensure-valid-credentials')
+ @RequirePermission('integration', 'update')
async ensureValidCredentials(
@Param('id') id: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
-
const connection = await this.connectionService.getConnection(id);
if (connection.organizationId !== organizationId) {
@@ -986,18 +982,12 @@ export class ConnectionsController {
* Update credentials for a custom auth connection
*/
@Put(':id/credentials')
+ @RequirePermission('integration', 'update')
async updateCredentials(
@Param('id') id: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
@Body() body: { credentials: Record },
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
-
const connection = await this.connectionService.getConnection(id);
if (connection.organizationId !== organizationId) {
diff --git a/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts
new file mode 100644
index 0000000000..361d909dec
--- /dev/null
+++ b/apps/api/src/integration-platform/controllers/dynamic-integrations.controller.ts
@@ -0,0 +1,368 @@
+import {
+ Controller,
+ Get,
+ Post,
+ Put,
+ Patch,
+ Delete,
+ Body,
+ Param,
+ HttpException,
+ HttpStatus,
+ Logger,
+ UseGuards,
+} from '@nestjs/common';
+import type { Prisma } from '@prisma/client';
+import { InternalTokenGuard } from '../../auth/internal-token.guard';
+import { DynamicIntegrationRepository } from '../repositories/dynamic-integration.repository';
+import { DynamicCheckRepository } from '../repositories/dynamic-check.repository';
+import { ProviderRepository } from '../repositories/provider.repository';
+import { DynamicManifestLoaderService } from '../services/dynamic-manifest-loader.service';
+import { validateIntegrationDefinition } from '@comp/integration-platform';
+
+@Controller({ path: 'internal/dynamic-integrations', version: '1' })
+@UseGuards(InternalTokenGuard)
+export class DynamicIntegrationsController {
+ private readonly logger = new Logger(DynamicIntegrationsController.name);
+
+ constructor(
+ private readonly dynamicIntegrationRepo: DynamicIntegrationRepository,
+ private readonly dynamicCheckRepo: DynamicCheckRepository,
+ private readonly providerRepo: ProviderRepository,
+ private readonly loaderService: DynamicManifestLoaderService,
+ ) {}
+
+ /**
+ * Upsert a dynamic integration with checks from a full definition.
+ * Creates if new, updates if exists. This is the primary endpoint for AI agents.
+ */
+ @Put()
+ async upsert(@Body() body: Record) {
+ const validation = validateIntegrationDefinition(body);
+ if (!validation.success) {
+ throw new HttpException(
+ { message: 'Invalid integration definition', errors: validation.errors },
+ HttpStatus.BAD_REQUEST,
+ );
+ }
+
+ const def = validation.data!;
+
+ // Upsert the integration
+ const integration = await this.dynamicIntegrationRepo.upsertBySlug({
+ slug: def.slug,
+ name: def.name,
+ description: def.description,
+ category: def.category,
+ logoUrl: def.logoUrl,
+ docsUrl: def.docsUrl,
+ baseUrl: def.baseUrl,
+ defaultHeaders: def.defaultHeaders as unknown as Prisma.InputJsonValue,
+ authConfig: def.authConfig as unknown as Prisma.InputJsonValue,
+ capabilities: def.capabilities as unknown as Prisma.InputJsonValue,
+ supportsMultipleConnections: def.supportsMultipleConnections,
+ });
+
+ // Delete checks not in the new definition, then upsert the rest
+ const existingChecks = await this.dynamicCheckRepo.findByIntegrationId(integration.id);
+ const newCheckSlugs = new Set(def.checks.map((c) => c.checkSlug));
+ for (const existing of existingChecks) {
+ if (!newCheckSlugs.has(existing.checkSlug)) {
+ await this.dynamicCheckRepo.delete(existing.id);
+ }
+ }
+
+ for (const [index, check] of def.checks.entries()) {
+ await this.dynamicCheckRepo.upsert({
+ integrationId: integration.id,
+ checkSlug: check.checkSlug,
+ name: check.name,
+ description: check.description,
+ taskMapping: check.taskMapping,
+ defaultSeverity: check.defaultSeverity,
+ definition: check.definition as unknown as Prisma.InputJsonValue,
+ variables: (check.variables ?? []) as unknown as Prisma.InputJsonValue,
+ isEnabled: check.isEnabled ?? true,
+ sortOrder: check.sortOrder ?? index,
+ });
+ }
+
+ // Upsert IntegrationProvider row
+ await this.providerRepo.upsert({
+ slug: def.slug,
+ name: def.name,
+ category: def.category,
+ capabilities: (def.capabilities as unknown as string[]) ?? ['checks'],
+ isActive: true,
+ });
+
+ // Refresh registry
+ await this.loaderService.invalidateCache();
+
+ this.logger.log(`Upserted dynamic integration: ${def.slug} with ${def.checks.length} checks`);
+
+ return {
+ success: true,
+ id: integration.id,
+ slug: integration.slug,
+ checksCount: def.checks.length,
+ };
+ }
+
+ /**
+ * Create a dynamic integration with checks from a full definition.
+ */
+ @Post()
+ async create(@Body() body: Record) {
+ const validation = validateIntegrationDefinition(body);
+ if (!validation.success) {
+ throw new HttpException(
+ { message: 'Invalid integration definition', errors: validation.errors },
+ HttpStatus.BAD_REQUEST,
+ );
+ }
+
+ const def = validation.data!;
+
+ const existing = await this.dynamicIntegrationRepo.findBySlug(def.slug);
+ if (existing) {
+ throw new HttpException(
+ `Integration with slug "${def.slug}" already exists. Use PUT to upsert.`,
+ HttpStatus.CONFLICT,
+ );
+ }
+
+ const integration = await this.dynamicIntegrationRepo.create({
+ slug: def.slug,
+ name: def.name,
+ description: def.description,
+ category: def.category,
+ logoUrl: def.logoUrl,
+ docsUrl: def.docsUrl,
+ baseUrl: def.baseUrl,
+ defaultHeaders: def.defaultHeaders as unknown as Prisma.InputJsonValue,
+ authConfig: def.authConfig as unknown as Prisma.InputJsonValue,
+ capabilities: def.capabilities as unknown as Prisma.InputJsonValue,
+ supportsMultipleConnections: def.supportsMultipleConnections,
+ });
+
+ for (const [index, check] of def.checks.entries()) {
+ await this.dynamicCheckRepo.create({
+ integrationId: integration.id,
+ checkSlug: check.checkSlug,
+ name: check.name,
+ description: check.description,
+ taskMapping: check.taskMapping,
+ defaultSeverity: check.defaultSeverity,
+ definition: check.definition as unknown as Prisma.InputJsonValue,
+ variables: (check.variables ?? []) as unknown as Prisma.InputJsonValue,
+ isEnabled: check.isEnabled ?? true,
+ sortOrder: check.sortOrder ?? index,
+ });
+ }
+
+ // Create provider row and refresh registry
+ await this.providerRepo.upsert({
+ slug: def.slug,
+ name: def.name,
+ category: def.category,
+ capabilities: (def.capabilities as unknown as string[]) ?? ['checks'],
+ isActive: true,
+ });
+
+ await this.loaderService.invalidateCache();
+
+ this.logger.log(`Created dynamic integration: ${def.slug} with ${def.checks.length} checks`);
+
+ return { success: true, id: integration.id, slug: integration.slug };
+ }
+
+ /**
+ * List all dynamic integrations.
+ */
+ @Get()
+ async list() {
+ const integrations = await this.dynamicIntegrationRepo.findAll();
+ return integrations.map((i) => ({
+ id: i.id,
+ slug: i.slug,
+ name: i.name,
+ description: i.description,
+ category: i.category,
+ isActive: i.isActive,
+ checksCount: i.checks.length,
+ createdAt: i.createdAt,
+ updatedAt: i.updatedAt,
+ }));
+ }
+
+ /**
+ * Get details of a dynamic integration with all checks.
+ */
+ @Get(':id')
+ async getById(@Param('id') id: string) {
+ const integration = await this.dynamicIntegrationRepo.findById(id);
+ if (!integration) {
+ throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
+ }
+ return integration;
+ }
+
+ /**
+ * Update manifest fields of a dynamic integration.
+ */
+ @Patch(':id')
+ async update(@Param('id') id: string, @Body() body: Record) {
+ const existing = await this.dynamicIntegrationRepo.findById(id);
+ if (!existing) {
+ throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
+ }
+
+ await this.dynamicIntegrationRepo.update(id, body);
+ await this.loaderService.invalidateCache();
+
+ return { success: true };
+ }
+
+ /**
+ * Delete a dynamic integration (cascades to checks).
+ */
+ @Delete(':id')
+ async remove(@Param('id') id: string) {
+ const existing = await this.dynamicIntegrationRepo.findById(id);
+ if (!existing) {
+ throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
+ }
+
+ await this.dynamicIntegrationRepo.delete(id);
+ await this.loaderService.invalidateCache();
+
+ this.logger.log(`Deleted dynamic integration: ${existing.slug}`);
+ return { success: true };
+ }
+
+ // ==================== Check Management ====================
+
+ /**
+ * Add a check to a dynamic integration.
+ */
+ @Post(':id/checks')
+ async addCheck(
+ @Param('id') id: string,
+ @Body() body: Record,
+ ) {
+ const integration = await this.dynamicIntegrationRepo.findById(id);
+ if (!integration) {
+ throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
+ }
+
+ const check = await this.dynamicCheckRepo.create({
+ integrationId: id,
+ checkSlug: body.checkSlug as string,
+ name: body.name as string,
+ description: body.description as string,
+ taskMapping: body.taskMapping as string | undefined,
+ defaultSeverity: body.defaultSeverity as string | undefined,
+ definition: body.definition as Prisma.InputJsonValue,
+ variables: body.variables as Prisma.InputJsonValue | undefined,
+ isEnabled: (body.isEnabled as boolean) ?? true,
+ sortOrder: (body.sortOrder as number) ?? 0,
+ });
+
+ await this.loaderService.invalidateCache();
+
+ return { success: true, id: check.id };
+ }
+
+ /**
+ * Update a check.
+ */
+ @Patch(':id/checks/:checkId')
+ async updateCheck(
+ @Param('id') id: string,
+ @Param('checkId') checkId: string,
+ @Body() body: Record,
+ ) {
+ const check = await this.dynamicCheckRepo.findById(checkId);
+ if (!check || check.integrationId !== id) {
+ throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
+ }
+
+ await this.dynamicCheckRepo.update(checkId, body);
+ await this.loaderService.invalidateCache();
+
+ return { success: true };
+ }
+
+ /**
+ * Delete a check.
+ */
+ @Delete(':id/checks/:checkId')
+ async removeCheck(
+ @Param('id') id: string,
+ @Param('checkId') checkId: string,
+ ) {
+ const check = await this.dynamicCheckRepo.findById(checkId);
+ if (!check || check.integrationId !== id) {
+ throw new HttpException('Check not found', HttpStatus.NOT_FOUND);
+ }
+
+ await this.dynamicCheckRepo.delete(checkId);
+ await this.loaderService.invalidateCache();
+
+ return { success: true };
+ }
+
+ // ==================== Activation ====================
+
+ /**
+ * Activate a dynamic integration.
+ */
+ @Post(':id/activate')
+ async activate(@Param('id') id: string) {
+ const integration = await this.dynamicIntegrationRepo.findById(id);
+ if (!integration) {
+ throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
+ }
+
+ for (const check of integration.checks) {
+ if (!check.definition || typeof check.definition !== 'object') {
+ throw new HttpException(
+ `Check "${check.checkSlug}" has invalid definition`,
+ HttpStatus.BAD_REQUEST,
+ );
+ }
+ }
+
+ await this.providerRepo.upsert({
+ slug: integration.slug,
+ name: integration.name,
+ category: integration.category,
+ capabilities: (integration.capabilities as unknown as string[]) ?? ['checks'],
+ isActive: true,
+ });
+
+ await this.dynamicIntegrationRepo.update(id, { isActive: true });
+ await this.loaderService.invalidateCache();
+
+ this.logger.log(`Activated dynamic integration: ${integration.slug}`);
+ return { success: true };
+ }
+
+ /**
+ * Deactivate a dynamic integration.
+ */
+ @Post(':id/deactivate')
+ async deactivate(@Param('id') id: string) {
+ const integration = await this.dynamicIntegrationRepo.findById(id);
+ if (!integration) {
+ throw new HttpException('Dynamic integration not found', HttpStatus.NOT_FOUND);
+ }
+
+ await this.dynamicIntegrationRepo.update(id, { isActive: false });
+ await this.loaderService.invalidateCache();
+
+ this.logger.log(`Deactivated dynamic integration: ${integration.slug}`);
+ return { success: true };
+ }
+}
diff --git a/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts b/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts
index 5713d540c8..0b5efd6efd 100644
--- a/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts
+++ b/apps/api/src/integration-platform/controllers/oauth-apps.controller.ts
@@ -9,20 +9,28 @@ import {
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
+import { OrganizationId } from '../../auth/auth-context.decorator';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { OAuthAppRepository } from '../repositories/oauth-app.repository';
import { getManifest } from '@comp/integration-platform';
interface SaveOAuthAppDto {
providerSlug: string;
- organizationId: string;
clientId: string;
clientSecret: string;
customScopes?: string[];
}
@Controller({ path: 'integrations/oauth-apps', version: '1' })
+@ApiTags('Integrations')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class OAuthAppsController {
private readonly logger = new Logger(OAuthAppsController.name);
@@ -35,14 +43,8 @@ export class OAuthAppsController {
* List custom OAuth apps for an organization
*/
@Get()
- async listOAuthApps(@Query('organizationId') organizationId: string) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
-
+ @RequirePermission('integration', 'read')
+ async listOAuthApps(@OrganizationId() organizationId: string) {
const apps =
await this.oauthAppRepository.findByOrganization(organizationId);
@@ -60,9 +62,10 @@ export class OAuthAppsController {
* Get OAuth app setup info for a provider
*/
@Get('setup/:providerSlug')
+ @RequirePermission('integration', 'read')
async getSetupInfo(
@Param('providerSlug') providerSlug: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
const manifest = getManifest(providerSlug);
if (!manifest) {
@@ -103,10 +106,13 @@ export class OAuthAppsController {
* Save custom OAuth app credentials for an organization
*/
@Post()
- async saveOAuthApp(@Body() body: SaveOAuthAppDto) {
+ @RequirePermission('integration', 'create')
+ async saveOAuthApp(
+ @OrganizationId() organizationId: string,
+ @Body() body: SaveOAuthAppDto,
+ ) {
const {
providerSlug,
- organizationId,
clientId,
clientSecret,
customScopes,
@@ -155,17 +161,11 @@ export class OAuthAppsController {
* Delete custom OAuth app credentials for an organization
*/
@Delete(':providerSlug')
+ @RequirePermission('integration', 'delete')
async deleteOAuthApp(
@Param('providerSlug') providerSlug: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
-
await this.oauthCredentialsService.deleteOrgCredentials(
providerSlug,
organizationId,
diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts
new file mode 100644
index 0000000000..8ca75078ee
--- /dev/null
+++ b/apps/api/src/integration-platform/controllers/oauth.controller.spec.ts
@@ -0,0 +1,371 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { HttpException } from '@nestjs/common';
+import { OAuthController } from './oauth.controller';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { OAuthStateRepository } from '../repositories/oauth-state.repository';
+import { ProviderRepository } from '../repositories/provider.repository';
+import { ConnectionRepository } from '../repositories/connection.repository';
+import { CredentialVaultService } from '../services/credential-vault.service';
+import { ConnectionService } from '../services/connection.service';
+import { OAuthCredentialsService } from '../services/oauth-credentials.service';
+import { AutoCheckRunnerService } from '../services/auto-check-runner.service';
+
+jest.mock('../../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ integration: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+jest.mock('@comp/integration-platform', () => ({
+ getManifest: jest.fn(),
+}));
+
+import { getManifest } from '@comp/integration-platform';
+
+const mockedGetManifest = getManifest as jest.MockedFunction<
+ typeof getManifest
+>;
+
+describe('OAuthController', () => {
+ let controller: OAuthController;
+
+ const mockOAuthStateRepository = {
+ create: jest.fn(),
+ findByState: jest.fn(),
+ delete: jest.fn(),
+ };
+
+ const mockProviderRepository = {
+ upsert: jest.fn(),
+ findBySlug: jest.fn(),
+ };
+
+ const mockConnectionRepository = {
+ findByProviderAndOrg: jest.fn(),
+ };
+
+ const mockCredentialVaultService = {
+ storeOAuthTokens: jest.fn(),
+ };
+
+ const mockConnectionService = {
+ createConnection: jest.fn(),
+ };
+
+ const mockOAuthCredentialsService = {
+ checkAvailability: jest.fn(),
+ getCredentials: jest.fn(),
+ };
+
+ const mockAutoCheckRunnerService = {
+ tryAutoRunChecks: jest.fn().mockResolvedValue(false),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [OAuthController],
+ providers: [
+ { provide: OAuthStateRepository, useValue: mockOAuthStateRepository },
+ { provide: ProviderRepository, useValue: mockProviderRepository },
+ { provide: ConnectionRepository, useValue: mockConnectionRepository },
+ {
+ provide: CredentialVaultService,
+ useValue: mockCredentialVaultService,
+ },
+ { provide: ConnectionService, useValue: mockConnectionService },
+ {
+ provide: OAuthCredentialsService,
+ useValue: mockOAuthCredentialsService,
+ },
+ {
+ provide: AutoCheckRunnerService,
+ useValue: mockAutoCheckRunnerService,
+ },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(OAuthController);
+
+ jest.clearAllMocks();
+ mockAutoCheckRunnerService.tryAutoRunChecks.mockResolvedValue(false);
+ });
+
+ describe('checkAvailability', () => {
+ it('should call oauthCredentialsService.checkAvailability', async () => {
+ const availability = {
+ hasPlatformCredentials: true,
+ hasOrgCredentials: false,
+ };
+ mockOAuthCredentialsService.checkAvailability.mockResolvedValue(
+ availability,
+ );
+
+ const result = await controller.checkAvailability('github', 'org_1');
+
+ expect(
+ mockOAuthCredentialsService.checkAvailability,
+ ).toHaveBeenCalledWith('github', 'org_1');
+ expect(result).toEqual(availability);
+ });
+
+ it('should throw BAD_REQUEST when providerSlug is empty', async () => {
+ await expect(
+ controller.checkAvailability('', 'org_1'),
+ ).rejects.toThrow(HttpException);
+ });
+ });
+
+ describe('startOAuth', () => {
+ it('should throw NOT_FOUND when provider does not exist', async () => {
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ await expect(
+ controller.startOAuth('org_1', {
+ providerSlug: 'nonexistent',
+ userId: 'user_1',
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw BAD_REQUEST when provider is not OAuth', async () => {
+ mockedGetManifest.mockReturnValue({
+ auth: { type: 'api_key' },
+ } as never);
+
+ await expect(
+ controller.startOAuth('org_1', {
+ providerSlug: 'datadog',
+ userId: 'user_1',
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw PRECONDITION_FAILED when no credentials available', async () => {
+ mockedGetManifest.mockReturnValue({
+ id: 'github',
+ name: 'GitHub',
+ auth: {
+ type: 'oauth2',
+ config: {
+ authorizeUrl: 'https://github.com/login/oauth/authorize',
+ tokenUrl: 'https://github.com/login/oauth/access_token',
+ },
+ },
+ category: 'dev',
+ capabilities: [],
+ isActive: true,
+ } as never);
+ mockOAuthCredentialsService.getCredentials.mockResolvedValue(null);
+ mockOAuthCredentialsService.checkAvailability.mockResolvedValue({
+ hasPlatformCredentials: false,
+ setupInstructions: 'Create an OAuth app',
+ createAppUrl: 'https://github.com/settings/apps',
+ });
+
+ await expect(
+ controller.startOAuth('org_1', {
+ providerSlug: 'github',
+ userId: 'user_1',
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should return authorization URL on success', async () => {
+ const manifest = {
+ id: 'github',
+ name: 'GitHub',
+ auth: {
+ type: 'oauth2',
+ config: {
+ authorizeUrl: 'https://github.com/login/oauth/authorize',
+ tokenUrl: 'https://github.com/login/oauth/access_token',
+ pkce: false,
+ },
+ },
+ category: 'dev',
+ capabilities: [],
+ isActive: true,
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+ mockOAuthCredentialsService.getCredentials.mockResolvedValue({
+ clientId: 'client_123',
+ clientSecret: 'secret_456',
+ scopes: ['repo', 'user'],
+ source: 'platform',
+ });
+ mockProviderRepository.upsert.mockResolvedValue(undefined);
+ mockOAuthStateRepository.create.mockResolvedValue({
+ state: 'random_state_token',
+ });
+
+ const result = await controller.startOAuth('org_1', {
+ providerSlug: 'github',
+ userId: 'user_1',
+ });
+
+ expect(result.authorizationUrl).toContain(
+ 'https://github.com/login/oauth/authorize',
+ );
+ expect(result.authorizationUrl).toContain('client_id=client_123');
+ expect(result.authorizationUrl).toContain('state=random_state_token');
+ expect(result.authorizationUrl).toContain('scope=repo+user');
+ expect(mockProviderRepository.upsert).toHaveBeenCalled();
+ expect(mockOAuthStateRepository.create).toHaveBeenCalledWith({
+ providerSlug: 'github',
+ organizationId: 'org_1',
+ userId: 'user_1',
+ codeVerifier: undefined,
+ redirectUrl: undefined,
+ });
+ });
+
+ it('should include PKCE params when enabled', async () => {
+ const manifest = {
+ id: 'linear',
+ name: 'Linear',
+ auth: {
+ type: 'oauth2',
+ config: {
+ authorizeUrl: 'https://linear.app/oauth/authorize',
+ tokenUrl: 'https://api.linear.app/oauth/token',
+ pkce: true,
+ },
+ },
+ category: 'dev',
+ capabilities: [],
+ isActive: true,
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+ mockOAuthCredentialsService.getCredentials.mockResolvedValue({
+ clientId: 'client_abc',
+ clientSecret: 'secret_xyz',
+ scopes: [],
+ source: 'platform',
+ });
+ mockProviderRepository.upsert.mockResolvedValue(undefined);
+ mockOAuthStateRepository.create.mockResolvedValue({
+ state: 'state_abc',
+ });
+
+ const result = await controller.startOAuth('org_1', {
+ providerSlug: 'linear',
+ userId: 'user_1',
+ });
+
+ expect(result.authorizationUrl).toContain('code_challenge=');
+ expect(result.authorizationUrl).toContain('code_challenge_method=S256');
+ });
+ });
+
+ describe('oauthCallback', () => {
+ const mockResponse = {
+ redirect: jest.fn(),
+ } as unknown as import('express').Response;
+
+ beforeEach(() => {
+ (mockResponse.redirect as jest.Mock).mockClear();
+ });
+
+ it('should redirect with error when OAuth error is present', async () => {
+ await controller.oauthCallback(
+ {
+ code: '',
+ state: '',
+ error: 'access_denied',
+ error_description: 'User denied access',
+ },
+ mockResponse,
+ );
+
+ expect(mockResponse.redirect).toHaveBeenCalled();
+ const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
+ expect(redirectUrl).toContain('error=access_denied');
+ });
+
+ it('should redirect with error when code or state is missing', async () => {
+ await controller.oauthCallback(
+ { code: '', state: '' },
+ mockResponse,
+ );
+
+ expect(mockResponse.redirect).toHaveBeenCalled();
+ const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
+ expect(redirectUrl).toContain('error=invalid_request');
+ });
+
+ it('should redirect with error when state is invalid', async () => {
+ mockOAuthStateRepository.findByState.mockResolvedValue(null);
+
+ await controller.oauthCallback(
+ { code: 'auth_code', state: 'invalid_state' },
+ mockResponse,
+ );
+
+ expect(mockResponse.redirect).toHaveBeenCalled();
+ const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
+ expect(redirectUrl).toContain('error=invalid_state');
+ });
+
+ it('should redirect with error when state is expired', async () => {
+ const expiredDate = new Date(Date.now() - 60000);
+ mockOAuthStateRepository.findByState.mockResolvedValue({
+ state: 'expired_state',
+ providerSlug: 'github',
+ organizationId: 'org_1',
+ redirectUrl: null,
+ expiresAt: expiredDate,
+ });
+
+ await controller.oauthCallback(
+ { code: 'auth_code', state: 'expired_state' },
+ mockResponse,
+ );
+
+ expect(mockOAuthStateRepository.delete).toHaveBeenCalledWith(
+ 'expired_state',
+ );
+ expect(mockResponse.redirect).toHaveBeenCalled();
+ const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
+ expect(redirectUrl).toContain('error=expired_state');
+ });
+
+ it('should redirect with error when manifest is not found', async () => {
+ const futureDate = new Date(Date.now() + 600000);
+ mockOAuthStateRepository.findByState.mockResolvedValue({
+ state: 'valid_state',
+ providerSlug: 'nonexistent',
+ organizationId: 'org_1',
+ userId: 'user_1',
+ codeVerifier: null,
+ redirectUrl: null,
+ expiresAt: futureDate,
+ });
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ await controller.oauthCallback(
+ { code: 'auth_code', state: 'valid_state' },
+ mockResponse,
+ );
+
+ expect(mockOAuthStateRepository.delete).toHaveBeenCalledWith(
+ 'valid_state',
+ );
+ expect(mockResponse.redirect).toHaveBeenCalled();
+ const redirectUrl = (mockResponse.redirect as jest.Mock).mock.calls[0][0];
+ expect(redirectUrl).toContain('error=token_exchange_failed');
+ });
+ });
+});
diff --git a/apps/api/src/integration-platform/controllers/oauth.controller.ts b/apps/api/src/integration-platform/controllers/oauth.controller.ts
index e655356b82..262059d2dd 100644
--- a/apps/api/src/integration-platform/controllers/oauth.controller.ts
+++ b/apps/api/src/integration-platform/controllers/oauth.controller.ts
@@ -8,9 +8,15 @@ import {
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
import type { Response } from 'express';
import { randomBytes, createHash } from 'crypto';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
+import { OrganizationId } from '../../auth/auth-context.decorator';
import { OAuthStateRepository } from '../repositories/oauth-state.repository';
import { ProviderRepository } from '../repositories/provider.repository';
import { ConnectionRepository } from '../repositories/connection.repository';
@@ -22,7 +28,6 @@ import { getManifest, type OAuthConfig } from '@comp/integration-platform';
interface StartOAuthDto {
providerSlug: string;
- organizationId: string;
userId: string;
redirectUrl?: string;
}
@@ -35,6 +40,8 @@ interface OAuthCallbackQuery {
}
@Controller({ path: 'integrations/oauth', version: '1' })
+@ApiTags('Integrations')
+@ApiSecurity('apikey')
export class OAuthController {
private readonly logger = new Logger(OAuthController.name);
@@ -52,13 +59,15 @@ export class OAuthController {
* Check if OAuth credentials are available for a provider
*/
@Get('availability')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'read')
async checkAvailability(
@Query('providerSlug') providerSlug: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
- if (!providerSlug || !organizationId) {
+ if (!providerSlug) {
throw new HttpException(
- 'providerSlug and organizationId are required',
+ 'providerSlug is required',
HttpStatus.BAD_REQUEST,
);
}
@@ -73,10 +82,13 @@ export class OAuthController {
* Start OAuth flow - returns authorization URL
*/
@Post('start')
+ @UseGuards(HybridAuthGuard, PermissionGuard)
+ @RequirePermission('integration', 'create')
async startOAuth(
+ @OrganizationId() organizationId: string,
@Body() body: StartOAuthDto,
): Promise<{ authorizationUrl: string }> {
- const { providerSlug, organizationId, userId, redirectUrl } = body;
+ const { providerSlug, userId, redirectUrl } = body;
// Get manifest and OAuth config
const manifest = getManifest(providerSlug);
@@ -301,8 +313,9 @@ export class OAuthController {
});
}
- // Store tokens
+ // Store tokens and mark connection as active
await this.credentialVaultService.storeOAuthTokens(connection.id, tokens);
+ await this.connectionService.activateConnection(connection.id);
// Provider-specific post-OAuth actions
if (oauthState.providerSlug === 'rippling') {
diff --git a/apps/api/src/integration-platform/controllers/sync.controller.ts b/apps/api/src/integration-platform/controllers/sync.controller.ts
index 26cc3e3e75..ee471ab11c 100644
--- a/apps/api/src/integration-platform/controllers/sync.controller.ts
+++ b/apps/api/src/integration-platform/controllers/sync.controller.ts
@@ -7,18 +7,19 @@ import {
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
+import { OrganizationId } from '../../auth/auth-context.decorator';
import { db } from '@db';
import { ConnectionRepository } from '../repositories/connection.repository';
import { CredentialVaultService } from '../services/credential-vault.service';
import { OAuthCredentialsService } from '../services/oauth-credentials.service';
import { getManifest, type OAuthConfig } from '@comp/integration-platform';
-interface SyncQuery {
- organizationId: string;
- connectionId: string;
-}
-
interface GoogleWorkspaceUser {
id: string;
primaryEmail: string;
@@ -102,6 +103,9 @@ const matchesSyncFilterTerms = (email: string, terms: string[]): boolean =>
terms.some((term) => matchesSyncFilterTerm(email, term));
@Controller({ path: 'integrations/sync', version: '1' })
+@ApiTags('Integrations')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class SyncController {
private readonly logger = new Logger(SyncController.name);
@@ -115,12 +119,14 @@ export class SyncController {
* Sync employees from Google Workspace
*/
@Post('google-workspace/employees')
- async syncGoogleWorkspaceEmployees(@Query() query: SyncQuery) {
- const { organizationId, connectionId } = query;
-
- if (!organizationId || !connectionId) {
+ @RequirePermission('integration', 'update')
+ async syncGoogleWorkspaceEmployees(
+ @OrganizationId() organizationId: string,
+ @Query('connectionId') connectionId: string,
+ ) {
+ if (!connectionId) {
throw new HttpException(
- 'organizationId and connectionId are required',
+ 'connectionId is required',
HttpStatus.BAD_REQUEST,
);
}
@@ -547,15 +553,10 @@ export class SyncController {
* Check if Google Workspace is connected for an organization
*/
@Post('google-workspace/status')
+ @RequirePermission('integration', 'read')
async getGoogleWorkspaceStatus(
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
const connection = await this.connectionRepository.findBySlugAndOrg(
'google-workspace',
@@ -583,12 +584,14 @@ export class SyncController {
* Sync employees from Rippling
*/
@Post('rippling/employees')
- async syncRipplingEmployees(@Query() query: SyncQuery) {
- const { organizationId, connectionId } = query;
-
- if (!organizationId || !connectionId) {
+ @RequirePermission('integration', 'update')
+ async syncRipplingEmployees(
+ @OrganizationId() organizationId: string,
+ @Query('connectionId') connectionId: string,
+ ) {
+ if (!connectionId) {
throw new HttpException(
- 'organizationId and connectionId are required',
+ 'connectionId is required',
HttpStatus.BAD_REQUEST,
);
}
@@ -951,13 +954,8 @@ export class SyncController {
* Check if Rippling is connected for an organization
*/
@Post('rippling/status')
- async getRipplingStatus(@Query('organizationId') organizationId: string) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
+ @RequirePermission('integration', 'read')
+ async getRipplingStatus(@OrganizationId() organizationId: string) {
const connection = await this.connectionRepository.findBySlugAndOrg(
'rippling',
@@ -985,12 +983,14 @@ export class SyncController {
* Sync employees from Ramp
*/
@Post('ramp/employees')
- async syncRampEmployees(@Query() query: SyncQuery) {
- const { organizationId, connectionId } = query;
-
- if (!organizationId || !connectionId) {
+ @RequirePermission('integration', 'update')
+ async syncRampEmployees(
+ @OrganizationId() organizationId: string,
+ @Query('connectionId') connectionId: string,
+ ) {
+ if (!connectionId) {
throw new HttpException(
- 'organizationId and connectionId are required',
+ 'connectionId is required',
HttpStatus.BAD_REQUEST,
);
}
@@ -1357,12 +1357,14 @@ export class SyncController {
* Sync employees from JumpCloud
*/
@Post('jumpcloud/employees')
- async syncJumpCloudEmployees(@Query() query: SyncQuery) {
- const { organizationId, connectionId } = query;
-
- if (!organizationId || !connectionId) {
+ @RequirePermission('integration', 'update')
+ async syncJumpCloudEmployees(
+ @OrganizationId() organizationId: string,
+ @Query('connectionId') connectionId: string,
+ ) {
+ if (!connectionId) {
throw new HttpException(
- 'organizationId and connectionId are required',
+ 'connectionId is required',
HttpStatus.BAD_REQUEST,
);
}
@@ -1865,13 +1867,8 @@ export class SyncController {
* Check if JumpCloud is connected for an organization
*/
@Post('jumpcloud/status')
- async getJumpCloudStatus(@Query('organizationId') organizationId: string) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
+ @RequirePermission('integration', 'read')
+ async getJumpCloudStatus(@OrganizationId() organizationId: string) {
const connection = await this.connectionRepository.findBySlugAndOrg(
'jumpcloud',
@@ -1899,13 +1896,8 @@ export class SyncController {
* Check if Ramp is connected for an organization
*/
@Post('ramp/status')
- async getRampStatus(@Query('organizationId') organizationId: string) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
+ @RequirePermission('integration', 'read')
+ async getRampStatus(@OrganizationId() organizationId: string) {
const connection = await this.connectionRepository.findBySlugAndOrg(
'ramp',
@@ -1933,15 +1925,10 @@ export class SyncController {
* Get the current employee sync provider for an organization
*/
@Get('employee-sync-provider')
+ @RequirePermission('integration', 'read')
async getEmployeeSyncProvider(
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
const org = await db.organization.findUnique({
where: { id: organizationId },
@@ -1961,16 +1948,11 @@ export class SyncController {
* Set the employee sync provider for an organization
*/
@Post('employee-sync-provider')
+ @RequirePermission('integration', 'update')
async setEmployeeSyncProvider(
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
@Body() body: { provider: string | null },
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
const { provider } = body;
diff --git a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts
index 562eb644a8..bdc277eeaf 100644
--- a/apps/api/src/integration-platform/controllers/task-integrations.controller.ts
+++ b/apps/api/src/integration-platform/controllers/task-integrations.controller.ts
@@ -8,7 +8,13 @@ import {
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
+import { OrganizationId } from '../../auth/auth-context.decorator';
import {
getActiveManifests,
getManifest,
@@ -51,6 +57,9 @@ interface RunCheckForTaskDto {
}
@Controller({ path: 'integrations/tasks', version: '1' })
+@ApiTags('Integrations')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class TaskIntegrationsController {
private readonly logger = new Logger(TaskIntegrationsController.name);
@@ -66,16 +75,11 @@ export class TaskIntegrationsController {
* Get all integration checks that can auto-complete a specific task template
*/
@Get('template/:templateId/checks')
+ @RequirePermission('integration', 'read')
async getChecksForTaskTemplate(
@Param('templateId') templateId: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
): Promise<{ checks: TaskIntegrationCheck[] }> {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
const manifests = getActiveManifests();
const checks: TaskIntegrationCheck[] = [];
@@ -157,19 +161,14 @@ export class TaskIntegrationsController {
* Get integration checks for a specific task (by task ID)
*/
@Get(':taskId/checks')
+ @RequirePermission('integration', 'read')
async getChecksForTask(
@Param('taskId') taskId: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
): Promise<{
checks: TaskIntegrationCheck[];
task: { id: string; title: string; templateId: string | null };
}> {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
// Get the task to find its template ID
const task = await db.task.findUnique({
@@ -204,9 +203,10 @@ export class TaskIntegrationsController {
* Run a specific check for a task and store results
*/
@Post(':taskId/run-check')
+ @RequirePermission('integration', 'update')
async runCheckForTask(
@Param('taskId') taskId: string,
- @Query('organizationId') organizationId: string,
+ @OrganizationId() organizationId: string,
@Body() body: RunCheckForTaskDto,
): Promise<{
success: boolean;
@@ -215,12 +215,6 @@ export class TaskIntegrationsController {
checkRunId?: string;
taskStatus?: string | null;
}> {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
const { connectionId, checkId } = body;
@@ -503,17 +497,11 @@ export class TaskIntegrationsController {
* Get check run history for a task
*/
@Get(':taskId/runs')
+ @RequirePermission('integration', 'read')
async getTaskCheckRuns(
@Param('taskId') taskId: string,
- @Query('organizationId') organizationId: string,
@Query('limit') limit?: string,
) {
- if (!organizationId) {
- throw new HttpException(
- 'organizationId is required',
- HttpStatus.BAD_REQUEST,
- );
- }
const runs = await this.checkRunRepository.findByTask(
taskId,
diff --git a/apps/api/src/integration-platform/controllers/variables.controller.spec.ts b/apps/api/src/integration-platform/controllers/variables.controller.spec.ts
new file mode 100644
index 0000000000..7139b779f9
--- /dev/null
+++ b/apps/api/src/integration-platform/controllers/variables.controller.spec.ts
@@ -0,0 +1,388 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { HttpException } from '@nestjs/common';
+import { VariablesController } from './variables.controller';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { ConnectionRepository } from '../repositories/connection.repository';
+import { ProviderRepository } from '../repositories/provider.repository';
+import { CredentialVaultService } from '../services/credential-vault.service';
+import { AutoCheckRunnerService } from '../services/auto-check-runner.service';
+
+jest.mock('../../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ integration: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+jest.mock('@comp/integration-platform', () => ({
+ getManifest: jest.fn(),
+}));
+
+import { getManifest } from '@comp/integration-platform';
+
+const mockedGetManifest = getManifest as jest.MockedFunction<
+ typeof getManifest
+>;
+
+describe('VariablesController', () => {
+ let controller: VariablesController;
+
+ const mockConnectionRepository = {
+ findById: jest.fn(),
+ update: jest.fn(),
+ };
+
+ const mockProviderRepository = {
+ findById: jest.fn(),
+ };
+
+ const mockCredentialVaultService = {
+ getDecryptedCredentials: jest.fn(),
+ };
+
+ const mockAutoCheckRunnerService = {
+ tryAutoRunChecks: jest.fn().mockResolvedValue(false),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [VariablesController],
+ providers: [
+ { provide: ConnectionRepository, useValue: mockConnectionRepository },
+ { provide: ProviderRepository, useValue: mockProviderRepository },
+ {
+ provide: CredentialVaultService,
+ useValue: mockCredentialVaultService,
+ },
+ {
+ provide: AutoCheckRunnerService,
+ useValue: mockAutoCheckRunnerService,
+ },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(VariablesController);
+
+ jest.clearAllMocks();
+ mockAutoCheckRunnerService.tryAutoRunChecks.mockResolvedValue(false);
+ });
+
+ describe('getProviderVariables', () => {
+ it('should return variables from manifest', async () => {
+ const manifest = {
+ variables: [
+ {
+ id: 'org_name',
+ label: 'Organization',
+ type: 'string',
+ required: true,
+ },
+ ],
+ checks: [
+ {
+ variables: [
+ {
+ id: 'repo_name',
+ label: 'Repository',
+ type: 'string',
+ required: false,
+ },
+ ],
+ },
+ ],
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+
+ const result = await controller.getProviderVariables('github');
+
+ expect(mockedGetManifest).toHaveBeenCalledWith('github');
+ expect(result.variables).toHaveLength(2);
+ expect(result.variables[0].id).toBe('org_name');
+ expect(result.variables[1].id).toBe('repo_name');
+ });
+
+ it('should throw NOT_FOUND when provider does not exist', async () => {
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ await expect(
+ controller.getProviderVariables('nonexistent'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should deduplicate variables by id', async () => {
+ const manifest = {
+ variables: [
+ {
+ id: 'shared_var',
+ label: 'Shared',
+ type: 'string',
+ required: true,
+ },
+ ],
+ checks: [
+ {
+ variables: [
+ {
+ id: 'shared_var',
+ label: 'Shared Duplicate',
+ type: 'string',
+ required: false,
+ },
+ ],
+ },
+ ],
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+
+ const result = await controller.getProviderVariables('test');
+
+ expect(result.variables).toHaveLength(1);
+ expect(result.variables[0].label).toBe('Shared');
+ });
+
+ it('should set hasDynamicOptions based on fetchOptions', async () => {
+ const manifest = {
+ variables: [
+ {
+ id: 'static_var',
+ label: 'Static',
+ type: 'select',
+ required: false,
+ options: [{ value: 'a', label: 'A' }],
+ },
+ {
+ id: 'dynamic_var',
+ label: 'Dynamic',
+ type: 'select',
+ required: false,
+ fetchOptions: jest.fn(),
+ },
+ ],
+ checks: [],
+ };
+ mockedGetManifest.mockReturnValue(manifest as never);
+
+ const result = await controller.getProviderVariables('test');
+
+ expect(result.variables[0].hasDynamicOptions).toBe(false);
+ expect(result.variables[1].hasDynamicOptions).toBe(true);
+ });
+ });
+
+ describe('getConnectionVariables', () => {
+ it('should return variables with current values', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ providerId: 'prov_1',
+ variables: { org_name: 'my-org' },
+ });
+ mockProviderRepository.findById.mockResolvedValue({
+ id: 'prov_1',
+ slug: 'github',
+ });
+ mockedGetManifest.mockReturnValue({
+ variables: [
+ {
+ id: 'org_name',
+ label: 'Organization',
+ type: 'string',
+ required: true,
+ },
+ ],
+ checks: [],
+ } as never);
+
+ const result = await controller.getConnectionVariables('conn_1');
+
+ expect(result.connectionId).toBe('conn_1');
+ expect(result.providerSlug).toBe('github');
+ expect(result.variables).toHaveLength(1);
+ expect(result.variables[0].currentValue).toBe('my-org');
+ });
+
+ it('should throw NOT_FOUND when connection does not exist', async () => {
+ mockConnectionRepository.findById.mockResolvedValue(null);
+
+ await expect(
+ controller.getConnectionVariables('nonexistent'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw NOT_FOUND when provider does not exist', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ providerId: 'prov_1',
+ variables: {},
+ });
+ mockProviderRepository.findById.mockResolvedValue(null);
+
+ await expect(
+ controller.getConnectionVariables('conn_1'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw NOT_FOUND when manifest does not exist', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ providerId: 'prov_1',
+ variables: {},
+ });
+ mockProviderRepository.findById.mockResolvedValue({
+ id: 'prov_1',
+ slug: 'missing',
+ });
+ mockedGetManifest.mockReturnValue(undefined as never);
+
+ await expect(
+ controller.getConnectionVariables('conn_1'),
+ ).rejects.toThrow(HttpException);
+ });
+ });
+
+ describe('fetchVariableOptions', () => {
+ it('should throw NOT_FOUND when connection does not exist', async () => {
+ mockConnectionRepository.findById.mockResolvedValue(null);
+
+ await expect(
+ controller.fetchVariableOptions('nonexistent', 'var_1'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should throw BAD_REQUEST when connection is not active', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ providerId: 'prov_1',
+ status: 'paused',
+ });
+
+ await expect(
+ controller.fetchVariableOptions('conn_1', 'var_1'),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should return static options when no fetchOptions defined', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ providerId: 'prov_1',
+ status: 'active',
+ });
+ mockProviderRepository.findById.mockResolvedValue({
+ id: 'prov_1',
+ slug: 'github',
+ });
+ mockedGetManifest.mockReturnValue({
+ variables: [
+ {
+ id: 'var_1',
+ label: 'Var',
+ type: 'select',
+ options: [{ value: 'a', label: 'A' }],
+ },
+ ],
+ checks: [],
+ } as never);
+
+ const result = await controller.fetchVariableOptions('conn_1', 'var_1');
+
+ expect(result.options).toEqual([{ value: 'a', label: 'A' }]);
+ });
+
+ it('should throw NOT_FOUND when variable does not exist', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ providerId: 'prov_1',
+ status: 'active',
+ });
+ mockProviderRepository.findById.mockResolvedValue({
+ id: 'prov_1',
+ slug: 'github',
+ });
+ mockedGetManifest.mockReturnValue({
+ variables: [],
+ checks: [],
+ } as never);
+
+ await expect(
+ controller.fetchVariableOptions('conn_1', 'missing_var'),
+ ).rejects.toThrow(HttpException);
+ });
+ });
+
+ describe('saveConnectionVariables', () => {
+ it('should merge and save variables', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ variables: { existing: 'value' },
+ });
+ mockConnectionRepository.update.mockResolvedValue(undefined);
+
+ const result = await controller.saveConnectionVariables('conn_1', {
+ variables: { newVar: 'newValue' },
+ });
+
+ expect(mockConnectionRepository.update).toHaveBeenCalledWith('conn_1', {
+ variables: { existing: 'value', newVar: 'newValue' },
+ });
+ expect(result.success).toBe(true);
+ expect(result.variables).toEqual({
+ existing: 'value',
+ newVar: 'newValue',
+ });
+ });
+
+ it('should throw NOT_FOUND when connection does not exist', async () => {
+ mockConnectionRepository.findById.mockResolvedValue(null);
+
+ await expect(
+ controller.saveConnectionVariables('nonexistent', {
+ variables: { key: 'val' },
+ }),
+ ).rejects.toThrow(HttpException);
+ });
+
+ it('should handle empty existing variables', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ variables: null,
+ });
+ mockConnectionRepository.update.mockResolvedValue(undefined);
+
+ const result = await controller.saveConnectionVariables('conn_1', {
+ variables: { newVar: 'value' },
+ });
+
+ expect(mockConnectionRepository.update).toHaveBeenCalledWith('conn_1', {
+ variables: { newVar: 'value' },
+ });
+ expect(result.success).toBe(true);
+ });
+
+ it('should trigger auto-run checks after saving', async () => {
+ mockConnectionRepository.findById.mockResolvedValue({
+ id: 'conn_1',
+ variables: {},
+ });
+ mockConnectionRepository.update.mockResolvedValue(undefined);
+
+ await controller.saveConnectionVariables('conn_1', {
+ variables: { key: 'val' },
+ });
+
+ expect(
+ mockAutoCheckRunnerService.tryAutoRunChecks,
+ ).toHaveBeenCalledWith('conn_1');
+ });
+ });
+});
diff --git a/apps/api/src/integration-platform/controllers/variables.controller.ts b/apps/api/src/integration-platform/controllers/variables.controller.ts
index 3c69dbbb8f..ea13a09357 100644
--- a/apps/api/src/integration-platform/controllers/variables.controller.ts
+++ b/apps/api/src/integration-platform/controllers/variables.controller.ts
@@ -4,11 +4,15 @@ import {
Post,
Param,
Body,
- Query,
HttpException,
HttpStatus,
Logger,
+ UseGuards,
} from '@nestjs/common';
+import { ApiTags, ApiSecurity } from '@nestjs/swagger';
+import { HybridAuthGuard } from '../../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../../auth/permission.guard';
+import { RequirePermission } from '../../auth/require-permission.decorator';
import { getManifest, type CheckVariable } from '@comp/integration-platform';
import { ConnectionRepository } from '../repositories/connection.repository';
import { ProviderRepository } from '../repositories/provider.repository';
@@ -37,6 +41,9 @@ interface VariableDefinition {
}
@Controller({ path: 'integrations/variables', version: '1' })
+@ApiTags('Integrations')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class VariablesController {
private readonly logger = new Logger(VariablesController.name);
@@ -51,6 +58,7 @@ export class VariablesController {
* Get all variables required for a provider's checks
*/
@Get('providers/:providerSlug')
+ @RequirePermission('integration', 'read')
async getProviderVariables(
@Param('providerSlug') providerSlug: string,
): Promise<{ variables: VariableDefinition[] }> {
@@ -100,6 +108,7 @@ export class VariablesController {
* Get variables for a specific connection (with current values)
*/
@Get('connections/:connectionId')
+ @RequirePermission('integration', 'read')
async getConnectionVariables(@Param('connectionId') connectionId: string) {
const connection = await this.connectionRepository.findById(connectionId);
if (!connection) {
@@ -166,6 +175,7 @@ export class VariablesController {
* Fetch dynamic options for a variable (requires active connection)
*/
@Get('connections/:connectionId/options/:variableId')
+ @RequirePermission('integration', 'read')
async fetchVariableOptions(
@Param('connectionId') connectionId: string,
@Param('variableId') variableId: string,
@@ -372,6 +382,7 @@ export class VariablesController {
* Save variable values for a connection
*/
@Post('connections/:connectionId')
+ @RequirePermission('integration', 'update')
async saveConnectionVariables(
@Param('connectionId') connectionId: string,
@Body() body: SaveVariablesDto,
diff --git a/apps/api/src/integration-platform/integration-platform.module.ts b/apps/api/src/integration-platform/integration-platform.module.ts
index 18fca66b98..ab538f4e44 100644
--- a/apps/api/src/integration-platform/integration-platform.module.ts
+++ b/apps/api/src/integration-platform/integration-platform.module.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
import { OAuthController } from './controllers/oauth.controller';
import { OAuthAppsController } from './controllers/oauth-apps.controller';
import { ConnectionsController } from './controllers/connections.controller';
import { AdminIntegrationsController } from './controllers/admin-integrations.controller';
+import { DynamicIntegrationsController } from './controllers/dynamic-integrations.controller';
import { ChecksController } from './controllers/checks.controller';
import { VariablesController } from './controllers/variables.controller';
import { TaskIntegrationsController } from './controllers/task-integrations.controller';
@@ -14,6 +16,7 @@ import { OAuthCredentialsService } from './services/oauth-credentials.service';
import { AutoCheckRunnerService } from './services/auto-check-runner.service';
import { ConnectionAuthTeardownService } from './services/connection-auth-teardown.service';
import { OAuthTokenRevocationService } from './services/oauth-token-revocation.service';
+import { DynamicManifestLoaderService } from './services/dynamic-manifest-loader.service';
import { ProviderRepository } from './repositories/provider.repository';
import { ConnectionRepository } from './repositories/connection.repository';
import { CredentialRepository } from './repositories/credential.repository';
@@ -21,13 +24,17 @@ import { OAuthStateRepository } from './repositories/oauth-state.repository';
import { OAuthAppRepository } from './repositories/oauth-app.repository';
import { PlatformCredentialRepository } from './repositories/platform-credential.repository';
import { CheckRunRepository } from './repositories/check-run.repository';
+import { DynamicIntegrationRepository } from './repositories/dynamic-integration.repository';
+import { DynamicCheckRepository } from './repositories/dynamic-check.repository';
@Module({
+ imports: [AuthModule],
controllers: [
OAuthController,
OAuthAppsController,
ConnectionsController,
AdminIntegrationsController,
+ DynamicIntegrationsController,
ChecksController,
VariablesController,
TaskIntegrationsController,
@@ -42,6 +49,7 @@ import { CheckRunRepository } from './repositories/check-run.repository';
AutoCheckRunnerService,
OAuthTokenRevocationService,
ConnectionAuthTeardownService,
+ DynamicManifestLoaderService,
// Repositories
ProviderRepository,
ConnectionRepository,
@@ -50,12 +58,15 @@ import { CheckRunRepository } from './repositories/check-run.repository';
OAuthAppRepository,
PlatformCredentialRepository,
CheckRunRepository,
+ DynamicIntegrationRepository,
+ DynamicCheckRepository,
],
exports: [
CredentialVaultService,
ConnectionService,
OAuthCredentialsService,
AutoCheckRunnerService,
+ DynamicManifestLoaderService,
],
})
export class IntegrationPlatformModule {}
diff --git a/apps/api/src/integration-platform/repositories/dynamic-check.repository.ts b/apps/api/src/integration-platform/repositories/dynamic-check.repository.ts
new file mode 100644
index 0000000000..275c4c6f7d
--- /dev/null
+++ b/apps/api/src/integration-platform/repositories/dynamic-check.repository.ts
@@ -0,0 +1,107 @@
+import { Injectable } from '@nestjs/common';
+import { db } from '@db';
+import type { DynamicCheck, Prisma } from '@prisma/client';
+
+@Injectable()
+export class DynamicCheckRepository {
+ async findByIntegrationId(integrationId: string): Promise {
+ return db.dynamicCheck.findMany({
+ where: { integrationId },
+ orderBy: { sortOrder: 'asc' },
+ });
+ }
+
+ async findById(id: string): Promise {
+ return db.dynamicCheck.findUnique({ where: { id } });
+ }
+
+ async create(data: {
+ integrationId: string;
+ checkSlug: string;
+ name: string;
+ description: string;
+ taskMapping?: string;
+ defaultSeverity?: string;
+ definition: Prisma.InputJsonValue;
+ variables?: Prisma.InputJsonValue;
+ isEnabled?: boolean;
+ sortOrder?: number;
+ }): Promise {
+ return db.dynamicCheck.create({
+ data: {
+ integrationId: data.integrationId,
+ checkSlug: data.checkSlug,
+ name: data.name,
+ description: data.description,
+ taskMapping: data.taskMapping,
+ defaultSeverity: data.defaultSeverity ?? 'medium',
+ definition: data.definition,
+ variables: data.variables ?? [],
+ isEnabled: data.isEnabled ?? true,
+ sortOrder: data.sortOrder ?? 0,
+ },
+ });
+ }
+
+ async update(
+ id: string,
+ data: Prisma.DynamicCheckUpdateInput,
+ ): Promise {
+ return db.dynamicCheck.update({
+ where: { id },
+ data,
+ });
+ }
+
+ async delete(id: string): Promise {
+ await db.dynamicCheck.delete({ where: { id } });
+ }
+
+ async deleteAllForIntegration(integrationId: string): Promise {
+ await db.dynamicCheck.deleteMany({ where: { integrationId } });
+ }
+
+ async upsert(data: {
+ integrationId: string;
+ checkSlug: string;
+ name: string;
+ description: string;
+ taskMapping?: string;
+ defaultSeverity?: string;
+ definition: Prisma.InputJsonValue;
+ variables?: Prisma.InputJsonValue;
+ isEnabled?: boolean;
+ sortOrder?: number;
+ }): Promise {
+ return db.dynamicCheck.upsert({
+ where: {
+ integrationId_checkSlug: {
+ integrationId: data.integrationId,
+ checkSlug: data.checkSlug,
+ },
+ },
+ create: {
+ integrationId: data.integrationId,
+ checkSlug: data.checkSlug,
+ name: data.name,
+ description: data.description,
+ taskMapping: data.taskMapping,
+ defaultSeverity: data.defaultSeverity ?? 'medium',
+ definition: data.definition,
+ variables: data.variables ?? [],
+ isEnabled: data.isEnabled ?? true,
+ sortOrder: data.sortOrder ?? 0,
+ },
+ update: {
+ name: data.name,
+ description: data.description,
+ taskMapping: data.taskMapping,
+ defaultSeverity: data.defaultSeverity ?? 'medium',
+ definition: data.definition,
+ variables: data.variables ?? [],
+ isEnabled: data.isEnabled ?? true,
+ sortOrder: data.sortOrder ?? 0,
+ },
+ });
+ }
+}
diff --git a/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts b/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts
new file mode 100644
index 0000000000..1867afe10f
--- /dev/null
+++ b/apps/api/src/integration-platform/repositories/dynamic-integration.repository.ts
@@ -0,0 +1,131 @@
+import { Injectable } from '@nestjs/common';
+import { db } from '@db';
+import type { DynamicIntegration, DynamicCheck, Prisma } from '@prisma/client';
+
+export type DynamicIntegrationWithChecks = DynamicIntegration & {
+ checks: DynamicCheck[];
+};
+
+@Injectable()
+export class DynamicIntegrationRepository {
+ async findAll(): Promise {
+ return db.dynamicIntegration.findMany({
+ include: { checks: { orderBy: { sortOrder: 'asc' } } },
+ orderBy: { name: 'asc' },
+ });
+ }
+
+ async findActive(): Promise {
+ return db.dynamicIntegration.findMany({
+ where: { isActive: true },
+ include: {
+ checks: {
+ where: { isEnabled: true },
+ orderBy: { sortOrder: 'asc' },
+ },
+ },
+ orderBy: { name: 'asc' },
+ });
+ }
+
+ async findById(id: string): Promise {
+ return db.dynamicIntegration.findUnique({
+ where: { id },
+ include: { checks: { orderBy: { sortOrder: 'asc' } } },
+ });
+ }
+
+ async findBySlug(slug: string): Promise {
+ return db.dynamicIntegration.findUnique({
+ where: { slug },
+ include: { checks: { orderBy: { sortOrder: 'asc' } } },
+ });
+ }
+
+ async create(data: {
+ slug: string;
+ name: string;
+ description: string;
+ category: string;
+ logoUrl: string;
+ docsUrl?: string;
+ baseUrl?: string;
+ defaultHeaders?: Prisma.InputJsonValue;
+ authConfig: Prisma.InputJsonValue;
+ capabilities?: Prisma.InputJsonValue;
+ supportsMultipleConnections?: boolean;
+ }): Promise {
+ return db.dynamicIntegration.create({
+ data: {
+ slug: data.slug,
+ name: data.name,
+ description: data.description,
+ category: data.category,
+ logoUrl: data.logoUrl,
+ docsUrl: data.docsUrl,
+ baseUrl: data.baseUrl,
+ defaultHeaders: data.defaultHeaders ?? undefined,
+ authConfig: data.authConfig,
+ capabilities: data.capabilities ?? ['checks'],
+ supportsMultipleConnections: data.supportsMultipleConnections ?? false,
+ },
+ });
+ }
+
+ async update(
+ id: string,
+ data: Prisma.DynamicIntegrationUpdateInput,
+ ): Promise {
+ return db.dynamicIntegration.update({
+ where: { id },
+ data,
+ });
+ }
+
+ async delete(id: string): Promise {
+ await db.dynamicIntegration.delete({ where: { id } });
+ }
+
+ async upsertBySlug(data: {
+ slug: string;
+ name: string;
+ description: string;
+ category: string;
+ logoUrl: string;
+ docsUrl?: string;
+ baseUrl?: string;
+ defaultHeaders?: Prisma.InputJsonValue;
+ authConfig: Prisma.InputJsonValue;
+ capabilities?: Prisma.InputJsonValue;
+ supportsMultipleConnections?: boolean;
+ }): Promise {
+ return db.dynamicIntegration.upsert({
+ where: { slug: data.slug },
+ create: {
+ slug: data.slug,
+ name: data.name,
+ description: data.description,
+ category: data.category,
+ logoUrl: data.logoUrl,
+ docsUrl: data.docsUrl,
+ baseUrl: data.baseUrl,
+ defaultHeaders: data.defaultHeaders ?? undefined,
+ authConfig: data.authConfig,
+ capabilities: data.capabilities ?? ['checks'],
+ supportsMultipleConnections: data.supportsMultipleConnections ?? false,
+ },
+ update: {
+ name: data.name,
+ description: data.description,
+ category: data.category,
+ logoUrl: data.logoUrl,
+ docsUrl: data.docsUrl,
+ baseUrl: data.baseUrl,
+ defaultHeaders: data.defaultHeaders ?? undefined,
+ authConfig: data.authConfig,
+ capabilities: data.capabilities ?? ['checks'],
+ supportsMultipleConnections: data.supportsMultipleConnections ?? false,
+ },
+ });
+ }
+}
diff --git a/apps/api/src/integration-platform/services/credential-vault.service.ts b/apps/api/src/integration-platform/services/credential-vault.service.ts
index ddd1c730ef..755625254f 100644
--- a/apps/api/src/integration-platform/services/credential-vault.service.ts
+++ b/apps/api/src/integration-platform/services/credential-vault.service.ts
@@ -51,9 +51,9 @@ export class CredentialVaultService {
) {}
private getSecretKey(): string {
- const secretKey = process.env.SECRET_KEY;
+ const secretKey = process.env.ENCRYPTION_KEY;
if (!secretKey) {
- throw new Error('SECRET_KEY environment variable is not set');
+ throw new Error('ENCRYPTION_KEY environment variable is not set');
}
return secretKey;
}
diff --git a/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts b/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts
new file mode 100644
index 0000000000..60b51c5e27
--- /dev/null
+++ b/apps/api/src/integration-platform/services/dynamic-manifest-loader.service.ts
@@ -0,0 +1,119 @@
+import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
+import {
+ registry,
+ interpretDeclarativeCheck,
+ type IntegrationManifest,
+ type AuthStrategy,
+ type IntegrationCategory,
+ type IntegrationCapability,
+ type FindingSeverity,
+ type CheckVariable,
+} from '@comp/integration-platform';
+import { DynamicIntegrationRepository, type DynamicIntegrationWithChecks } from '../repositories/dynamic-integration.repository';
+import type { DynamicCheck } from '@prisma/client';
+
+@Injectable()
+export class DynamicManifestLoaderService implements OnModuleInit {
+ private readonly logger = new Logger(DynamicManifestLoaderService.name);
+ private refreshTimer: ReturnType | null = null;
+
+ constructor(
+ private readonly dynamicIntegrationRepo: DynamicIntegrationRepository,
+ ) {}
+
+ async onModuleInit() {
+ try {
+ await this.loadDynamicManifests();
+ // Background refresh every 60 seconds as safety net
+ this.refreshTimer = setInterval(() => {
+ this.loadDynamicManifests().catch((err) => {
+ this.logger.error('Background refresh failed', err);
+ });
+ }, 60_000);
+ } catch (error) {
+ this.logger.error('Failed to load dynamic manifests on boot', error);
+ }
+ }
+
+ /**
+ * Load all active dynamic integrations from DB and merge into the registry.
+ */
+ async loadDynamicManifests(): Promise {
+ const integrations = await this.dynamicIntegrationRepo.findActive();
+
+ const manifests: IntegrationManifest[] = [];
+
+ for (const integration of integrations) {
+ try {
+ const manifest = this.convertToManifest(integration);
+ manifests.push(manifest);
+ } catch (error) {
+ this.logger.error(
+ `Failed to convert dynamic integration "${integration.slug}": ${error instanceof Error ? error.message : String(error)}`,
+ );
+ }
+ }
+
+ registry.refreshDynamic(manifests);
+ this.logger.log(`Loaded ${manifests.length} dynamic integrations into registry`);
+ }
+
+ /**
+ * Invalidate cache — force reload from DB.
+ * Call this after creating/updating/deleting dynamic integrations.
+ */
+ async invalidateCache(): Promise {
+ await this.loadDynamicManifests();
+ }
+
+ /**
+ * Convert a DynamicIntegration (DB row) + checks into an IntegrationManifest.
+ */
+ private convertToManifest(
+ integration: DynamicIntegrationWithChecks,
+ ): IntegrationManifest {
+ const authConfig = integration.authConfig as Record;
+ const auth: AuthStrategy = {
+ type: authConfig.type as AuthStrategy['type'],
+ config: authConfig.config as AuthStrategy['config'],
+ } as AuthStrategy;
+
+ const checks = integration.checks.map((check) =>
+ this.convertCheck(check, integration.slug),
+ );
+
+ return {
+ id: integration.slug,
+ name: integration.name,
+ description: integration.description,
+ category: integration.category as IntegrationCategory,
+ logoUrl: integration.logoUrl,
+ docsUrl: integration.docsUrl ?? undefined,
+ auth,
+ baseUrl: integration.baseUrl ?? undefined,
+ defaultHeaders: (integration.defaultHeaders as Record) ?? undefined,
+ capabilities: (integration.capabilities as unknown as IntegrationCapability[]) ?? ['checks'],
+ supportsMultipleConnections: integration.supportsMultipleConnections,
+ checks,
+ isActive: integration.isActive,
+ };
+ }
+
+ /**
+ * Convert a DynamicCheck (DB row) into an IntegrationCheck using the DSL interpreter.
+ */
+ private convertCheck(check: DynamicCheck, _integrationSlug: string) {
+ const definition = check.definition as Record;
+ const variables = check.variables as unknown as CheckVariable[] | undefined;
+
+ return interpretDeclarativeCheck({
+ id: check.checkSlug,
+ name: check.name,
+ description: check.description,
+ definition: definition as Parameters[0]['definition'],
+ taskMapping: check.taskMapping ?? undefined,
+ defaultSeverity: (check.defaultSeverity as FindingSeverity) ?? 'medium',
+ variables: variables && variables.length > 0 ? variables : undefined,
+ });
+ }
+}
diff --git a/apps/api/src/knowledge-base/dto/save-manual-answer.dto.ts b/apps/api/src/knowledge-base/dto/save-manual-answer.dto.ts
new file mode 100644
index 0000000000..7d5944eb8d
--- /dev/null
+++ b/apps/api/src/knowledge-base/dto/save-manual-answer.dto.ts
@@ -0,0 +1,18 @@
+import { IsString, IsOptional, IsArray } from 'class-validator';
+
+export class SaveManualAnswerDto {
+ @IsString()
+ question!: string;
+
+ @IsString()
+ answer!: string;
+
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ tags?: string[];
+
+ @IsString()
+ @IsOptional()
+ sourceQuestionnaireId?: string;
+}
diff --git a/apps/api/src/knowledge-base/knowledge-base.controller.spec.ts b/apps/api/src/knowledge-base/knowledge-base.controller.spec.ts
new file mode 100644
index 0000000000..1a44dc6700
--- /dev/null
+++ b/apps/api/src/knowledge-base/knowledge-base.controller.spec.ts
@@ -0,0 +1,212 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { KnowledgeBaseController } from './knowledge-base.controller';
+import { KnowledgeBaseService } from './knowledge-base.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+describe('KnowledgeBaseController', () => {
+ let controller: KnowledgeBaseController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ listDocuments: jest.fn(),
+ listManualAnswers: jest.fn(),
+ saveManualAnswer: jest.fn(),
+ uploadDocument: jest.fn(),
+ getDownloadUrl: jest.fn(),
+ getViewUrl: jest.fn(),
+ deleteDocument: jest.fn(),
+ processDocuments: jest.fn(),
+ createRunReadToken: jest.fn(),
+ deleteManualAnswer: jest.fn(),
+ deleteAllManualAnswers: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [KnowledgeBaseController],
+ providers: [{ provide: KnowledgeBaseService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(KnowledgeBaseController);
+ service = module.get(KnowledgeBaseService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('listDocuments', () => {
+ it('should return documents from service', async () => {
+ const mockDocs = [
+ { id: 'd1', name: 'doc.pdf', processingStatus: 'completed' },
+ ];
+ mockService.listDocuments.mockResolvedValue(mockDocs);
+
+ const result = await controller.listDocuments('org_1');
+
+ expect(result).toEqual(mockDocs);
+ expect(service.listDocuments).toHaveBeenCalledWith('org_1');
+ });
+ });
+
+ describe('listManualAnswers', () => {
+ it('should return manual answers from service', async () => {
+ const mockAnswers = [
+ { id: 'ma1', question: 'Q1?', answer: 'A1' },
+ ];
+ mockService.listManualAnswers.mockResolvedValue(mockAnswers);
+
+ const result = await controller.listManualAnswers('org_1');
+
+ expect(result).toEqual(mockAnswers);
+ expect(service.listManualAnswers).toHaveBeenCalledWith('org_1');
+ });
+ });
+
+ describe('saveManualAnswer', () => {
+ it('should pass dto with organizationId to service', async () => {
+ const dto = { question: 'Q1?', answer: 'A1', tags: ['security'] };
+ mockService.saveManualAnswer.mockResolvedValue({
+ success: true,
+ manualAnswerId: 'ma1',
+ });
+
+ const result = await controller.saveManualAnswer('org_1', dto as any);
+
+ expect(result).toEqual({ success: true, manualAnswerId: 'ma1' });
+ expect(service.saveManualAnswer).toHaveBeenCalledWith({
+ ...dto,
+ organizationId: 'org_1',
+ });
+ });
+ });
+
+ describe('uploadDocument', () => {
+ it('should delegate to service', async () => {
+ const dto = {
+ organizationId: 'org_1',
+ fileName: 'doc.pdf',
+ fileType: 'application/pdf',
+ fileData: 'base64',
+ };
+ mockService.uploadDocument.mockResolvedValue({
+ id: 'd1',
+ name: 'doc.pdf',
+ s3Key: 'key',
+ });
+
+ const result = await controller.uploadDocument(dto as any);
+
+ expect(result.id).toBe('d1');
+ expect(service.uploadDocument).toHaveBeenCalledWith(dto);
+ });
+ });
+
+ describe('getDownloadUrl', () => {
+ it('should merge documentId param with dto', async () => {
+ const dto = { organizationId: 'org_1' };
+ mockService.getDownloadUrl.mockResolvedValue({
+ signedUrl: 'https://example.com/signed',
+ fileName: 'doc.pdf',
+ });
+
+ const result = await controller.getDownloadUrl('d1', dto as any);
+
+ expect(result.signedUrl).toBe('https://example.com/signed');
+ expect(service.getDownloadUrl).toHaveBeenCalledWith({
+ ...dto,
+ documentId: 'd1',
+ });
+ });
+ });
+
+ describe('deleteDocument', () => {
+ it('should merge documentId param with dto', async () => {
+ const dto = { organizationId: 'org_1' };
+ mockService.deleteDocument.mockResolvedValue({ success: true });
+
+ const result = await controller.deleteDocument('d1', dto as any);
+
+ expect(result).toEqual({ success: true });
+ expect(service.deleteDocument).toHaveBeenCalledWith({
+ ...dto,
+ documentId: 'd1',
+ });
+ });
+ });
+
+ describe('processDocuments', () => {
+ it('should delegate to service', async () => {
+ const dto = {
+ organizationId: 'org_1',
+ documentIds: ['d1', 'd2'],
+ };
+ mockService.processDocuments.mockResolvedValue({
+ success: true,
+ runId: 'run_1',
+ message: 'Processing 2 documents in parallel...',
+ });
+
+ const result = await controller.processDocuments(dto as any);
+
+ expect(result.success).toBe(true);
+ expect(service.processDocuments).toHaveBeenCalledWith(dto);
+ });
+ });
+
+ describe('createRunToken', () => {
+ it('should return token when created', async () => {
+ mockService.createRunReadToken.mockResolvedValue('token_123');
+
+ const result = await controller.createRunToken('run_1');
+
+ expect(result).toEqual({ success: true, token: 'token_123' });
+ expect(service.createRunReadToken).toHaveBeenCalledWith('run_1');
+ });
+
+ it('should return success false when token creation fails', async () => {
+ mockService.createRunReadToken.mockResolvedValue(undefined);
+
+ const result = await controller.createRunToken('run_1');
+
+ expect(result).toEqual({ success: false, token: undefined });
+ });
+ });
+
+ describe('deleteManualAnswer', () => {
+ it('should merge manualAnswerId param with dto', async () => {
+ const dto = { organizationId: 'org_1' };
+ mockService.deleteManualAnswer.mockResolvedValue({ success: true });
+
+ const result = await controller.deleteManualAnswer('ma1', dto as any);
+
+ expect(result).toEqual({ success: true });
+ expect(service.deleteManualAnswer).toHaveBeenCalledWith({
+ ...dto,
+ manualAnswerId: 'ma1',
+ });
+ });
+ });
+
+ describe('deleteAllManualAnswers', () => {
+ it('should delegate to service', async () => {
+ const dto = { organizationId: 'org_1' };
+ mockService.deleteAllManualAnswers.mockResolvedValue({ success: true });
+
+ const result = await controller.deleteAllManualAnswers(dto as any);
+
+ expect(result).toEqual({ success: true });
+ expect(service.deleteAllManualAnswers).toHaveBeenCalledWith(dto);
+ });
+ });
+});
diff --git a/apps/api/src/knowledge-base/knowledge-base.controller.ts b/apps/api/src/knowledge-base/knowledge-base.controller.ts
index 050757f7f1..a1f56c85fa 100644
--- a/apps/api/src/knowledge-base/knowledge-base.controller.ts
+++ b/apps/api/src/knowledge-base/knowledge-base.controller.ts
@@ -4,16 +4,22 @@ import {
Post,
Body,
Param,
- Query,
HttpCode,
HttpStatus,
+ UseGuards,
} from '@nestjs/common';
import {
ApiTags,
ApiOperation,
ApiOkResponse,
ApiConsumes,
+ ApiSecurity,
} from '@nestjs/swagger';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
+import { OrganizationId } from '../auth/auth-context.decorator';
+import { AuditRead } from '../audit/skip-audit-log.decorator';
import { KnowledgeBaseService } from './knowledge-base.service';
import { UploadDocumentDto } from './dto/upload-document.dto';
import { DeleteDocumentDto } from './dto/delete-document.dto';
@@ -21,76 +27,64 @@ import { GetDocumentUrlDto } from './dto/get-document-url.dto';
import { ProcessDocumentsDto } from './dto/process-documents.dto';
import { DeleteManualAnswerDto } from './dto/delete-manual-answer.dto';
import { DeleteAllManualAnswersDto } from './dto/delete-all-manual-answers.dto';
+import { SaveManualAnswerDto } from './dto/save-manual-answer.dto';
@Controller({ path: 'knowledge-base', version: '1' })
@ApiTags('Knowledge Base')
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class KnowledgeBaseController {
constructor(private readonly knowledgeBaseService: KnowledgeBaseService) {}
@Get('documents')
+ @RequirePermission('questionnaire', 'read')
@ApiOperation({
summary: 'List all knowledge base documents for an organization',
})
- @ApiOkResponse({
- description: 'List of knowledge base documents',
- schema: {
- type: 'array',
- items: {
- type: 'object',
- properties: {
- id: { type: 'string' },
- name: { type: 'string' },
- description: { type: 'string', nullable: true },
- s3Key: { type: 'string' },
- fileType: { type: 'string' },
- fileSize: { type: 'number' },
- processingStatus: {
- type: 'string',
- enum: ['pending', 'processing', 'completed', 'failed'],
- },
- createdAt: { type: 'string', format: 'date-time' },
- updatedAt: { type: 'string', format: 'date-time' },
- },
- },
- },
- })
- async listDocuments(@Query('organizationId') organizationId: string) {
+ @ApiOkResponse({ description: 'List of knowledge base documents' })
+ async listDocuments(@OrganizationId() organizationId: string) {
return this.knowledgeBaseService.listDocuments(organizationId);
}
+ @Get('manual-answers')
+ @RequirePermission('questionnaire', 'read')
+ @ApiOperation({ summary: 'List all manual answers for an organization' })
+ @ApiOkResponse({ description: 'List of manual answers' })
+ async listManualAnswers(@OrganizationId() organizationId: string) {
+ return this.knowledgeBaseService.listManualAnswers(organizationId);
+ }
+
+ @Post('manual-answers')
+ @RequirePermission('questionnaire', 'update')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: 'Save or update a manual answer' })
+ @ApiConsumes('application/json')
+ @ApiOkResponse({ description: 'Manual answer saved' })
+ async saveManualAnswer(
+ @OrganizationId() organizationId: string,
+ @Body() dto: SaveManualAnswerDto,
+ ) {
+ return this.knowledgeBaseService.saveManualAnswer({
+ ...dto,
+ organizationId,
+ });
+ }
+
@Post('documents/upload')
+ @RequirePermission('questionnaire', 'create')
@ApiOperation({ summary: 'Upload a knowledge base document' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Document uploaded successfully',
- schema: {
- type: 'object',
- properties: {
- id: { type: 'string' },
- name: { type: 'string' },
- s3Key: { type: 'string' },
- },
- },
- })
+ @ApiOkResponse({ description: 'Document uploaded successfully' })
async uploadDocument(@Body() dto: UploadDocumentDto) {
return this.knowledgeBaseService.uploadDocument(dto);
}
@Post('documents/:documentId/download')
- @ApiOperation({
- summary: 'Get a signed download URL for a knowledge base document',
- })
+ @RequirePermission('questionnaire', 'read')
+ @AuditRead()
+ @ApiOperation({ summary: 'Get a signed download URL for a document' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Signed download URL generated',
- schema: {
- type: 'object',
- properties: {
- signedUrl: { type: 'string' },
- fileName: { type: 'string' },
- },
- },
- })
+ @ApiOkResponse({ description: 'Signed download URL generated' })
async getDownloadUrl(
@Param('documentId') documentId: string,
@Body() dto: Omit,
@@ -102,22 +96,11 @@ export class KnowledgeBaseController {
}
@Post('documents/:documentId/view')
- @ApiOperation({
- summary: 'Get a signed view URL for a knowledge base document',
- })
+ @RequirePermission('questionnaire', 'read')
+ @AuditRead()
+ @ApiOperation({ summary: 'Get a signed view URL for a document' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Signed view URL generated',
- schema: {
- type: 'object',
- properties: {
- signedUrl: { type: 'string' },
- fileName: { type: 'string' },
- fileType: { type: 'string' },
- viewableInBrowser: { type: 'boolean' },
- },
- },
- })
+ @ApiOkResponse({ description: 'Signed view URL generated' })
async getViewUrl(
@Param('documentId') documentId: string,
@Body() dto: Omit,
@@ -129,20 +112,11 @@ export class KnowledgeBaseController {
}
@Post('documents/:documentId/delete')
+ @RequirePermission('questionnaire', 'delete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete a knowledge base document' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Document deleted successfully',
- schema: {
- type: 'object',
- properties: {
- success: { type: 'boolean' },
- vectorDeletionRunId: { type: 'string', nullable: true },
- publicAccessToken: { type: 'string', nullable: true },
- },
- },
- })
+ @ApiOkResponse({ description: 'Document deleted successfully' })
async deleteDocument(
@Param('documentId') documentId: string,
@Body() dto: Omit,
@@ -154,61 +128,29 @@ export class KnowledgeBaseController {
}
@Post('documents/process')
+ @RequirePermission('questionnaire', 'create')
@ApiOperation({ summary: 'Trigger processing of knowledge base documents' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Document processing triggered',
- schema: {
- type: 'object',
- properties: {
- success: { type: 'boolean' },
- runId: { type: 'string' },
- publicAccessToken: { type: 'string', nullable: true },
- message: { type: 'string' },
- },
- },
- })
+ @ApiOkResponse({ description: 'Document processing triggered' })
async processDocuments(@Body() dto: ProcessDocumentsDto) {
return this.knowledgeBaseService.processDocuments(dto);
}
@Post('runs/:runId/token')
- @ApiOperation({
- summary: 'Create a public access token for a Trigger.dev run',
- })
- @ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Public access token created',
- schema: {
- type: 'object',
- properties: {
- success: { type: 'boolean' },
- token: { type: 'string', nullable: true },
- },
- },
- })
+ @RequirePermission('questionnaire', 'read')
+ @ApiOperation({ summary: 'Create a public access token for a run' })
+ @ApiOkResponse({ description: 'Public access token created' })
async createRunToken(@Param('runId') runId: string) {
const token = await this.knowledgeBaseService.createRunReadToken(runId);
- return {
- success: !!token,
- token,
- };
+ return { success: !!token, token };
}
@Post('manual-answers/:manualAnswerId/delete')
+ @RequirePermission('questionnaire', 'delete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete a manual answer' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'Manual answer deleted successfully',
- schema: {
- type: 'object',
- properties: {
- success: { type: 'boolean' },
- error: { type: 'string', nullable: true },
- },
- },
- })
+ @ApiOkResponse({ description: 'Manual answer deleted' })
async deleteManualAnswer(
@Param('manualAnswerId') manualAnswerId: string,
@Body() dto: DeleteManualAnswerDto,
@@ -220,19 +162,11 @@ export class KnowledgeBaseController {
}
@Post('manual-answers/delete-all')
+ @RequirePermission('questionnaire', 'delete')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Delete all manual answers for an organization' })
@ApiConsumes('application/json')
- @ApiOkResponse({
- description: 'All manual answers deleted successfully',
- schema: {
- type: 'object',
- properties: {
- success: { type: 'boolean' },
- error: { type: 'string', nullable: true },
- },
- },
- })
+ @ApiOkResponse({ description: 'All manual answers deleted' })
async deleteAllManualAnswers(@Body() dto: DeleteAllManualAnswersDto) {
return this.knowledgeBaseService.deleteAllManualAnswers(dto);
}
diff --git a/apps/api/src/knowledge-base/knowledge-base.module.ts b/apps/api/src/knowledge-base/knowledge-base.module.ts
index 9742370d9c..bce607bd61 100644
--- a/apps/api/src/knowledge-base/knowledge-base.module.ts
+++ b/apps/api/src/knowledge-base/knowledge-base.module.ts
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
import { KnowledgeBaseController } from './knowledge-base.controller';
import { KnowledgeBaseService } from './knowledge-base.service';
@Module({
+ imports: [AuthModule],
controllers: [KnowledgeBaseController],
providers: [KnowledgeBaseService],
})
diff --git a/apps/api/src/knowledge-base/knowledge-base.service.spec.ts b/apps/api/src/knowledge-base/knowledge-base.service.spec.ts
new file mode 100644
index 0000000000..52a4c6817d
--- /dev/null
+++ b/apps/api/src/knowledge-base/knowledge-base.service.spec.ts
@@ -0,0 +1,398 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { KnowledgeBaseService } from './knowledge-base.service';
+
+jest.mock('@db', () => ({
+ db: {
+ knowledgeBaseDocument: {
+ findMany: jest.fn(),
+ findUnique: jest.fn(),
+ create: jest.fn(),
+ delete: jest.fn(),
+ },
+ securityQuestionnaireManualAnswer: {
+ findMany: jest.fn(),
+ findUnique: jest.fn(),
+ upsert: jest.fn(),
+ delete: jest.fn(),
+ deleteMany: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@trigger.dev/sdk', () => ({
+ tasks: { trigger: jest.fn() },
+ auth: { createPublicToken: jest.fn() },
+}));
+
+jest.mock('@/vector-store/lib', () => ({
+ syncManualAnswerToVector: jest.fn(),
+}));
+
+jest.mock('./utils/s3-operations', () => ({
+ uploadToS3: jest.fn(),
+ generateDownloadUrl: jest.fn(),
+ generateViewUrl: jest.fn(),
+ deleteFromS3: jest.fn(),
+}));
+
+jest.mock('./utils/constants', () => ({
+ isViewableInBrowser: jest.fn(),
+}));
+
+jest.mock(
+ '@/trigger/vector-store/process-knowledge-base-document',
+ () => ({}),
+);
+jest.mock(
+ '@/trigger/vector-store/process-knowledge-base-documents-orchestrator',
+ () => ({}),
+);
+jest.mock(
+ '@/trigger/vector-store/delete-knowledge-base-document',
+ () => ({}),
+);
+jest.mock('@/trigger/vector-store/delete-manual-answer', () => ({}));
+jest.mock(
+ '@/trigger/vector-store/delete-all-manual-answers-orchestrator',
+ () => ({}),
+);
+
+import { db } from '@db';
+import { tasks, auth } from '@trigger.dev/sdk';
+import { syncManualAnswerToVector } from '@/vector-store/lib';
+import {
+ uploadToS3,
+ generateDownloadUrl,
+ generateViewUrl,
+ deleteFromS3,
+} from './utils/s3-operations';
+
+const mockDb = db as jest.Mocked;
+
+describe('KnowledgeBaseService', () => {
+ let service: KnowledgeBaseService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [KnowledgeBaseService],
+ }).compile();
+
+ service = module.get(KnowledgeBaseService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('listDocuments', () => {
+ it('should return documents for organization', async () => {
+ const mockDocs = [
+ { id: 'd1', name: 'doc.pdf', processingStatus: 'completed' },
+ ];
+ (mockDb.knowledgeBaseDocument.findMany as jest.Mock).mockResolvedValue(
+ mockDocs,
+ );
+
+ const result = await service.listDocuments('org_1');
+
+ expect(result).toEqual(mockDocs);
+ expect(mockDb.knowledgeBaseDocument.findMany).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ select: expect.objectContaining({
+ id: true,
+ name: true,
+ processingStatus: true,
+ }),
+ orderBy: { createdAt: 'desc' },
+ });
+ });
+ });
+
+ describe('listManualAnswers', () => {
+ it('should return manual answers for organization', async () => {
+ const mockAnswers = [
+ { id: 'ma1', question: 'Q1?', answer: 'A1', tags: [] },
+ ];
+ (
+ mockDb.securityQuestionnaireManualAnswer.findMany as jest.Mock
+ ).mockResolvedValue(mockAnswers);
+
+ const result = await service.listManualAnswers('org_1');
+
+ expect(result).toEqual(mockAnswers);
+ expect(
+ mockDb.securityQuestionnaireManualAnswer.findMany,
+ ).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ select: expect.objectContaining({
+ id: true,
+ question: true,
+ answer: true,
+ tags: true,
+ }),
+ orderBy: { updatedAt: 'desc' },
+ });
+ });
+ });
+
+ describe('saveManualAnswer', () => {
+ it('should upsert manual answer and sync to vector DB', async () => {
+ (
+ mockDb.securityQuestionnaireManualAnswer.upsert as jest.Mock
+ ).mockResolvedValue({ id: 'ma1' });
+ (syncManualAnswerToVector as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await service.saveManualAnswer({
+ organizationId: 'org_1',
+ question: 'Q1?',
+ answer: 'A1',
+ tags: ['security'],
+ });
+
+ expect(result).toEqual({ success: true, manualAnswerId: 'ma1' });
+ expect(
+ mockDb.securityQuestionnaireManualAnswer.upsert,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: {
+ organizationId_question: {
+ organizationId: 'org_1',
+ question: 'Q1?',
+ },
+ },
+ create: expect.objectContaining({
+ question: 'Q1?',
+ answer: 'A1',
+ tags: ['security'],
+ }),
+ update: expect.objectContaining({
+ answer: 'A1',
+ tags: ['security'],
+ }),
+ }),
+ );
+ expect(syncManualAnswerToVector).toHaveBeenCalledWith('ma1', 'org_1');
+ });
+
+ it('should still succeed if vector sync fails', async () => {
+ (
+ mockDb.securityQuestionnaireManualAnswer.upsert as jest.Mock
+ ).mockResolvedValue({ id: 'ma1' });
+ (syncManualAnswerToVector as jest.Mock).mockRejectedValue(
+ new Error('Vector DB error'),
+ );
+
+ const result = await service.saveManualAnswer({
+ organizationId: 'org_1',
+ question: 'Q1?',
+ answer: 'A1',
+ });
+
+ expect(result).toEqual({ success: true, manualAnswerId: 'ma1' });
+ });
+ });
+
+ describe('uploadDocument', () => {
+ it('should upload to S3 and create DB record', async () => {
+ (uploadToS3 as jest.Mock).mockResolvedValue({
+ s3Key: 'org_1/doc.pdf',
+ fileSize: 1024,
+ });
+ (mockDb.knowledgeBaseDocument.create as jest.Mock).mockResolvedValue({
+ id: 'd1',
+ name: 'doc.pdf',
+ s3Key: 'org_1/doc.pdf',
+ });
+
+ const result = await service.uploadDocument({
+ organizationId: 'org_1',
+ fileName: 'doc.pdf',
+ fileType: 'application/pdf',
+ fileData: 'base64data',
+ } as any);
+
+ expect(result).toEqual({
+ id: 'd1',
+ name: 'doc.pdf',
+ s3Key: 'org_1/doc.pdf',
+ });
+ expect(uploadToS3).toHaveBeenCalledWith(
+ 'org_1',
+ 'doc.pdf',
+ 'application/pdf',
+ 'base64data',
+ );
+ });
+ });
+
+ describe('getDownloadUrl', () => {
+ it('should generate signed download URL', async () => {
+ (mockDb.knowledgeBaseDocument.findUnique as jest.Mock).mockResolvedValue({
+ s3Key: 'key',
+ name: 'doc.pdf',
+ fileType: 'application/pdf',
+ });
+ (generateDownloadUrl as jest.Mock).mockResolvedValue({
+ signedUrl: 'https://s3.example.com/signed',
+ });
+
+ const result = await service.getDownloadUrl({
+ documentId: 'd1',
+ organizationId: 'org_1',
+ });
+
+ expect(result.signedUrl).toBe('https://s3.example.com/signed');
+ expect(result.fileName).toBe('doc.pdf');
+ });
+
+ it('should throw when document not found', async () => {
+ (mockDb.knowledgeBaseDocument.findUnique as jest.Mock).mockResolvedValue(
+ null,
+ );
+
+ await expect(
+ service.getDownloadUrl({
+ documentId: 'missing',
+ organizationId: 'org_1',
+ }),
+ ).rejects.toThrow('Document not found');
+ });
+ });
+
+ describe('deleteDocument', () => {
+ it('should delete from vector DB, S3, and database', async () => {
+ (mockDb.knowledgeBaseDocument.findUnique as jest.Mock).mockResolvedValue({
+ id: 'd1',
+ s3Key: 'key',
+ });
+ (tasks.trigger as jest.Mock).mockResolvedValue({ id: 'run_1' });
+ (auth.createPublicToken as jest.Mock).mockResolvedValue('token_1');
+ (deleteFromS3 as jest.Mock).mockResolvedValue(true);
+ (mockDb.knowledgeBaseDocument.delete as jest.Mock).mockResolvedValue({});
+
+ const result = await service.deleteDocument({
+ documentId: 'd1',
+ organizationId: 'org_1',
+ });
+
+ expect(result.success).toBe(true);
+ expect(deleteFromS3).toHaveBeenCalledWith('key');
+ expect(mockDb.knowledgeBaseDocument.delete).toHaveBeenCalledWith({
+ where: { id: 'd1' },
+ });
+ });
+
+ it('should throw when document not found', async () => {
+ (mockDb.knowledgeBaseDocument.findUnique as jest.Mock).mockResolvedValue(
+ null,
+ );
+
+ await expect(
+ service.deleteDocument({
+ documentId: 'missing',
+ organizationId: 'org_1',
+ }),
+ ).rejects.toThrow('Document not found');
+ });
+ });
+
+ describe('deleteManualAnswer', () => {
+ it('should delete manual answer and trigger vector deletion', async () => {
+ (
+ mockDb.securityQuestionnaireManualAnswer.findUnique as jest.Mock
+ ).mockResolvedValue({ id: 'ma1' });
+ (tasks.trigger as jest.Mock).mockResolvedValue({ id: 'run_1' });
+ (
+ mockDb.securityQuestionnaireManualAnswer.delete as jest.Mock
+ ).mockResolvedValue({});
+
+ const result = await service.deleteManualAnswer({
+ manualAnswerId: 'ma1',
+ organizationId: 'org_1',
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(
+ mockDb.securityQuestionnaireManualAnswer.delete,
+ ).toHaveBeenCalledWith({ where: { id: 'ma1' } });
+ });
+
+ it('should return error when manual answer not found', async () => {
+ (
+ mockDb.securityQuestionnaireManualAnswer.findUnique as jest.Mock
+ ).mockResolvedValue(null);
+
+ const result = await service.deleteManualAnswer({
+ manualAnswerId: 'missing',
+ organizationId: 'org_1',
+ });
+
+ expect(result).toEqual({
+ success: false,
+ error: 'Manual answer not found',
+ });
+ });
+ });
+
+ describe('deleteAllManualAnswers', () => {
+ it('should delete all manual answers and trigger batch deletion', async () => {
+ (
+ mockDb.securityQuestionnaireManualAnswer.findMany as jest.Mock
+ ).mockResolvedValue([{ id: 'ma1' }, { id: 'ma2' }]);
+ (tasks.trigger as jest.Mock).mockResolvedValue({ id: 'run_1' });
+ (
+ mockDb.securityQuestionnaireManualAnswer.deleteMany as jest.Mock
+ ).mockResolvedValue({ count: 2 });
+
+ const result = await service.deleteAllManualAnswers({
+ organizationId: 'org_1',
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(tasks.trigger).toHaveBeenCalled();
+ expect(
+ mockDb.securityQuestionnaireManualAnswer.deleteMany,
+ ).toHaveBeenCalledWith({
+ where: { organizationId: 'org_1' },
+ });
+ });
+
+ it('should skip vector deletion when no manual answers exist', async () => {
+ (
+ mockDb.securityQuestionnaireManualAnswer.findMany as jest.Mock
+ ).mockResolvedValue([]);
+ (
+ mockDb.securityQuestionnaireManualAnswer.deleteMany as jest.Mock
+ ).mockResolvedValue({ count: 0 });
+
+ const result = await service.deleteAllManualAnswers({
+ organizationId: 'org_1',
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(tasks.trigger).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('createRunReadToken', () => {
+ it('should create and return public token', async () => {
+ (auth.createPublicToken as jest.Mock).mockResolvedValue('token_123');
+
+ const result = await service.createRunReadToken('run_1');
+
+ expect(result).toBe('token_123');
+ expect(auth.createPublicToken).toHaveBeenCalledWith({
+ scopes: { read: { runs: ['run_1'] } },
+ expirationTime: '1hr',
+ });
+ });
+
+ it('should return undefined when token creation fails', async () => {
+ (auth.createPublicToken as jest.Mock).mockRejectedValue(
+ new Error('Token error'),
+ );
+
+ const result = await service.createRunReadToken('run_1');
+
+ expect(result).toBeUndefined();
+ });
+ });
+});
diff --git a/apps/api/src/knowledge-base/knowledge-base.service.ts b/apps/api/src/knowledge-base/knowledge-base.service.ts
index 245530fc6b..1b754192db 100644
--- a/apps/api/src/knowledge-base/knowledge-base.service.ts
+++ b/apps/api/src/knowledge-base/knowledge-base.service.ts
@@ -1,6 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { db } from '@db';
import { tasks, auth } from '@trigger.dev/sdk';
+import { syncManualAnswerToVector } from '@/vector-store/lib';
import { UploadDocumentDto } from './dto/upload-document.dto';
import { DeleteDocumentDto } from './dto/delete-document.dto';
import { GetDocumentUrlDto } from './dto/get-document-url.dto';
@@ -211,6 +212,71 @@ export class KnowledgeBaseService {
}
}
+ async listManualAnswers(organizationId: string) {
+ return db.securityQuestionnaireManualAnswer.findMany({
+ where: { organizationId },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ tags: true,
+ sourceQuestionnaireId: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ orderBy: { updatedAt: 'desc' },
+ });
+ }
+
+ async saveManualAnswer(dto: {
+ organizationId: string;
+ question: string;
+ answer: string;
+ tags?: string[];
+ sourceQuestionnaireId?: string;
+ }) {
+ const manualAnswer = await db.securityQuestionnaireManualAnswer.upsert({
+ where: {
+ organizationId_question: {
+ organizationId: dto.organizationId,
+ question: dto.question.trim(),
+ },
+ },
+ create: {
+ question: dto.question.trim(),
+ answer: dto.answer.trim(),
+ tags: dto.tags || [],
+ organizationId: dto.organizationId,
+ sourceQuestionnaireId: dto.sourceQuestionnaireId || null,
+ createdBy: null,
+ updatedBy: null,
+ },
+ update: {
+ answer: dto.answer.trim(),
+ tags: dto.tags || [],
+ sourceQuestionnaireId: dto.sourceQuestionnaireId || null,
+ updatedBy: null,
+ updatedAt: new Date(),
+ },
+ });
+
+ // Sync to vector DB
+ try {
+ await syncManualAnswerToVector(manualAnswer.id, dto.organizationId);
+ } catch (error) {
+ this.logger.error('Failed to sync manual answer to vector DB', {
+ manualAnswerId: manualAnswer.id,
+ organizationId: dto.organizationId,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+
+ return {
+ success: true,
+ manualAnswerId: manualAnswer.id,
+ };
+ }
+
async deleteManualAnswer(
dto: DeleteManualAnswerDto & { manualAnswerId: string },
) {
diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts
index 42b11e7dfb..c887b87333 100644
--- a/apps/api/src/main.ts
+++ b/apps/api/src/main.ts
@@ -13,7 +13,11 @@ import { mkdirSync, writeFileSync, existsSync } from 'fs';
let app: INestApplication | null = null;
async function bootstrap(): Promise {
- app = await NestFactory.create(AppModule);
+ // Disable body parser - required for better-auth NestJS integration
+ // The library will re-add body parsers after handling auth routes
+ app = await NestFactory.create(AppModule, {
+ bodyParser: false,
+ });
// Enable CORS for all origins - security is handled by authentication
app.enableCors({
diff --git a/apps/api/src/notifications/check-unsubscribe.spec.ts b/apps/api/src/notifications/check-unsubscribe.spec.ts
new file mode 100644
index 0000000000..fd9c05482b
--- /dev/null
+++ b/apps/api/src/notifications/check-unsubscribe.spec.ts
@@ -0,0 +1,427 @@
+import { isUserUnsubscribed } from '@trycompai/email';
+import type { EmailPreferenceType } from '@trycompai/email';
+
+/**
+ * Helper to build a mock db object for isUserUnsubscribed.
+ * Only requires user.findUnique; member and roleNotificationSetting are optional.
+ */
+function createMockDb(overrides?: {
+ user?: {
+ emailNotificationsUnsubscribed: boolean;
+ emailPreferences: unknown;
+ } | null;
+ members?: { role: string }[];
+ roleSettings?: {
+ policyNotifications: boolean;
+ taskReminders: boolean;
+ taskAssignments: boolean;
+ taskMentions: boolean;
+ weeklyTaskDigest: boolean;
+ findingNotifications: boolean;
+ }[];
+}) {
+ return {
+ user: {
+ findUnique: jest.fn().mockResolvedValue(
+ overrides?.user === undefined
+ ? {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: null,
+ }
+ : overrides.user,
+ ),
+ },
+ member: {
+ findMany: jest.fn().mockResolvedValue(overrides?.members ?? []),
+ },
+ roleNotificationSetting: {
+ findMany: jest.fn().mockResolvedValue(overrides?.roleSettings ?? []),
+ },
+ };
+}
+
+const ALL_ON = {
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: true,
+ findingNotifications: true,
+};
+
+const ALL_OFF = {
+ policyNotifications: false,
+ taskReminders: false,
+ taskAssignments: false,
+ taskMentions: false,
+ weeklyTaskDigest: false,
+ findingNotifications: false,
+};
+
+describe('isUserUnsubscribed', () => {
+ const email = 'user@example.com';
+ const orgId = 'org_123';
+
+ // ---------------------------------------------------------------------------
+ // Legacy behavior (no organizationId)
+ // ---------------------------------------------------------------------------
+ describe('legacy behavior (no organizationId)', () => {
+ it('should return false when user is not found', async () => {
+ const db = createMockDb({ user: null });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders');
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when legacy unsubscribe flag is set', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: true,
+ emailPreferences: null,
+ },
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders');
+
+ expect(result).toBe(true);
+ });
+
+ it('should return true for any preference type when legacy flag is set', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: true,
+ emailPreferences: null,
+ },
+ });
+
+ const types: EmailPreferenceType[] = [
+ 'policyNotifications',
+ 'taskReminders',
+ 'weeklyTaskDigest',
+ 'taskMentions',
+ 'taskAssignments',
+ 'findingNotifications',
+ ];
+
+ for (const type of types) {
+ expect(await isUserUnsubscribed(db, email, type)).toBe(true);
+ }
+ });
+
+ it('should return false when no preference type is specified and not unsubscribed', async () => {
+ const db = createMockDb();
+
+ const result = await isUserUnsubscribed(db, email);
+
+ expect(result).toBe(false);
+ });
+
+ it('should return true when a specific preference is disabled', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false },
+ },
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders');
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when a specific preference is enabled', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: true },
+ },
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders');
+
+ expect(result).toBe(false);
+ });
+
+ it('should default to enabled when no email preferences are stored', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: null,
+ },
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'policyNotifications');
+
+ expect(result).toBe(false);
+ });
+
+ it('should merge stored preferences with defaults', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false },
+ // policyNotifications not set — should default to true
+ },
+ });
+
+ expect(await isUserUnsubscribed(db, email, 'taskReminders')).toBe(true);
+ expect(await isUserUnsubscribed(db, email, 'policyNotifications')).toBe(false);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Role-based behavior (with organizationId)
+ // ---------------------------------------------------------------------------
+ describe('role-based notification settings', () => {
+ it('should return true when all roles disable the notification', async () => {
+ const db = createMockDb({
+ members: [{ role: 'employee' }],
+ roleSettings: [{ ...ALL_OFF }],
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when at least one role enables the notification (union)', async () => {
+ const db = createMockDb({
+ members: [{ role: 'auditor' }, { role: 'employee' }],
+ roleSettings: [
+ { ...ALL_OFF, taskReminders: false }, // auditor: OFF
+ { ...ALL_OFF, taskReminders: true }, // employee: ON
+ ],
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(false);
+ });
+
+ it('should honor existing personal opt-out for non-admin users when role says ON', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false }, // user previously opted out
+ },
+ members: [{ role: 'employee' }],
+ roleSettings: [{ ...ALL_ON }], // role says ON
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(true); // existing opt-out is preserved
+ });
+
+ it('should not unsubscribe non-admin users who have no personal opt-out when role says ON', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: null, // no personal preferences set
+ },
+ members: [{ role: 'employee' }],
+ roleSettings: [{ ...ALL_ON }], // role says ON
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(false); // defaults to enabled
+ });
+
+ it('should allow admin to opt out via personal preferences when role says ON', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false }, // admin opted out
+ },
+ members: [{ role: 'admin' }],
+ roleSettings: [{ ...ALL_ON }], // role says ON
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(true); // admin can opt out
+ });
+
+ it('should allow owner to opt out via personal preferences when role says ON', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { weeklyTaskDigest: false },
+ },
+ members: [{ role: 'owner' }],
+ roleSettings: [{ ...ALL_ON }],
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'weeklyTaskDigest', orgId);
+
+ expect(result).toBe(true);
+ });
+
+ it('should fall through to personal preferences when no role settings exist', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false },
+ },
+ members: [{ role: 'employee' }],
+ roleSettings: [], // no role settings configured
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(true); // falls through to personal pref
+ });
+
+ it('should fall through to personal preferences when no member records found', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false },
+ },
+ members: [], // user not a member of this org
+ roleSettings: [],
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(true); // falls through to personal pref
+ });
+
+ it('should handle comma-separated roles on a single member record', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: null, // no personal opt-out
+ },
+ members: [{ role: 'auditor,employee' }],
+ roleSettings: [
+ { ...ALL_OFF, taskReminders: false }, // auditor: OFF
+ { ...ALL_OFF, taskReminders: true }, // employee: ON
+ ],
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(false); // employee role enables it, no personal opt-out
+ });
+
+ it('should treat comma-separated admin role as admin for opt-out', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false },
+ },
+ members: [{ role: 'admin,auditor' }],
+ roleSettings: [{ ...ALL_ON }, { ...ALL_ON }],
+ });
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(true); // admin portion allows opt-out
+ });
+
+ it('should handle unassignedItemsNotifications without role-level setting', async () => {
+ // unassignedItemsNotifications has no role-level mapping, so it should
+ // always fall through to personal preferences regardless of org context
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { unassignedItemsNotifications: false },
+ },
+ members: [{ role: 'employee' }],
+ roleSettings: [{ ...ALL_ON }],
+ });
+
+ const result = await isUserUnsubscribed(
+ db,
+ email,
+ 'unassignedItemsNotifications',
+ orgId,
+ );
+
+ expect(result).toBe(true); // falls through to personal pref
+ });
+
+ it('should check each notification type independently', async () => {
+ const db = createMockDb({
+ members: [{ role: 'employee' }],
+ roleSettings: [
+ {
+ policyNotifications: true,
+ taskReminders: false,
+ taskAssignments: true,
+ taskMentions: false,
+ weeklyTaskDigest: true,
+ findingNotifications: false,
+ },
+ ],
+ });
+
+ // ON by role
+ expect(await isUserUnsubscribed(db, email, 'policyNotifications', orgId)).toBe(false);
+ expect(await isUserUnsubscribed(db, email, 'taskAssignments', orgId)).toBe(false);
+ expect(await isUserUnsubscribed(db, email, 'weeklyTaskDigest', orgId)).toBe(false);
+
+ // OFF by role
+ expect(await isUserUnsubscribed(db, email, 'taskReminders', orgId)).toBe(true);
+ expect(await isUserUnsubscribed(db, email, 'taskMentions', orgId)).toBe(true);
+ expect(await isUserUnsubscribed(db, email, 'findingNotifications', orgId)).toBe(true);
+ });
+ });
+
+ // ---------------------------------------------------------------------------
+ // Edge cases & error handling
+ // ---------------------------------------------------------------------------
+ describe('edge cases', () => {
+ it('should return false when db.user.findUnique throws', async () => {
+ const db = createMockDb();
+ db.user.findUnique.mockRejectedValue(new Error('DB connection error'));
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders');
+
+ expect(result).toBe(false);
+ });
+
+ it('should work without member/roleNotificationSetting on db object', async () => {
+ // When db doesn't have member or roleNotificationSetting (e.g., legacy callers)
+ const db = {
+ user: {
+ findUnique: jest.fn().mockResolvedValue({
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: { taskReminders: false },
+ }),
+ },
+ };
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ // Should fall through to personal preferences since db.member is missing
+ expect(result).toBe(true);
+ });
+
+ it('should return false on error during role lookup', async () => {
+ const db = createMockDb();
+ db.member.findMany.mockRejectedValue(new Error('Query failed'));
+
+ const result = await isUserUnsubscribed(db, email, 'taskReminders', orgId);
+
+ expect(result).toBe(false);
+ });
+
+ it('should handle emailPreferences that is not an object', async () => {
+ const db = createMockDb({
+ user: {
+ emailNotificationsUnsubscribed: false,
+ emailPreferences: 'invalid' as unknown,
+ },
+ });
+
+ // Should use defaults (all true) since preferences is not an object
+ const result = await isUserUnsubscribed(db, email, 'taskReminders');
+
+ expect(result).toBe(false);
+ });
+ });
+});
diff --git a/apps/api/src/org-chart/org-chart.controller.spec.ts b/apps/api/src/org-chart/org-chart.controller.spec.ts
new file mode 100644
index 0000000000..e7cc4b0a24
--- /dev/null
+++ b/apps/api/src/org-chart/org-chart.controller.spec.ts
@@ -0,0 +1,129 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { OrgChartController } from './org-chart.controller';
+import { OrgChartService } from './org-chart.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ organization: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('OrgChartController', () => {
+ let controller: OrgChartController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ findByOrganization: jest.fn(),
+ upsertInteractive: jest.fn(),
+ uploadImage: jest.fn(),
+ delete: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [OrgChartController],
+ providers: [{ provide: OrgChartService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(OrgChartController);
+ service = module.get(OrgChartService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getOrgChart', () => {
+ it('should call service.findByOrganization with organizationId', async () => {
+ const mockChart = { id: 'chart_1', type: 'interactive', nodes: [] };
+ mockService.findByOrganization.mockResolvedValue(mockChart);
+
+ const result = await controller.getOrgChart('org_1');
+
+ expect(result).toEqual(mockChart);
+ expect(service.findByOrganization).toHaveBeenCalledWith('org_1');
+ });
+ });
+
+ describe('upsertOrgChart', () => {
+ it('should call service.upsertInteractive with organizationId and parsed dto', async () => {
+ const body = {
+ name: 'My Org Chart',
+ nodes: [{ id: 'n1', label: 'CEO' }],
+ edges: [{ id: 'e1', source: 'n1', target: 'n2' }],
+ };
+ const mockResult = { id: 'chart_1', ...body };
+ mockService.upsertInteractive.mockResolvedValue(mockResult);
+
+ const result = await controller.upsertOrgChart('org_1', body);
+
+ expect(result).toEqual(mockResult);
+ expect(service.upsertInteractive).toHaveBeenCalledWith('org_1', {
+ name: 'My Org Chart',
+ nodes: [{ id: 'n1', label: 'CEO' }],
+ edges: [{ id: 'e1', source: 'n1', target: 'n2' }],
+ });
+ });
+
+ it('should default nodes and edges to empty arrays when not provided', async () => {
+ mockService.upsertInteractive.mockResolvedValue({ id: 'chart_1' });
+
+ await controller.upsertOrgChart('org_1', {});
+
+ expect(service.upsertInteractive).toHaveBeenCalledWith('org_1', {
+ name: undefined,
+ nodes: [],
+ edges: [],
+ });
+ });
+
+ it('should default name to undefined when not a string', async () => {
+ mockService.upsertInteractive.mockResolvedValue({ id: 'chart_1' });
+
+ await controller.upsertOrgChart('org_1', { name: 123 });
+
+ expect(service.upsertInteractive).toHaveBeenCalledWith('org_1', {
+ name: undefined,
+ nodes: [],
+ edges: [],
+ });
+ });
+ });
+
+ describe('uploadOrgChart', () => {
+ it('should call service.uploadImage with organizationId and dto', async () => {
+ const dto = { imageUrl: 'https://example.com/chart.png' };
+ const mockResult = { id: 'chart_1', type: 'image' };
+ mockService.uploadImage.mockResolvedValue(mockResult);
+
+ const result = await controller.uploadOrgChart('org_1', dto as never);
+
+ expect(result).toEqual(mockResult);
+ expect(service.uploadImage).toHaveBeenCalledWith('org_1', dto);
+ });
+ });
+
+ describe('deleteOrgChart', () => {
+ it('should call service.delete with organizationId', async () => {
+ const mockResult = { success: true };
+ mockService.delete.mockResolvedValue(mockResult);
+
+ const result = await controller.deleteOrgChart('org_1');
+
+ expect(result).toEqual(mockResult);
+ expect(service.delete).toHaveBeenCalledWith('org_1');
+ });
+ });
+});
diff --git a/apps/api/src/org-chart/org-chart.controller.ts b/apps/api/src/org-chart/org-chart.controller.ts
index e3a9334849..6791ad5f2a 100644
--- a/apps/api/src/org-chart/org-chart.controller.ts
+++ b/apps/api/src/org-chart/org-chart.controller.ts
@@ -18,13 +18,15 @@ import {
} from '@nestjs/swagger';
import { 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 { OrgChartService } from './org-chart.service';
import { UpsertOrgChartDto } from './dto/upsert-org-chart.dto';
import { UploadOrgChartDto } from './dto/upload-org-chart.dto';
@ApiTags('Org Chart')
@Controller({ path: 'org-chart', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
@@ -36,6 +38,7 @@ export class OrgChartController {
constructor(private readonly orgChartService: OrgChartService) {}
@Get()
+ @RequirePermission('organization', 'read')
@ApiOperation({ summary: 'Get the organization chart' })
@ApiResponse({ status: 200, description: 'The organization chart' })
async getOrgChart(@OrganizationId() organizationId: string) {
@@ -43,6 +46,7 @@ export class OrgChartController {
}
@Put()
+ @RequirePermission('organization', 'update')
@ApiOperation({
summary: 'Create or update an interactive organization chart',
})
@@ -61,6 +65,7 @@ export class OrgChartController {
}
@Post('upload')
+ @RequirePermission('organization', 'update')
@ApiOperation({ summary: 'Upload an image as the organization chart' })
@ApiResponse({ status: 201, description: 'The uploaded organization chart' })
@UsePipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }))
@@ -72,6 +77,7 @@ export class OrgChartController {
}
@Delete()
+ @RequirePermission('organization', 'delete')
@ApiOperation({ summary: 'Delete the organization chart' })
@ApiResponse({ status: 200, description: 'Deletion confirmation' })
async deleteOrgChart(@OrganizationId() organizationId: string) {
diff --git a/apps/api/src/organization/dto/update-organization.dto.ts b/apps/api/src/organization/dto/update-organization.dto.ts
index 48a219f408..de0f3f6f73 100644
--- a/apps/api/src/organization/dto/update-organization.dto.ts
+++ b/apps/api/src/organization/dto/update-organization.dto.ts
@@ -9,4 +9,5 @@ export interface UpdateOrganizationDto {
fleetDmLabelId?: number;
isFleetSetupCompleted?: boolean;
primaryColor?: string;
+ advancedModeEnabled?: boolean;
}
diff --git a/apps/api/src/organization/organization.controller.spec.ts b/apps/api/src/organization/organization.controller.spec.ts
new file mode 100644
index 0000000000..b07e065a84
--- /dev/null
+++ b/apps/api/src/organization/organization.controller.spec.ts
@@ -0,0 +1,234 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { BadRequestException } from '@nestjs/common';
+import { OrganizationController } from './organization.controller';
+import { OrganizationService } from './organization.service';
+import { ApiKeyService } from '../auth/api-key.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext } from '../auth/types';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: {
+ api: {
+ getSession: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ organization: ['read', 'update', 'delete'],
+ member: ['create', 'read', 'update', 'delete'],
+ apiKey: ['create', 'read', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('OrganizationController', () => {
+ let controller: OrganizationController;
+
+ const mockOrganizationService = {
+ findById: jest.fn(),
+ getLogoSignedUrl: jest.fn(),
+ getOwnershipData: jest.fn(),
+ updateRoleNotifications: jest.fn(),
+ findOnboarding: jest.fn(),
+ updateById: jest.fn(),
+ deleteById: jest.fn(),
+ transferOwnership: jest.fn(),
+ getPrimaryColor: jest.fn(),
+ uploadLogo: jest.fn(),
+ removeLogo: jest.fn(),
+ listApiKeys: jest.fn(),
+ getRoleNotificationSettings: jest.fn(),
+ };
+
+ const mockApiKeyService = {
+ getAvailableScopes: jest.fn(),
+ create: jest.fn(),
+ revoke: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const sessionAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'usr_123',
+ userEmail: 'test@example.com',
+ userRoles: ['owner'],
+ };
+
+ const apiKeyAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'api-key',
+ isApiKey: true,
+ isPlatformAdmin: false,
+ userId: undefined,
+ userEmail: undefined,
+ userRoles: [],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [OrganizationController],
+ providers: [
+ { provide: OrganizationService, useValue: mockOrganizationService },
+ { provide: ApiKeyService, useValue: mockApiKeyService },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(OrganizationController);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getOrganization', () => {
+ const mockOrg = { id: 'org_123', name: 'Test Org', logo: 'logo.png' };
+ const mockLogoUrl = 'https://s3.example.com/logo.png';
+
+ beforeEach(() => {
+ mockOrganizationService.findById.mockResolvedValue(mockOrg);
+ mockOrganizationService.getLogoSignedUrl.mockResolvedValue(mockLogoUrl);
+ });
+
+ it('should return org with authenticatedUser for session auth', async () => {
+ const result = await controller.getOrganization(
+ 'org_123',
+ sessionAuthContext,
+ );
+
+ expect(result).toMatchObject({
+ ...mockOrg,
+ logoUrl: mockLogoUrl,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ expect(mockOrganizationService.findById).toHaveBeenCalledWith('org_123');
+ });
+
+ it('should return org without authenticatedUser for API key auth', async () => {
+ const result = await controller.getOrganization(
+ 'org_123',
+ apiKeyAuthContext,
+ );
+
+ expect(result.authType).toBe('api-key');
+ expect(result.authenticatedUser).toBeUndefined();
+ });
+
+ it('should include ownership data when includeOwnership=true and session auth', async () => {
+ const ownershipData = {
+ isOwner: true,
+ eligibleMembers: [{ id: 'mem_1', name: 'Alice' }],
+ };
+ mockOrganizationService.getOwnershipData.mockResolvedValue(ownershipData);
+
+ const result = await controller.getOrganization(
+ 'org_123',
+ sessionAuthContext,
+ 'true',
+ );
+
+ expect(result.isOwner).toBe(true);
+ expect(result.eligibleMembers).toEqual(ownershipData.eligibleMembers);
+ expect(mockOrganizationService.getOwnershipData).toHaveBeenCalledWith(
+ 'org_123',
+ 'usr_123',
+ );
+ });
+
+ it('should not include ownership data for API key auth even when includeOwnership=true', async () => {
+ const result = await controller.getOrganization(
+ 'org_123',
+ apiKeyAuthContext,
+ 'true',
+ );
+
+ expect(result.isOwner).toBeUndefined();
+ expect(result.eligibleMembers).toBeUndefined();
+ expect(mockOrganizationService.getOwnershipData).not.toHaveBeenCalled();
+ });
+
+ it('should not include ownership data when includeOwnership is not "true"', async () => {
+ const result = await controller.getOrganization(
+ 'org_123',
+ sessionAuthContext,
+ 'false',
+ );
+
+ expect(result.isOwner).toBeUndefined();
+ expect(mockOrganizationService.getOwnershipData).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('updateRoleNotifications', () => {
+ const validSettings = [
+ {
+ role: 'admin',
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: false,
+ findingNotifications: true,
+ },
+ ];
+
+ it('should pass valid settings to service', async () => {
+ mockOrganizationService.updateRoleNotifications.mockResolvedValue({
+ success: true,
+ });
+
+ const result = await controller.updateRoleNotifications('org_123', {
+ settings: validSettings,
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(
+ mockOrganizationService.updateRoleNotifications,
+ ).toHaveBeenCalledWith('org_123', validSettings);
+ });
+
+ it('should throw BadRequestException with empty body', async () => {
+ await expect(
+ controller.updateRoleNotifications(
+ 'org_123',
+ {} as { settings: never[] },
+ ),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('should throw BadRequestException with null settings', async () => {
+ await expect(
+ controller.updateRoleNotifications('org_123', {
+ settings: null as unknown as never[],
+ }),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('should throw BadRequestException with non-array settings', async () => {
+ await expect(
+ controller.updateRoleNotifications('org_123', {
+ settings: 'not-an-array' as unknown as never[],
+ }),
+ ).rejects.toThrow(BadRequestException);
+ });
+
+ it('should throw with message about settings being required', async () => {
+ await expect(
+ controller.updateRoleNotifications(
+ 'org_123',
+ {} as { settings: never[] },
+ ),
+ ).rejects.toThrow('settings is required and must be an array');
+ });
+ });
+});
diff --git a/apps/api/src/organization/organization.controller.ts b/apps/api/src/organization/organization.controller.ts
index 73f1ed3ffc..3024ddea42 100644
--- a/apps/api/src/organization/organization.controller.ts
+++ b/apps/api/src/organization/organization.controller.ts
@@ -6,12 +6,12 @@ import {
Get,
Patch,
Post,
+ Put,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBody,
- ApiHeader,
ApiOperation,
ApiQuery,
ApiResponse,
@@ -20,10 +20,12 @@ import {
} from '@nestjs/swagger';
import {
AuthContext,
- IsApiKeyAuth,
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 { ApiKeyService } from '../auth/api-key.service';
import type { AuthContext as AuthContextType } from '../auth/types';
import type { UpdateOrganizationDto } from './dto/update-organization.dto';
import type { TransferOwnershipDto } from './dto/transfer-ownership.dto';
@@ -41,32 +43,32 @@ import { ORGANIZATION_OPERATIONS } from './schemas/organization-operations';
@ApiTags('Organization')
@Controller({ path: 'organization', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey') // Still document API key for external customers
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class OrganizationController {
- constructor(private readonly organizationService: OrganizationService) {}
+ constructor(
+ private readonly organizationService: OrganizationService,
+ private readonly apiKeyService: ApiKeyService,
+ ) {}
@Get()
+ @RequirePermission('organization', 'read')
@ApiOperation(ORGANIZATION_OPERATIONS.getOrganization)
+ @ApiQuery({ name: 'includeOwnership', required: false, description: 'Include ownership data for transfer UI' })
@ApiResponse(GET_ORGANIZATION_RESPONSES[200])
@ApiResponse(GET_ORGANIZATION_RESPONSES[401])
async getOrganization(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
- @IsApiKeyAuth() isApiKey: boolean,
+ @Query('includeOwnership') includeOwnership?: string,
) {
const org = await this.organizationService.findById(organizationId);
+ const logoUrl = await this.organizationService.getLogoSignedUrl(org.logo);
- return {
+ const result: Record = {
...org,
+ logoUrl,
authType: authContext.authType,
- // Include user context for session auth (helpful for debugging)
...(authContext.userId && {
authenticatedUser: {
id: authContext.userId,
@@ -74,9 +76,27 @@ export class OrganizationController {
},
}),
};
+
+ if (includeOwnership === 'true' && authContext.userId) {
+ const ownership = await this.organizationService.getOwnershipData(
+ organizationId,
+ authContext.userId,
+ );
+ result.isOwner = ownership.isOwner;
+ result.eligibleMembers = ownership.eligibleMembers;
+ }
+
+ return result;
+ }
+
+ @Get('onboarding')
+ @RequirePermission('organization', 'read')
+ async getOnboarding(@OrganizationId() organizationId: string) {
+ return this.organizationService.findOnboarding(organizationId);
}
@Patch()
+ @RequirePermission('organization', 'update')
@ApiOperation(ORGANIZATION_OPERATIONS.updateOrganization)
@ApiBody(UPDATE_ORGANIZATION_BODY)
@ApiResponse(UPDATE_ORGANIZATION_RESPONSES[200])
@@ -107,6 +127,7 @@ export class OrganizationController {
}
@Post('transfer-ownership')
+ @RequirePermission('organization', 'update')
@ApiOperation(ORGANIZATION_OPERATIONS.transferOwnership)
@ApiBody(TRANSFER_OWNERSHIP_BODY)
@ApiResponse(TRANSFER_OWNERSHIP_RESPONSES[200])
@@ -158,6 +179,7 @@ export class OrganizationController {
}
@Delete()
+ @RequirePermission('organization', 'delete')
@ApiOperation(ORGANIZATION_OPERATIONS.deleteOrganization)
@ApiResponse(DELETE_ORGANIZATION_RESPONSES[200])
@ApiResponse(DELETE_ORGANIZATION_RESPONSES[401])
@@ -181,7 +203,91 @@ export class OrganizationController {
};
}
+ @Put('role-notifications')
+ @RequirePermission('organization', 'update')
+ @ApiOperation({ summary: 'Update role notification settings' })
+ @ApiBody({
+ schema: {
+ type: 'object',
+ required: ['settings'],
+ properties: {
+ settings: {
+ type: 'array',
+ items: {
+ type: 'object',
+ required: [
+ 'role',
+ 'policyNotifications',
+ 'taskReminders',
+ 'taskAssignments',
+ 'taskMentions',
+ 'weeklyTaskDigest',
+ 'findingNotifications',
+ ],
+ properties: {
+ role: { type: 'string' },
+ policyNotifications: { type: 'boolean' },
+ taskReminders: { type: 'boolean' },
+ taskAssignments: { type: 'boolean' },
+ taskMentions: { type: 'boolean' },
+ weeklyTaskDigest: { type: 'boolean' },
+ findingNotifications: { type: 'boolean' },
+ },
+ },
+ },
+ },
+ },
+ })
+ async updateRoleNotifications(
+ @OrganizationId() organizationId: string,
+ @Body()
+ body: {
+ settings: Array<{
+ role: string;
+ policyNotifications: boolean;
+ taskReminders: boolean;
+ taskAssignments: boolean;
+ taskMentions: boolean;
+ weeklyTaskDigest: boolean;
+ findingNotifications: boolean;
+ }>;
+ },
+ ) {
+ if (!body?.settings || !Array.isArray(body.settings)) {
+ throw new BadRequestException(
+ 'settings is required and must be an array',
+ );
+ }
+
+ return this.organizationService.updateRoleNotifications(
+ organizationId,
+ body.settings,
+ );
+ }
+
+ @Get('api-keys')
+ @RequirePermission('apiKey', 'read')
+ @ApiOperation({ summary: 'List active API keys' })
+ async listApiKeys(@OrganizationId() organizationId: string) {
+ return this.organizationService.listApiKeys(organizationId);
+ }
+
+ @Get('api-keys/available-scopes')
+ @RequirePermission('apiKey', 'read')
+ @ApiOperation({ summary: 'Get available API key scopes' })
+ async getAvailableScopes() {
+ return { data: this.apiKeyService.getAvailableScopes() };
+ }
+
+ @Get('role-notifications')
+ @RequirePermission('organization', 'read')
+ @ApiOperation({ summary: 'Get role notification settings' })
+ async getRoleNotifications(@OrganizationId() organizationId: string) {
+ return this.organizationService.getRoleNotificationSettings(organizationId);
+ }
+
@Get('primary-color')
+ @UseGuards() // Override class-level guards — public endpoint for trust portal (uses token or auth)
@ApiOperation(ORGANIZATION_OPERATIONS.getPrimaryColor)
@ApiQuery({
name: 'token',
@@ -223,4 +329,57 @@ export class OrganizationController {
}),
};
}
+
+ @Post('logo')
+ @RequirePermission('organization', 'update')
+ @ApiOperation({ summary: 'Upload organization logo' })
+ async uploadLogo(
+ @OrganizationId() organizationId: string,
+ @Body() body: { fileName: string; fileType: string; fileData: string },
+ ) {
+ return this.organizationService.uploadLogo(
+ organizationId,
+ body.fileName,
+ body.fileType,
+ body.fileData,
+ );
+ }
+
+ @Delete('logo')
+ @RequirePermission('organization', 'update')
+ @ApiOperation({ summary: 'Remove organization logo' })
+ async removeLogo(@OrganizationId() organizationId: string) {
+ return this.organizationService.removeLogo(organizationId);
+ }
+
+ @Post('api-keys')
+ @RequirePermission('apiKey', 'create')
+ @ApiOperation({ summary: 'Create a new API key' })
+ async createApiKey(
+ @OrganizationId() organizationId: string,
+ @Body() body: { name: string; expiresAt?: string; scopes?: string[] },
+ ) {
+ if (!body.name) {
+ throw new BadRequestException('Name is required');
+ }
+ return this.apiKeyService.create(
+ organizationId,
+ body.name,
+ body.expiresAt,
+ body.scopes,
+ );
+ }
+
+ @Post('api-keys/revoke')
+ @RequirePermission('apiKey', 'delete')
+ @ApiOperation({ summary: 'Revoke an API key' })
+ async revokeApiKey(
+ @OrganizationId() organizationId: string,
+ @Body() body: { id: string },
+ ) {
+ if (!body.id) {
+ throw new BadRequestException('API key ID is required');
+ }
+ return this.apiKeyService.revoke(body.id, organizationId);
+ }
}
diff --git a/apps/api/src/organization/organization.service.ts b/apps/api/src/organization/organization.service.ts
index b484e7d256..946f48bf8e 100644
--- a/apps/api/src/organization/organization.service.ts
+++ b/apps/api/src/organization/organization.service.ts
@@ -4,8 +4,13 @@ import {
Logger,
BadRequestException,
ForbiddenException,
+ InternalServerErrorException,
} from '@nestjs/common';
+import { allRoles } from '@comp/auth';
+import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
+import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { db, Role } from '@trycompai/db';
+import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '../app/s3';
import type { UpdateOrganizationDto } from './dto/update-organization.dto';
import type { TransferOwnershipResponseDto } from './dto/transfer-ownership.dto';
@@ -29,6 +34,7 @@ export class OrganizationService {
fleetDmLabelId: true,
isFleetSetupCompleted: true,
primaryColor: true,
+ advancedModeEnabled: true,
createdAt: true,
},
});
@@ -48,6 +54,14 @@ export class OrganizationService {
}
}
+ async findOnboarding(organizationId: string) {
+ const onboarding = await db.onboarding.findFirst({
+ where: { organizationId },
+ select: { triggerJobId: true, triggerJobCompleted: true },
+ });
+ return onboarding;
+ }
+
async updateById(id: string, updateData: UpdateOrganizationDto) {
try {
// First check if the organization exists
@@ -65,6 +79,7 @@ export class OrganizationService {
fleetDmLabelId: true,
isFleetSetupCompleted: true,
primaryColor: true,
+ advancedModeEnabled: true,
createdAt: true,
},
});
@@ -89,6 +104,7 @@ export class OrganizationService {
fleetDmLabelId: true,
isFleetSetupCompleted: true,
primaryColor: true,
+ advancedModeEnabled: true,
createdAt: true,
},
});
@@ -274,6 +290,318 @@ export class OrganizationService {
throw error;
}
}
+ async listApiKeys(organizationId: string) {
+ const apiKeys = await db.apiKey.findMany({
+ where: { organizationId, isActive: true },
+ select: {
+ id: true,
+ name: true,
+ createdAt: true,
+ expiresAt: true,
+ lastUsedAt: true,
+ isActive: true,
+ scopes: true,
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return {
+ data: apiKeys.map((key) => ({
+ ...key,
+ createdAt: key.createdAt.toISOString(),
+ expiresAt: key.expiresAt ? key.expiresAt.toISOString() : null,
+ lastUsedAt: key.lastUsedAt ? key.lastUsedAt.toISOString() : null,
+ })),
+ count: apiKeys.length,
+ };
+ }
+
+ async getRoleNotificationSettings(organizationId: string) {
+ const BUILT_IN_ROLES = Object.keys(allRoles);
+
+ const BUILT_IN_DEFAULTS: Record<
+ string,
+ Record
+ > = {
+ owner: {
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: true,
+ findingNotifications: true,
+ },
+ admin: {
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: true,
+ findingNotifications: true,
+ },
+ auditor: {
+ policyNotifications: true,
+ taskReminders: false,
+ taskAssignments: false,
+ taskMentions: false,
+ weeklyTaskDigest: false,
+ findingNotifications: true,
+ },
+ employee: {
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: true,
+ findingNotifications: false,
+ },
+ contractor: {
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: false,
+ findingNotifications: false,
+ },
+ };
+
+ const ALL_ON: Record = {
+ policyNotifications: true,
+ taskReminders: true,
+ taskAssignments: true,
+ taskMentions: true,
+ weeklyTaskDigest: true,
+ findingNotifications: true,
+ };
+
+ const [savedSettings, customRoles] = await Promise.all([
+ db.roleNotificationSetting.findMany({ where: { organizationId } }),
+ db.organizationRole.findMany({
+ where: { organizationId },
+ select: { name: true },
+ }),
+ ]);
+
+ const settingsMap = new Map(savedSettings.map((s) => [s.role, s]));
+ const configs: Array<{
+ role: string;
+ label: string;
+ isCustom: boolean;
+ notifications: Record;
+ }> = [];
+
+ for (const role of BUILT_IN_ROLES) {
+ const saved = settingsMap.get(role);
+ const defaults = BUILT_IN_DEFAULTS[role];
+ configs.push({
+ role,
+ label: role.charAt(0).toUpperCase() + role.slice(1),
+ isCustom: false,
+ notifications: saved
+ ? {
+ policyNotifications: saved.policyNotifications,
+ taskReminders: saved.taskReminders,
+ taskAssignments: saved.taskAssignments,
+ taskMentions: saved.taskMentions,
+ weeklyTaskDigest: saved.weeklyTaskDigest,
+ findingNotifications: saved.findingNotifications,
+ }
+ : defaults,
+ });
+ }
+
+ for (const customRole of customRoles) {
+ const saved = settingsMap.get(customRole.name);
+ configs.push({
+ role: customRole.name,
+ label: customRole.name,
+ isCustom: true,
+ notifications: saved
+ ? {
+ policyNotifications: saved.policyNotifications,
+ taskReminders: saved.taskReminders,
+ taskAssignments: saved.taskAssignments,
+ taskMentions: saved.taskMentions,
+ weeklyTaskDigest: saved.weeklyTaskDigest,
+ findingNotifications: saved.findingNotifications,
+ }
+ : ALL_ON,
+ });
+ }
+
+ return { data: configs };
+ }
+
+ async getLogoSignedUrl(logoKey: string | null | undefined): Promise {
+ if (!logoKey || !s3Client || !APP_AWS_ORG_ASSETS_BUCKET) {
+ return null;
+ }
+
+ try {
+ return await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket: APP_AWS_ORG_ASSETS_BUCKET,
+ Key: logoKey,
+ }),
+ { expiresIn: 3600 },
+ );
+ } catch {
+ return null;
+ }
+ }
+
+ async getOwnershipData(organizationId: string, userId: string) {
+ const currentUserMember = await db.member.findFirst({
+ where: { organizationId, userId, deactivated: false },
+ });
+
+ const currentUserRoles =
+ currentUserMember?.role?.split(',').map((r) => r.trim()) ?? [];
+ const isOwner = currentUserRoles.includes(Role.owner);
+
+ let eligibleMembers: Array<{
+ id: string;
+ user: { name: string | null; email: string };
+ }> = [];
+
+ if (isOwner) {
+ eligibleMembers = await db.member.findMany({
+ where: {
+ organizationId,
+ userId: { not: userId },
+ deactivated: false,
+ },
+ select: {
+ id: true,
+ user: { select: { name: true, email: true } },
+ },
+ orderBy: { user: { email: 'asc' } },
+ });
+ }
+
+ return { isOwner, eligibleMembers };
+ }
+
+ async updateRoleNotifications(
+ organizationId: string,
+ settings: Array<{
+ role: string;
+ policyNotifications: boolean;
+ taskReminders: boolean;
+ taskAssignments: boolean;
+ taskMentions: boolean;
+ weeklyTaskDigest: boolean;
+ findingNotifications: boolean;
+ }>,
+ ) {
+ try {
+ await Promise.all(
+ settings.map((setting) =>
+ db.roleNotificationSetting.upsert({
+ where: {
+ organizationId_role: {
+ organizationId,
+ role: setting.role,
+ },
+ },
+ create: {
+ organizationId,
+ role: setting.role,
+ policyNotifications: setting.policyNotifications,
+ taskReminders: setting.taskReminders,
+ taskAssignments: setting.taskAssignments,
+ taskMentions: setting.taskMentions,
+ weeklyTaskDigest: setting.weeklyTaskDigest,
+ findingNotifications: setting.findingNotifications,
+ },
+ update: {
+ policyNotifications: setting.policyNotifications,
+ taskReminders: setting.taskReminders,
+ taskAssignments: setting.taskAssignments,
+ taskMentions: setting.taskMentions,
+ weeklyTaskDigest: setting.weeklyTaskDigest,
+ findingNotifications: setting.findingNotifications,
+ },
+ }),
+ ),
+ );
+
+ this.logger.log(
+ `Updated role notification settings for organization ${organizationId} (${settings.length} roles)`,
+ );
+
+ return { success: true };
+ } catch (error) {
+ this.logger.error(
+ `Failed to update role notification settings for organization ${organizationId}:`,
+ error,
+ );
+ throw error;
+ }
+ }
+
+ async uploadLogo(
+ organizationId: string,
+ fileName: string,
+ fileType: string,
+ fileData: string,
+ ) {
+ if (!fileType.startsWith('image/')) {
+ throw new BadRequestException('Only image files are allowed');
+ }
+
+ if (!s3Client || !APP_AWS_ORG_ASSETS_BUCKET) {
+ throw new InternalServerErrorException(
+ 'File upload service is not available',
+ );
+ }
+
+ const fileBuffer = Buffer.from(fileData, 'base64');
+ const MAX_LOGO_SIZE = 2 * 1024 * 1024;
+ if (fileBuffer.length > MAX_LOGO_SIZE) {
+ throw new BadRequestException('Logo must be less than 2MB');
+ }
+
+ const timestamp = Date.now();
+ const sanitizedFileName = fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
+ const key = `${organizationId}/logo/${timestamp}-${sanitizedFileName}`;
+
+ await s3Client.send(
+ new PutObjectCommand({
+ Bucket: APP_AWS_ORG_ASSETS_BUCKET,
+ Key: key,
+ Body: fileBuffer,
+ ContentType: fileType,
+ }),
+ );
+
+ await db.organization.update({
+ where: { id: organizationId },
+ data: { logo: key },
+ });
+
+ const signedUrl = await getSignedUrl(
+ s3Client,
+ new GetObjectCommand({
+ Bucket: APP_AWS_ORG_ASSETS_BUCKET,
+ Key: key,
+ }),
+ { expiresIn: 3600 },
+ );
+
+ return { logoUrl: signedUrl };
+ }
+
+ async removeLogo(organizationId: string) {
+ await db.organization.update({
+ where: { id: organizationId },
+ data: { logo: null },
+ });
+
+ return { success: true };
+ }
+
async getPrimaryColor(organizationId: string, token?: string) {
try {
let targetOrgId = organizationId;
diff --git a/apps/api/src/organization/schemas/organization-operations.ts b/apps/api/src/organization/schemas/organization-operations.ts
index 770e31637c..a071b6dec6 100644
--- a/apps/api/src/organization/schemas/organization-operations.ts
+++ b/apps/api/src/organization/schemas/organization-operations.ts
@@ -4,26 +4,26 @@ export const ORGANIZATION_OPERATIONS: Record = {
getOrganization: {
summary: 'Get organization information',
description:
- 'Returns detailed information about the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns detailed information about the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
updateOrganization: {
summary: 'Update organization',
description:
- 'Partially updates the authenticated organization. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Partially updates the authenticated organization. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
deleteOrganization: {
summary: 'Delete organization',
description:
- 'Permanently deletes the authenticated organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Permanently deletes the authenticated organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
transferOwnership: {
summary: 'Transfer organization ownership',
description:
- 'Transfers organization ownership to another member. The current owner will become an admin and keep all other roles. The new owner will receive the owner role while keeping their existing roles. Only the current organization owner can perform this action. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Transfers organization ownership to another member. The current owner will become an admin and keep all other roles. The new owner will receive the owner role while keeping their existing roles. Only the current organization owner can perform this action. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
getPrimaryColor: {
summary: 'Get organization primary color',
description:
- 'Returns the primary color of the organization. Supports three access methods: 1) API key authentication (X-API-Key header), 2) Session authentication (cookies + X-Organization-Id header), or 3) Public access using an access token query parameter (?token=tok_xxx). When using an access token, no authentication is required.',
+ 'Returns the primary color of the organization. Supports three access methods: 1) API key authentication (X-API-Key header), 2) Session authentication (Bearer token or cookies), or 3) Public access using an access token query parameter (?token=tok_xxx). When using an access token, no authentication is required.',
},
};
diff --git a/apps/api/src/people/dto/create-people.dto.ts b/apps/api/src/people/dto/create-people.dto.ts
index 7d540bd850..0c272341d0 100644
--- a/apps/api/src/people/dto/create-people.dto.ts
+++ b/apps/api/src/people/dto/create-people.dto.ts
@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsString,
+ IsNotEmpty,
IsOptional,
IsEnum,
IsBoolean,
@@ -14,13 +15,15 @@ export class CreatePeopleDto {
example: 'usr_abc123def456',
})
@IsString()
+ @IsNotEmpty()
userId: string;
@ApiProperty({
- description: 'Role for the member',
+ description: 'Role for the member (built-in role name or custom role ID)',
example: 'admin',
})
@IsString()
+ @IsNotEmpty()
role: string;
@ApiProperty({
diff --git a/apps/api/src/people/dto/invite-people.dto.ts b/apps/api/src/people/dto/invite-people.dto.ts
new file mode 100644
index 0000000000..d1d4d58086
--- /dev/null
+++ b/apps/api/src/people/dto/invite-people.dto.ts
@@ -0,0 +1,32 @@
+import { ApiProperty } from '@nestjs/swagger';
+import {
+ IsArray,
+ IsEmail,
+ IsNotEmpty,
+ IsString,
+ ValidateNested,
+ ArrayMinSize,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class InviteItemDto {
+ @ApiProperty({ example: 'user@example.com' })
+ @IsEmail()
+ @IsNotEmpty()
+ email: string;
+
+ @ApiProperty({ example: ['employee'], type: [String] })
+ @IsArray()
+ @IsString({ each: true })
+ @ArrayMinSize(1)
+ roles: string[];
+}
+
+export class InvitePeopleDto {
+ @ApiProperty({ type: [InviteItemDto] })
+ @IsArray()
+ @ValidateNested({ each: true })
+ @Type(() => InviteItemDto)
+ @ArrayMinSize(1)
+ invites: InviteItemDto[];
+}
diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts
index 28d7cabdfe..ae361734ff 100644
--- a/apps/api/src/people/dto/people-responses.dto.ts
+++ b/apps/api/src/people/dto/people-responses.dto.ts
@@ -51,6 +51,12 @@ export class UserResponseDto {
nullable: true,
})
lastLogin: Date | null;
+
+ @ApiProperty({
+ description: 'Whether the user is a platform admin (Comp AI team member)',
+ example: false,
+ })
+ isPlatformAdmin: boolean;
}
export class PeopleResponseDto {
diff --git a/apps/api/src/people/dto/update-email-preferences.dto.ts b/apps/api/src/people/dto/update-email-preferences.dto.ts
new file mode 100644
index 0000000000..28516bc6bd
--- /dev/null
+++ b/apps/api/src/people/dto/update-email-preferences.dto.ts
@@ -0,0 +1,37 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { IsBoolean, IsNotEmptyObject, ValidateNested } from 'class-validator';
+import { Type } from 'class-transformer';
+
+export class EmailPreferencesDto {
+ @ApiProperty({ example: true })
+ @IsBoolean()
+ policyNotifications: boolean;
+
+ @ApiProperty({ example: true })
+ @IsBoolean()
+ taskReminders: boolean;
+
+ @ApiProperty({ example: true })
+ @IsBoolean()
+ weeklyTaskDigest: boolean;
+
+ @ApiProperty({ example: true })
+ @IsBoolean()
+ unassignedItemsNotifications: boolean;
+
+ @ApiProperty({ example: true })
+ @IsBoolean()
+ taskMentions: boolean;
+
+ @ApiProperty({ example: true })
+ @IsBoolean()
+ taskAssignments: boolean;
+}
+
+export class UpdateEmailPreferencesDto {
+ @ApiProperty({ type: EmailPreferencesDto })
+ @IsNotEmptyObject()
+ @ValidateNested()
+ @Type(() => EmailPreferencesDto)
+ preferences: EmailPreferencesDto;
+}
diff --git a/apps/api/src/people/dto/update-people.dto.ts b/apps/api/src/people/dto/update-people.dto.ts
index 41ac378831..8c5c53f070 100644
--- a/apps/api/src/people/dto/update-people.dto.ts
+++ b/apps/api/src/people/dto/update-people.dto.ts
@@ -1,5 +1,11 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
-import { IsOptional, IsBoolean } from 'class-validator';
+import {
+ IsOptional,
+ IsBoolean,
+ IsString,
+ IsEmail,
+ IsDateString,
+} from 'class-validator';
import { CreatePeopleDto } from './create-people.dto';
export class UpdatePeopleDto extends PartialType(CreatePeopleDto) {
@@ -11,4 +17,31 @@ export class UpdatePeopleDto extends PartialType(CreatePeopleDto) {
@IsOptional()
@IsBoolean()
isActive?: boolean;
+
+ @ApiProperty({
+ description: 'Name of the associated user',
+ example: 'John Doe',
+ required: false,
+ })
+ @IsOptional()
+ @IsString()
+ name?: string;
+
+ @ApiProperty({
+ description: 'Email of the associated user',
+ example: 'john@example.com',
+ required: false,
+ })
+ @IsOptional()
+ @IsEmail()
+ email?: string;
+
+ @ApiProperty({
+ description: 'Member join date (createdAt override)',
+ example: '2024-01-15T00:00:00.000Z',
+ required: false,
+ })
+ @IsOptional()
+ @IsDateString()
+ createdAt?: string;
}
diff --git a/apps/api/src/people/people-fleet.helper.ts b/apps/api/src/people/people-fleet.helper.ts
new file mode 100644
index 0000000000..33009f13b1
--- /dev/null
+++ b/apps/api/src/people/people-fleet.helper.ts
@@ -0,0 +1,181 @@
+import { Logger } from '@nestjs/common';
+import { db } from '@trycompai/db';
+import { FleetService } from '../lib/fleet.service';
+
+const MDM_POLICY_ID = -9999;
+const logger = new Logger('PeopleFleetHelper');
+
+export interface FleetPolicyResult {
+ id: number;
+ name: string;
+ response: string;
+ attachments: unknown[];
+ query?: string;
+ critical?: boolean;
+ description?: string;
+}
+
+function buildPoliciesWithResults(
+ host: Record,
+ results: { fleetPolicyId: number; fleetPolicyResponse: string | null; attachments: unknown }[],
+) {
+ const platform = (host.platform as string)?.toLowerCase();
+ const osVersion = (host.os_version as string)?.toLowerCase();
+ const isMacOS =
+ platform === 'darwin' ||
+ platform === 'macos' ||
+ platform === 'osx' ||
+ osVersion?.includes('mac');
+
+ const hostPolicies = (host.policies || []) as { id: number; name: string; response: string }[];
+ const mdm = host.mdm as { connected_to_fleet?: boolean } | undefined;
+
+ const allPolicies = [
+ ...hostPolicies,
+ ...(isMacOS && mdm
+ ? [{ id: MDM_POLICY_ID, name: 'MDM Enabled', response: mdm.connected_to_fleet ? 'pass' : 'fail' }]
+ : []),
+ ];
+
+ return allPolicies.map((policy) => {
+ const policyResult = results.find((r) => r.fleetPolicyId === policy.id);
+ return {
+ ...policy,
+ response:
+ policy.response === 'pass' || policyResult?.fleetPolicyResponse === 'pass'
+ ? 'pass'
+ : 'fail',
+ attachments: policyResult?.attachments || [],
+ };
+ }) as FleetPolicyResult[];
+}
+
+export async function getFleetComplianceForMember(
+ fleetService: FleetService,
+ memberId: string,
+ organizationId: string,
+ memberFleetLabelId: number | null,
+ memberUserId: string,
+) {
+ if (!memberFleetLabelId) {
+ return { fleetPolicies: [], device: null };
+ }
+
+ try {
+ const labelHostsData = await fleetService.getHostsByLabel(memberFleetLabelId);
+ const firstHost = labelHostsData?.hosts?.[0];
+
+ if (!firstHost) {
+ return { fleetPolicies: [], device: null };
+ }
+
+ const hostData = await fleetService.getHostById(firstHost.id);
+ const host = hostData?.host;
+
+ if (!host) {
+ return { fleetPolicies: [], device: null };
+ }
+
+ const results = await db.fleetPolicyResult.findMany({
+ where: { organizationId, userId: memberUserId },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return {
+ fleetPolicies: buildPoliciesWithResults(host, results),
+ device: host,
+ };
+ } catch (error) {
+ logger.error(
+ `Failed to get fleet compliance for member ${memberId}:`,
+ error,
+ );
+ return { fleetPolicies: [], device: null };
+ }
+}
+
+export async function getAllEmployeeDevices(
+ fleetService: FleetService,
+ organizationId: string,
+) {
+ try {
+ const employees = await db.member.findMany({
+ where: {
+ organizationId,
+ deactivated: false,
+ OR: [
+ { user: { isPlatformAdmin: false } },
+ { role: { contains: 'owner' } },
+ ],
+ },
+ include: { user: true },
+ });
+
+ const membersWithLabels = employees.filter((e) => e.fleetDmLabelId);
+ if (membersWithLabels.length === 0) return [];
+
+ const labelResponses = await Promise.all(
+ membersWithLabels.map(async (employee) => {
+ try {
+ const data = await fleetService.getHostsByLabel(employee.fleetDmLabelId!);
+ return {
+ userId: employee.userId,
+ userName: employee.user?.name,
+ memberId: employee.id,
+ hosts: data?.hosts || [],
+ };
+ } catch {
+ return { userId: employee.userId, userName: employee.user?.name, memberId: employee.id, hosts: [] };
+ }
+ }),
+ );
+
+ const hostRequests = labelResponses.flatMap((entry) =>
+ (entry.hosts as { id: number }[]).map((host) => ({
+ userId: entry.userId,
+ memberId: entry.memberId,
+ userName: entry.userName,
+ hostId: host.id,
+ })),
+ );
+
+ if (hostRequests.length === 0) return [];
+
+ const devices = await Promise.all(
+ hostRequests.map(async ({ hostId }) => {
+ try {
+ return await fleetService.getHostById(hostId);
+ } catch {
+ return null;
+ }
+ }),
+ );
+
+ const results = await db.fleetPolicyResult.findMany({
+ where: { organizationId },
+ orderBy: { createdAt: 'desc' },
+ });
+
+ return devices
+ .map((device, index) => {
+ if (!device?.host) return null;
+ const host = device.host;
+ const req = hostRequests[index];
+ const memberResults = results.filter((r) => r.userId === req.userId);
+
+ return {
+ ...host,
+ user_name: req.userName,
+ member_id: req.memberId,
+ policies: buildPoliciesWithResults(host, memberResults),
+ };
+ })
+ .filter(Boolean);
+ } catch (error) {
+ logger.error(
+ `Failed to get employee devices for org ${organizationId}:`,
+ error,
+ );
+ return [];
+ }
+}
diff --git a/apps/api/src/people/people-invite.service.spec.ts b/apps/api/src/people/people-invite.service.spec.ts
new file mode 100644
index 0000000000..276ff511e3
--- /dev/null
+++ b/apps/api/src/people/people-invite.service.spec.ts
@@ -0,0 +1,291 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { BadRequestException, ForbiddenException } from '@nestjs/common';
+import { PeopleInviteService } from './people-invite.service';
+
+jest.mock('@trycompai/db', () => ({
+ db: {
+ organization: {
+ findUnique: jest.fn(),
+ },
+ user: {
+ findFirst: jest.fn(),
+ create: jest.fn(),
+ },
+ member: {
+ findFirst: jest.fn(),
+ create: jest.fn(),
+ update: jest.fn(),
+ },
+ invitation: {
+ create: jest.fn(),
+ },
+ employeeTrainingVideoCompletion: {
+ createMany: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('../email/trigger-email', () => ({
+ triggerEmail: jest.fn().mockResolvedValue({ id: 'trigger_123' }),
+}));
+
+jest.mock('../email/templates/invite-member', () => ({
+ InviteEmail: jest.fn().mockReturnValue('mocked-react-element'),
+}));
+
+import { db } from '@trycompai/db';
+import { triggerEmail } from '../email/trigger-email';
+
+const mockDb = db as jest.Mocked;
+const mockTriggerEmail = triggerEmail as jest.Mock;
+
+describe('PeopleInviteService', () => {
+ let service: PeopleInviteService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [PeopleInviteService],
+ }).compile();
+
+ service = module.get(PeopleInviteService);
+ jest.clearAllMocks();
+ });
+
+ it('should be defined', () => {
+ expect(service).toBeDefined();
+ });
+
+ describe('inviteMembers', () => {
+ const baseParams = {
+ organizationId: 'org_123',
+ callerUserId: 'user_caller',
+ callerRole: 'admin,owner',
+ };
+
+ it('should throw ForbiddenException for unauthorized roles', async () => {
+ await expect(
+ service.inviteMembers({
+ ...baseParams,
+ callerRole: 'employee',
+ invites: [{ email: 'test@example.com', roles: ['employee'] }],
+ }),
+ ).rejects.toThrow(ForbiddenException);
+ });
+
+ it('should restrict auditors to only invite auditors', async () => {
+ const results = await service.inviteMembers({
+ ...baseParams,
+ callerRole: 'auditor',
+ invites: [{ email: 'test@example.com', roles: ['admin'] }],
+ });
+
+ expect(results[0].success).toBe(false);
+ expect(results[0].error).toContain('Auditors can only invite');
+ });
+
+ it('should allow auditors to invite other auditors', async () => {
+ // inviteWithCheck path: user doesn't exist → create invitation
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (mockDb.invitation.create as jest.Mock).mockResolvedValue({
+ id: 'inv_auditor',
+ });
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ callerRole: 'auditor',
+ invites: [{ email: 'auditor@example.com', roles: ['auditor'] }],
+ });
+
+ expect(results[0].success).toBe(true);
+ expect(mockDb.invitation.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ email: 'auditor@example.com',
+ role: 'auditor',
+ }),
+ }),
+ );
+ });
+
+ it('should add employee without invitation for employee/contractor roles', async () => {
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.user.create as jest.Mock).mockResolvedValue({
+ id: 'user_new',
+ email: 'emp@example.com',
+ });
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.member.create as jest.Mock).mockResolvedValue({
+ id: 'member_new',
+ });
+ (mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock).mockResolvedValue({
+ count: 5,
+ });
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ invites: [{ email: 'emp@example.com', roles: ['employee'] }],
+ });
+
+ expect(results[0].success).toBe(true);
+ expect(results[0].emailSent).toBe(true);
+ expect(mockDb.member.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ role: 'employee',
+ organizationId: 'org_123',
+ }),
+ }),
+ );
+ });
+
+ it('should reactivate deactivated members', async () => {
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue({
+ id: 'user_existing',
+ email: 'emp@example.com',
+ });
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue({
+ id: 'member_existing',
+ deactivated: true,
+ });
+ (mockDb.member.update as jest.Mock).mockResolvedValue({
+ id: 'member_existing',
+ });
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ invites: [{ email: 'emp@example.com', roles: ['employee'] }],
+ });
+
+ expect(results[0].success).toBe(true);
+ expect(mockDb.member.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ deactivated: false,
+ role: 'employee',
+ }),
+ }),
+ );
+ });
+
+ it('should handle multiple invites', async () => {
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.user.create as jest.Mock).mockResolvedValue({
+ id: 'user_new',
+ email: 'test@example.com',
+ });
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.member.create as jest.Mock).mockResolvedValue({
+ id: 'member_new',
+ });
+ (mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock).mockResolvedValue({
+ count: 5,
+ });
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ invites: [
+ { email: 'emp1@example.com', roles: ['employee'] },
+ { email: 'emp2@example.com', roles: ['contractor'] },
+ ],
+ });
+
+ expect(results).toHaveLength(2);
+ expect(results.every((r) => r.success)).toBe(true);
+ });
+
+ it('should continue processing when one invite fails', async () => {
+ (mockDb.organization.findUnique as jest.Mock)
+ .mockResolvedValueOnce(null) // First org lookup fails
+ .mockResolvedValueOnce({ name: 'Test Org' }); // Second succeeds
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.user.create as jest.Mock).mockResolvedValue({
+ id: 'user_new',
+ email: 'test@example.com',
+ });
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.member.create as jest.Mock).mockResolvedValue({
+ id: 'member_new',
+ });
+ (mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock).mockResolvedValue({
+ count: 5,
+ });
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ invites: [
+ { email: 'fail@example.com', roles: ['employee'] },
+ { email: 'success@example.com', roles: ['employee'] },
+ ],
+ });
+
+ expect(results).toHaveLength(2);
+ expect(results[0].success).toBe(false);
+ expect(results[1].success).toBe(true);
+ });
+
+ it('should handle email send failure gracefully', async () => {
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.user.create as jest.Mock).mockResolvedValue({
+ id: 'user_new',
+ email: 'emp@example.com',
+ });
+ (mockDb.member.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.member.create as jest.Mock).mockResolvedValue({
+ id: 'member_new',
+ });
+ (mockDb.employeeTrainingVideoCompletion.createMany as jest.Mock).mockResolvedValue({
+ count: 5,
+ });
+ mockTriggerEmail.mockRejectedValueOnce(new Error('Email service down'));
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ invites: [{ email: 'emp@example.com', roles: ['employee'] }],
+ });
+
+ expect(results[0].success).toBe(true);
+ expect(results[0].emailSent).toBe(false);
+ });
+
+ it('should create invitation for admin role invites', async () => {
+ (mockDb.user.findFirst as jest.Mock).mockResolvedValue(null);
+ (mockDb.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (mockDb.invitation.create as jest.Mock).mockResolvedValue({
+ id: 'inv_new',
+ });
+
+ const results = await service.inviteMembers({
+ ...baseParams,
+ invites: [{ email: 'admin@example.com', roles: ['admin'] }],
+ });
+
+ expect(results[0].success).toBe(true);
+ expect(mockDb.invitation.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ email: 'admin@example.com',
+ role: 'admin',
+ status: 'pending',
+ }),
+ }),
+ );
+ });
+ });
+});
diff --git a/apps/api/src/people/people-invite.service.ts b/apps/api/src/people/people-invite.service.ts
new file mode 100644
index 0000000000..0bfd014645
--- /dev/null
+++ b/apps/api/src/people/people-invite.service.ts
@@ -0,0 +1,309 @@
+import {
+ Injectable,
+ Logger,
+ BadRequestException,
+ ForbiddenException,
+} from '@nestjs/common';
+import { db } from '@trycompai/db';
+import { triggerEmail } from '../email/trigger-email';
+import { InviteEmail } from '../email/templates/invite-member';
+import type { InviteItemDto } from './dto/invite-people.dto';
+
+export interface InviteResult {
+ email: string;
+ success: boolean;
+ error?: string;
+ emailSent?: boolean;
+}
+
+@Injectable()
+export class PeopleInviteService {
+ private readonly logger = new Logger(PeopleInviteService.name);
+
+ async inviteMembers(params: {
+ organizationId: string;
+ invites: InviteItemDto[];
+ callerUserId: string;
+ callerRole: string;
+ }): Promise {
+ const { organizationId, invites, callerUserId, callerRole } = params;
+
+ const isAdmin =
+ callerRole.includes('admin') || callerRole.includes('owner');
+ const isAuditor = callerRole.includes('auditor');
+
+ if (!isAdmin && !isAuditor) {
+ throw new ForbiddenException(
+ "You don't have permission to invite members.",
+ );
+ }
+
+ const results: InviteResult[] = [];
+
+ for (const invite of invites) {
+ try {
+ // Auditors can only invite auditors
+ if (isAuditor && !isAdmin) {
+ const onlyAuditor =
+ invite.roles.length === 1 && invite.roles[0] === 'auditor';
+ if (!onlyAuditor) {
+ results.push({
+ email: invite.email,
+ success: false,
+ error: "Auditors can only invite users with the 'auditor' role.",
+ });
+ continue;
+ }
+ }
+
+ const email = invite.email.toLowerCase();
+ const hasEmployeeRoleAndNoAdmin =
+ !invite.roles.includes('admin') &&
+ (invite.roles.includes('employee') ||
+ invite.roles.includes('contractor'));
+
+ if (hasEmployeeRoleAndNoAdmin) {
+ const result = await this.addEmployeeWithoutInvite(
+ email,
+ invite.roles,
+ organizationId,
+ );
+ results.push({
+ email: invite.email,
+ success: true,
+ emailSent: result.emailSent,
+ });
+ } else {
+ await this.inviteWithCheck(
+ email,
+ invite.roles,
+ organizationId,
+ callerUserId,
+ );
+ results.push({ email: invite.email, success: true });
+ }
+ } catch (error) {
+ this.logger.error(
+ `Failed to invite ${invite.email}:`,
+ error instanceof Error ? error.message : 'Unknown error',
+ );
+ results.push({
+ email: invite.email,
+ success: false,
+ error: error instanceof Error ? error.message : 'Unknown error',
+ });
+ }
+ }
+
+ return results;
+ }
+
+ private async addEmployeeWithoutInvite(
+ email: string,
+ roles: string[],
+ organizationId: string,
+ ): Promise<{ emailSent: boolean }> {
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ });
+
+ if (!organization) {
+ throw new BadRequestException('Organization not found.');
+ }
+
+ let userId = '';
+ const existingUser = await db.user.findFirst({
+ where: { email: { equals: email, mode: 'insensitive' } },
+ });
+
+ if (!existingUser) {
+ const newUser = await db.user.create({
+ data: { emailVerified: false, email, name: email.split('@')[0] },
+ });
+ userId = newUser.id;
+ }
+
+ const finalUserId = existingUser?.id ?? userId;
+
+ const existingMember = await db.member.findFirst({
+ where: { userId: finalUserId, organizationId },
+ });
+
+ let member: { id: string } | null = null;
+ let isNewMember = false;
+
+ if (existingMember) {
+ if (existingMember.deactivated) {
+ const roleString = [...roles].sort().join(',');
+ member = await db.member.update({
+ where: { id: existingMember.id },
+ data: { deactivated: false, role: roleString },
+ });
+ } else {
+ member = existingMember;
+ }
+ } else {
+ member = await db.member.create({
+ data: {
+ userId: finalUserId,
+ organizationId,
+ role: roles.join(','),
+ isActive: true,
+ },
+ });
+ isNewMember = true;
+ }
+
+ // Create training video entries for new members
+ if (member && isNewMember) {
+ await this.createTrainingVideoEntries(member.id);
+ }
+
+ // Send invite email (non-fatal)
+ let emailSent = true;
+ try {
+ const inviteLink = this.buildPortalUrl(organizationId);
+ await triggerEmail({
+ to: email,
+ subject: `You've been invited to join ${organization.name} on Comp AI`,
+ react: InviteEmail({ organizationName: organization.name, inviteLink }),
+ });
+ } catch (emailErr) {
+ emailSent = false;
+ this.logger.error(
+ `Invite email failed after member was added: ${email}`,
+ emailErr instanceof Error ? emailErr.message : 'Unknown error',
+ );
+ }
+
+ return { emailSent };
+ }
+
+ private async inviteWithCheck(
+ email: string,
+ roles: string[],
+ organizationId: string,
+ currentUserId: string,
+ ): Promise {
+ const existingUser = await db.user.findFirst({
+ where: { email: { equals: email, mode: 'insensitive' } },
+ });
+
+ if (existingUser) {
+ const existingMember = await db.member.findFirst({
+ where: { userId: existingUser.id, organizationId },
+ });
+
+ if (existingMember) {
+ if (existingMember.deactivated) {
+ const roleString = [...roles].sort().join(',');
+ await db.member.update({
+ where: { id: existingMember.id },
+ data: { deactivated: false, isActive: true, role: roleString },
+ });
+ return;
+ }
+
+ // Active member — send invitation email
+ await this.sendInvitationEmailToExistingMember(
+ email,
+ roles,
+ organizationId,
+ currentUserId,
+ );
+ return;
+ }
+ }
+
+ // User doesn't exist or isn't a member — create invitation and send email
+ const roleString = roles.join(',');
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ });
+
+ if (!organization) {
+ throw new BadRequestException('Organization not found.');
+ }
+
+ const invitation = await db.invitation.create({
+ data: {
+ email,
+ organizationId,
+ role: roleString,
+ status: 'pending',
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ inviterId: currentUserId,
+ },
+ });
+
+ const inviteLink = this.buildInviteLink(invitation.id);
+ await triggerEmail({
+ to: email,
+ subject: `You've been invited to join ${organization.name} on Comp AI`,
+ react: InviteEmail({ organizationName: organization.name, inviteLink }),
+ });
+ }
+
+ private async sendInvitationEmailToExistingMember(
+ email: string,
+ roles: string[],
+ organizationId: string,
+ inviterId: string,
+ ): Promise {
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ });
+
+ if (!organization) {
+ throw new BadRequestException('Organization not found.');
+ }
+
+ const invitation = await db.invitation.create({
+ data: {
+ email: email.toLowerCase(),
+ organizationId,
+ role: roles.length === 1 ? roles[0] : roles.join(','),
+ status: 'pending',
+ expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ inviterId,
+ },
+ });
+
+ const inviteLink = this.buildInviteLink(invitation.id);
+ await triggerEmail({
+ to: email.toLowerCase(),
+ subject: `You've been invited to join ${organization.name} on Comp AI`,
+ react: InviteEmail({ organizationName: organization.name, inviteLink }),
+ });
+ }
+
+ private async createTrainingVideoEntries(memberId: string): Promise {
+ // Training videos are defined in the app; we create entries for known video IDs
+ const trainingVideoIds = ['sat-1', 'sat-2', 'sat-3', 'sat-4', 'sat-5'];
+
+ await db.employeeTrainingVideoCompletion.createMany({
+ data: trainingVideoIds.map((videoId) => ({
+ memberId,
+ videoId,
+ })),
+ skipDuplicates: true,
+ });
+ }
+
+ private buildPortalUrl(organizationId: string): string {
+ const portalUrl =
+ process.env.NEXT_PUBLIC_PORTAL_URL ?? 'https://portal.trycomp.ai';
+ return `${portalUrl}/${organizationId}`;
+ }
+
+ private buildInviteLink(invitationId: string): string {
+ const appUrl =
+ process.env.NEXT_PUBLIC_APP_URL ??
+ process.env.BETTER_AUTH_URL ??
+ 'https://app.trycomp.ai';
+ return `${appUrl}/invite/${invitationId}`;
+ }
+}
diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts
new file mode 100644
index 0000000000..53f24eabd5
--- /dev/null
+++ b/apps/api/src/people/people.controller.spec.ts
@@ -0,0 +1,324 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { PeopleService } from './people.service';
+import { PeopleInviteService } from './people-invite.service';
+import type { AuthContext } from '../auth/types';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { PeopleController } from './people.controller';
+import { BadRequestException } from '@nestjs/common';
+
+// Mock auth.server to avoid importing better-auth ESM in Jest
+jest.mock('../auth/auth.server', () => ({
+ auth: {
+ api: {
+ getSession: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ organization: ['read', 'update', 'delete'],
+ member: ['create', 'read', 'update', 'delete'],
+ risk: ['create', 'read', 'update', 'delete'],
+ control: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+describe('PeopleController', () => {
+ let controller: PeopleController;
+ let peopleService: jest.Mocked;
+
+ const mockPeopleService = {
+ findAllByOrganization: jest.fn(),
+ findById: jest.fn(),
+ create: jest.fn(),
+ bulkCreate: jest.fn(),
+ updateById: jest.fn(),
+ deleteById: jest.fn(),
+ unlinkDevice: jest.fn(),
+ removeHostById: jest.fn(),
+ updateEmailPreferences: jest.fn(),
+ findMentionableMembers: jest.fn(),
+ };
+
+ const mockPeopleInviteService = {
+ inviteMembers: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'usr_123',
+ userEmail: 'test@example.com',
+ userRoles: ['owner'],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [PeopleController],
+ providers: [
+ { provide: PeopleService, useValue: mockPeopleService },
+ { provide: PeopleInviteService, useValue: mockPeopleInviteService },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(PeopleController);
+ peopleService = module.get(PeopleService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getAllPeople', () => {
+ it('should return people with auth context', async () => {
+ const mockPeople = [
+ { id: 'mem_1', user: { name: 'Alice' } },
+ { id: 'mem_2', user: { name: 'Bob' } },
+ ];
+
+ mockPeopleService.findAllByOrganization.mockResolvedValue(mockPeople);
+
+ const result = await controller.getAllPeople('org_123', mockAuthContext);
+
+ expect(result.data).toEqual(mockPeople);
+ expect(result.count).toBe(2);
+ expect(result.authType).toBe('session');
+ expect(result.authenticatedUser).toEqual({
+ id: 'usr_123',
+ email: 'test@example.com',
+ });
+ expect(peopleService.findAllByOrganization).toHaveBeenCalledWith(
+ 'org_123',
+ false,
+ );
+ });
+
+ it('should not include authenticatedUser when userId is missing', async () => {
+ const apiKeyContext: AuthContext = {
+ ...mockAuthContext,
+ userId: undefined,
+ userEmail: undefined,
+ authType: 'api-key',
+ isApiKey: true,
+ };
+ mockPeopleService.findAllByOrganization.mockResolvedValue([]);
+
+ const result = await controller.getAllPeople('org_123', apiKeyContext);
+
+ expect(result.authenticatedUser).toBeUndefined();
+ expect(result.authType).toBe('api-key');
+ });
+ });
+
+ describe('createMember', () => {
+ it('should create a member and return with auth context', async () => {
+ const dto = { userId: 'usr_new', role: 'employee' };
+ const createdMember = {
+ id: 'mem_new',
+ user: { name: 'NewUser' },
+ role: 'employee',
+ };
+ mockPeopleService.create.mockResolvedValue(createdMember);
+
+ const result = await controller.createMember(
+ dto as any,
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result).toMatchObject(createdMember);
+ expect(result.authType).toBe('session');
+ expect(peopleService.create).toHaveBeenCalledWith('org_123', dto);
+ });
+ });
+
+ describe('bulkCreateMembers', () => {
+ it('should bulk create and return summary', async () => {
+ const dto = {
+ members: [
+ { userId: 'usr_1', role: 'employee' },
+ { userId: 'usr_2', role: 'contractor' },
+ ],
+ };
+ const bulkResult = {
+ created: [{ id: 'mem_1' }],
+ errors: [{ index: 1, userId: 'usr_2', error: 'Duplicate' }],
+ summary: { total: 2, successful: 1, failed: 1 },
+ };
+ mockPeopleService.bulkCreate.mockResolvedValue(bulkResult);
+
+ const result = await controller.bulkCreateMembers(
+ dto as any,
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result.summary).toEqual(bulkResult.summary);
+ expect(peopleService.bulkCreate).toHaveBeenCalledWith('org_123', dto);
+ });
+ });
+
+ describe('getPersonById', () => {
+ it('should return a single person with auth context', async () => {
+ const person = {
+ id: 'mem_1',
+ user: { name: 'Alice', email: 'alice@test.com' },
+ };
+ mockPeopleService.findById.mockResolvedValue(person);
+
+ const result = await controller.getPersonById(
+ 'mem_1',
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result).toMatchObject(person);
+ expect(result.authType).toBe('session');
+ expect(peopleService.findById).toHaveBeenCalledWith('mem_1', 'org_123');
+ });
+ });
+
+ describe('updateMember', () => {
+ it('should update a member', async () => {
+ const dto = { role: 'admin' };
+ const updated = { id: 'mem_1', user: { name: 'Alice' }, role: 'admin' };
+ mockPeopleService.updateById.mockResolvedValue(updated);
+
+ const result = await controller.updateMember(
+ 'mem_1',
+ dto as any,
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result).toMatchObject(updated);
+ expect(peopleService.updateById).toHaveBeenCalledWith(
+ 'mem_1',
+ 'org_123',
+ dto,
+ );
+ });
+ });
+
+ describe('deleteMember', () => {
+ it('should delete a member and pass actor userId', async () => {
+ const deleteResult = {
+ success: true,
+ deletedMember: { id: 'mem_1', name: 'Alice', email: 'alice@test.com' },
+ };
+ mockPeopleService.deleteById.mockResolvedValue(deleteResult);
+
+ const result = await controller.deleteMember(
+ 'mem_1',
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result.success).toBe(true);
+ expect(peopleService.deleteById).toHaveBeenCalledWith(
+ 'mem_1',
+ 'org_123',
+ 'usr_123',
+ );
+ });
+ });
+
+ describe('unlinkDevice', () => {
+ it('should unlink device for a member', async () => {
+ const updated = {
+ id: 'mem_1',
+ user: { name: 'Alice' },
+ fleetDmLabelId: null,
+ };
+ mockPeopleService.unlinkDevice.mockResolvedValue(updated);
+
+ const result = await controller.unlinkDevice(
+ 'mem_1',
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result).toMatchObject(updated);
+ expect(peopleService.unlinkDevice).toHaveBeenCalledWith(
+ 'mem_1',
+ 'org_123',
+ );
+ });
+ });
+
+ describe('removeHost', () => {
+ it('should remove a host by ID', async () => {
+ mockPeopleService.removeHostById.mockResolvedValue({ success: true });
+
+ const result = await controller.removeHost(
+ 'mem_1',
+ 42,
+ 'org_123',
+ mockAuthContext,
+ );
+
+ expect(result.success).toBe(true);
+ expect(peopleService.removeHostById).toHaveBeenCalledWith(
+ 'mem_1',
+ 'org_123',
+ 42,
+ );
+ });
+ });
+
+ describe('updateEmailPreferences', () => {
+ it('should update email preferences for the current user', async () => {
+ const prefs = {
+ policyNotifications: true,
+ taskReminders: false,
+ weeklyTaskDigest: true,
+ unassignedItemsNotifications: false,
+ taskMentions: true,
+ taskAssignments: true,
+ };
+ mockPeopleService.updateEmailPreferences.mockResolvedValue({
+ success: true,
+ });
+
+ const result = await controller.updateEmailPreferences(mockAuthContext, {
+ preferences: prefs,
+ });
+
+ expect(result).toEqual({ success: true });
+ expect(peopleService.updateEmailPreferences).toHaveBeenCalledWith(
+ 'usr_123',
+ prefs,
+ );
+ });
+
+ it('should throw BadRequestException when userId is missing', async () => {
+ const noUserContext: AuthContext = {
+ ...mockAuthContext,
+ userId: undefined,
+ };
+
+ await expect(
+ controller.updateEmailPreferences(noUserContext, {
+ preferences: {
+ policyNotifications: true,
+ taskReminders: true,
+ weeklyTaskDigest: true,
+ unassignedItemsNotifications: true,
+ taskMentions: true,
+ taskAssignments: true,
+ },
+ }),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+});
diff --git a/apps/api/src/people/people.controller.ts b/apps/api/src/people/people.controller.ts
index 8be17cb615..57188685c3 100644
--- a/apps/api/src/people/people.controller.ts
+++ b/apps/api/src/people/people.controller.ts
@@ -2,19 +2,21 @@ import {
Controller,
Get,
Post,
+ Put,
Patch,
Delete,
Body,
Param,
+ Query,
ParseIntPipe,
UseGuards,
HttpCode,
HttpStatus,
+ BadRequestException,
} from '@nestjs/common';
import {
ApiBody,
ApiExtraModels,
- ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
@@ -22,14 +24,20 @@ import {
ApiTags,
} from '@nestjs/swagger';
import { AuthContext, OrganizationId } from '../auth/auth-context.decorator';
+import { AuditRead } from '../audit/skip-audit-log.decorator';
import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
-import { RequireRoles } from '../auth/role-validator.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RequirePermission } from '../auth/require-permission.decorator';
import type { AuthContext as AuthContextType } from '../auth/types';
+import { statement } from '@comp/auth';
import { CreatePeopleDto } from './dto/create-people.dto';
import { UpdatePeopleDto } from './dto/update-people.dto';
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 { PeopleService } from './people.service';
+import { PeopleInviteService } from './people-invite.service';
import { GET_ALL_PEOPLE_RESPONSES } from './schemas/get-all-people.responses';
import { CREATE_MEMBER_RESPONSES } from './schemas/create-member.responses';
import { BULK_CREATE_MEMBERS_RESPONSES } from './schemas/bulk-create-members.responses';
@@ -44,18 +52,44 @@ import { PEOPLE_BODIES } from './schemas/people-bodies';
@ApiTags('People')
@ApiExtraModels(PeopleResponseDto, UserResponseDto)
@Controller({ path: 'people', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class PeopleController {
- constructor(private readonly peopleService: PeopleService) {}
+ constructor(
+ private readonly peopleService: PeopleService,
+ private readonly peopleInviteService: PeopleInviteService,
+ ) {}
+
+ @Post('invite')
+ @RequirePermission('member', 'create')
+ @ApiOperation({ summary: 'Invite members to the organization' })
+ async inviteMembers(
+ @Body() inviteData: InvitePeopleDto,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const results = await this.peopleInviteService.inviteMembers({
+ organizationId,
+ invites: inviteData.invites,
+ callerUserId: authContext.userId!,
+ callerRole: authContext.userRoles?.join(',') ?? '',
+ });
+
+ return {
+ results,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
@Get()
+ @RequirePermission('member', 'read')
@ApiOperation(PEOPLE_OPERATIONS.getAllPeople)
@ApiResponse(GET_ALL_PEOPLE_RESPONSES[200])
@ApiResponse(GET_ALL_PEOPLE_RESPONSES[401])
@@ -64,9 +98,12 @@ export class PeopleController {
async getAllPeople(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
+ @Query('includeDeactivated') includeDeactivated?: string,
) {
- const people =
- await this.peopleService.findAllByOrganization(organizationId);
+ const people = await this.peopleService.findAllByOrganization(
+ organizationId,
+ includeDeactivated === 'true',
+ );
return {
data: people,
@@ -82,7 +119,52 @@ export class PeopleController {
};
}
+ @Get('devices')
+ @RequirePermission('member', 'read')
+ @ApiOperation({ summary: 'Get all employee devices with fleet compliance data' })
+ async getDevices(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const devices = await this.peopleService.getDevices(organizationId);
+
+ return {
+ data: devices,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Get('test-stats/by-assignee')
+ @RequirePermission('member', 'read')
+ @ApiOperation({ summary: 'Get integration test statistics grouped by assignee' })
+ async getTestStatsByAssignee(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.peopleService.getTestStatsByAssignee(organizationId);
+
+ return {
+ data,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
@Post()
+ @RequirePermission('member', 'create')
@ApiOperation(PEOPLE_OPERATIONS.createMember)
@ApiBody(PEOPLE_BODIES.createMember)
@ApiResponse(CREATE_MEMBER_RESPONSES[201])
@@ -111,6 +193,7 @@ export class PeopleController {
}
@Post('bulk')
+ @RequirePermission('member', 'create')
@ApiOperation(PEOPLE_OPERATIONS.bulkCreateMembers)
@ApiBody(PEOPLE_BODIES.bulkCreateMembers)
@ApiResponse(BULK_CREATE_MEMBERS_RESPONSES[201])
@@ -141,7 +224,74 @@ export class PeopleController {
};
}
+ @Get('mentionable')
+ @RequirePermission('member', 'read')
+ @ApiOperation({ summary: 'Get members who can read a specific resource type' })
+ async getMentionableMembers(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ @Query('resource') resource: string,
+ ) {
+ if (!resource) {
+ throw new BadRequestException('Query parameter "resource" is required');
+ }
+
+ const validResources = Object.keys(statement);
+ if (!validResources.includes(resource)) {
+ throw new BadRequestException(
+ `Invalid resource: "${resource}". Valid resources: ${validResources.join(', ')}`,
+ );
+ }
+
+ const members = await this.peopleService.findMentionableMembers(
+ organizationId,
+ resource,
+ );
+
+ return {
+ data: members,
+ count: members.length,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Patch(':id/reactivate')
+ @RequirePermission('member', 'update')
+ @ApiOperation({ summary: 'Reactivate a deactivated member' })
+ @ApiParam(PEOPLE_PARAMS.memberId)
+ async reactivateMember(
+ @Param('id') memberId: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const member = await this.peopleService.reactivateById(
+ memberId,
+ organizationId,
+ );
+
+ return {
+ ...member,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
@Get(':id')
+ @AuditRead()
+ @RequirePermission('member', 'read')
@ApiOperation(PEOPLE_OPERATIONS.getPersonById)
@ApiParam(PEOPLE_PARAMS.memberId)
@ApiResponse(GET_PERSON_BY_ID_RESPONSES[200])
@@ -168,7 +318,62 @@ export class PeopleController {
};
}
+ @Get(':id/training-videos')
+ @RequirePermission('member', 'read')
+ @ApiOperation({ summary: 'Get training video completions for a member' })
+ @ApiParam(PEOPLE_PARAMS.memberId)
+ async getTrainingVideos(
+ @Param('id') memberId: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.peopleService.getTrainingVideos(
+ memberId,
+ organizationId,
+ );
+
+ return {
+ data,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Get(':id/fleet-compliance')
+ @RequirePermission('member', 'read')
+ @ApiOperation({ summary: 'Get fleet/device compliance for a member' })
+ @ApiParam(PEOPLE_PARAMS.memberId)
+ async getFleetCompliance(
+ @Param('id') memberId: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.peopleService.getFleetCompliance(
+ memberId,
+ organizationId,
+ );
+
+ return {
+ ...data,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
@Patch(':id')
+ @RequirePermission('member', 'update')
@ApiOperation(PEOPLE_OPERATIONS.updateMember)
@ApiParam(PEOPLE_PARAMS.memberId)
@ApiBody(PEOPLE_BODIES.updateMember)
@@ -204,7 +409,7 @@ export class PeopleController {
@Delete(':id/host/:hostId')
@HttpCode(HttpStatus.OK)
- @UseGuards(RequireRoles('owner'))
+ @RequirePermission('member', 'delete')
@ApiOperation(PEOPLE_OPERATIONS.removeHost)
@ApiParam(PEOPLE_PARAMS.memberId)
@ApiParam(PEOPLE_PARAMS.hostId)
@@ -238,6 +443,7 @@ export class PeopleController {
}
@Delete(':id')
+ @RequirePermission('member', 'delete')
@ApiOperation(PEOPLE_OPERATIONS.deleteMember)
@ApiParam(PEOPLE_PARAMS.memberId)
@ApiResponse(DELETE_MEMBER_RESPONSES[200])
@@ -252,6 +458,7 @@ export class PeopleController {
const result = await this.peopleService.deleteById(
memberId,
organizationId,
+ authContext.userId,
);
return {
@@ -269,6 +476,7 @@ export class PeopleController {
@Patch(':id/unlink-device')
@HttpCode(HttpStatus.OK)
+ @RequirePermission('member', 'update')
@ApiOperation(PEOPLE_OPERATIONS.unlinkDevice)
@ApiParam(PEOPLE_PARAMS.memberId)
@ApiResponse(UPDATE_MEMBER_RESPONSES[200])
@@ -298,4 +506,41 @@ export class PeopleController {
}),
};
}
+
+ @Get('me/email-preferences')
+ @ApiOperation({ summary: 'Get current user email notification preferences' })
+ async getEmailPreferences(
+ @AuthContext() authContext: AuthContextType,
+ @OrganizationId() organizationId: string,
+ ) {
+ if (!authContext.userId) {
+ throw new BadRequestException(
+ 'User ID is required. This endpoint requires session authentication.',
+ );
+ }
+
+ return this.peopleService.getEmailPreferences(
+ authContext.userId,
+ authContext.userEmail!,
+ organizationId,
+ );
+ }
+
+ @Put('me/email-preferences')
+ @ApiOperation({ summary: 'Update current user email notification preferences' })
+ async updateEmailPreferences(
+ @AuthContext() authContext: AuthContextType,
+ @Body() body: UpdateEmailPreferencesDto,
+ ) {
+ if (!authContext.userId) {
+ throw new BadRequestException(
+ 'User ID is required. This endpoint requires session authentication.',
+ );
+ }
+
+ return this.peopleService.updateEmailPreferences(
+ authContext.userId,
+ { ...body.preferences },
+ );
+ }
}
diff --git a/apps/api/src/people/people.module.ts b/apps/api/src/people/people.module.ts
index be2e39c926..87c878e8aa 100644
--- a/apps/api/src/people/people.module.ts
+++ b/apps/api/src/people/people.module.ts
@@ -3,11 +3,12 @@ import { AuthModule } from '../auth/auth.module';
import { FleetService } from '../lib/fleet.service';
import { PeopleController } from './people.controller';
import { PeopleService } from './people.service';
+import { PeopleInviteService } from './people-invite.service';
@Module({
imports: [AuthModule],
controllers: [PeopleController],
- providers: [PeopleService, FleetService],
+ providers: [PeopleService, PeopleInviteService, FleetService],
exports: [PeopleService],
})
export class PeopleModule {}
diff --git a/apps/api/src/people/people.service.spec.ts b/apps/api/src/people/people.service.spec.ts
new file mode 100644
index 0000000000..6fb12d5301
--- /dev/null
+++ b/apps/api/src/people/people.service.spec.ts
@@ -0,0 +1,595 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import {
+ NotFoundException,
+ ForbiddenException,
+ BadRequestException,
+} from '@nestjs/common';
+import { PeopleService } from './people.service';
+import { FleetService } from '../lib/fleet.service';
+import { MemberValidator } from './utils/member-validator';
+import { MemberQueries } from './utils/member-queries';
+
+// Mock the database
+jest.mock('@trycompai/db', () => ({
+ db: {
+ member: {
+ findFirst: jest.fn(),
+ findMany: jest.fn(),
+ update: jest.fn(),
+ },
+ task: {
+ findMany: jest.fn(),
+ updateMany: jest.fn(),
+ },
+ policy: {
+ findMany: jest.fn(),
+ updateMany: jest.fn(),
+ },
+ risk: {
+ findMany: jest.fn(),
+ updateMany: jest.fn(),
+ },
+ vendor: {
+ findMany: jest.fn(),
+ updateMany: jest.fn(),
+ },
+ session: {
+ deleteMany: jest.fn(),
+ },
+ organization: {
+ findUnique: jest.fn(),
+ },
+ organizationChart: {
+ findUnique: jest.fn(),
+ update: jest.fn(),
+ },
+ user: {
+ update: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@comp/auth', () => ({
+ BUILT_IN_ROLE_PERMISSIONS: {
+ owner: { organization: ['read', 'update', 'delete'], member: ['create', 'read', 'update', 'delete'] },
+ admin: { organization: ['read', 'update'], member: ['create', 'read', 'update', 'delete'] },
+ auditor: { organization: ['read'], member: ['read'] },
+ employee: { compliance: ['required'] },
+ contractor: { compliance: ['required'] },
+ },
+}));
+
+jest.mock('@trycompai/email', () => ({
+ isUserUnsubscribed: jest.fn().mockResolvedValue(false),
+ sendUnassignedItemsNotificationEmail: jest.fn().mockResolvedValue(undefined),
+}));
+
+jest.mock('./utils/member-validator');
+jest.mock('./utils/member-queries');
+
+import { db } from '@trycompai/db';
+
+describe('PeopleService', () => {
+ let service: PeopleService;
+ let fleetService: jest.Mocked;
+
+ const mockFleetService = {
+ removeHostsByLabel: jest.fn(),
+ getHostsByLabel: jest.fn(),
+ removeHostById: jest.fn(),
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [
+ PeopleService,
+ { provide: FleetService, useValue: mockFleetService },
+ ],
+ }).compile();
+
+ service = module.get(PeopleService);
+ fleetService = module.get(FleetService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('findAllByOrganization', () => {
+ it('should return all members for an organization', async () => {
+ const mockMembers = [
+ { id: 'mem_1', user: { name: 'Alice' } },
+ { id: 'mem_2', user: { name: 'Bob' } },
+ ];
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findAllByOrganization as jest.Mock).mockResolvedValue(
+ mockMembers,
+ );
+
+ const result = await service.findAllByOrganization('org_123');
+
+ expect(result).toEqual(mockMembers);
+ expect(result).toHaveLength(2);
+ expect(MemberValidator.validateOrganization).toHaveBeenCalledWith(
+ 'org_123',
+ );
+ });
+
+ it('should throw NotFoundException when organization does not exist', async () => {
+ (MemberValidator.validateOrganization as jest.Mock).mockRejectedValue(
+ new NotFoundException('Organization not found'),
+ );
+
+ await expect(
+ service.findAllByOrganization('org_nonexistent'),
+ ).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('findById', () => {
+ it('should return a member by ID', async () => {
+ const mockMember = {
+ id: 'mem_1',
+ user: { name: 'Alice', email: 'alice@test.com' },
+ };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ mockMember,
+ );
+
+ const result = await service.findById('mem_1', 'org_123');
+
+ expect(result).toEqual(mockMember);
+ expect(MemberQueries.findByIdInOrganization).toHaveBeenCalledWith(
+ 'mem_1',
+ 'org_123',
+ );
+ });
+
+ it('should throw NotFoundException when member does not exist', async () => {
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ null,
+ );
+
+ await expect(service.findById('mem_none', 'org_123')).rejects.toThrow(
+ NotFoundException,
+ );
+ });
+ });
+
+ describe('create', () => {
+ it('should create a new member', async () => {
+ const createData = {
+ userId: 'usr_new',
+ role: 'employee',
+ department: 'engineering',
+ };
+ const createdMember = {
+ id: 'mem_new',
+ user: { name: 'NewUser' },
+ role: 'employee',
+ };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberValidator.validateUser as jest.Mock).mockResolvedValue(undefined);
+ (MemberValidator.validateUserNotMember as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.createMember as jest.Mock).mockResolvedValue(
+ createdMember,
+ );
+
+ const result = await service.create('org_123', createData as any);
+
+ expect(result).toEqual(createdMember);
+ expect(MemberQueries.createMember).toHaveBeenCalledWith(
+ 'org_123',
+ createData,
+ );
+ });
+
+ it('should throw when user is already a member', async () => {
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberValidator.validateUser as jest.Mock).mockResolvedValue(undefined);
+ (MemberValidator.validateUserNotMember as jest.Mock).mockRejectedValue(
+ new BadRequestException('User is already a member'),
+ );
+
+ await expect(
+ service.create('org_123', { userId: 'usr_dup' } as any),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+
+ describe('updateById', () => {
+ it('should update a member', async () => {
+ const updateData = { role: 'admin' };
+ const existingMember = {
+ id: 'mem_1',
+ userId: 'usr_1',
+ role: 'employee',
+ };
+ const updatedMember = { id: 'mem_1', user: { name: 'Alice' }, role: 'admin' };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberValidator.validateMemberExists as jest.Mock).mockResolvedValue(
+ existingMember,
+ );
+ (MemberQueries.updateMember as jest.Mock).mockResolvedValue(
+ updatedMember,
+ );
+
+ const result = await service.updateById(
+ 'mem_1',
+ 'org_123',
+ updateData as any,
+ );
+
+ expect(result).toEqual(updatedMember);
+ expect(MemberQueries.updateMember).toHaveBeenCalledWith(
+ 'mem_1',
+ updateData,
+ );
+ });
+
+ it('should validate new userId when changing user', async () => {
+ const updateData = { userId: 'usr_new' };
+ const existingMember = {
+ id: 'mem_1',
+ userId: 'usr_old',
+ role: 'employee',
+ };
+ const updatedMember = { id: 'mem_1', user: { name: 'New' }, role: 'employee' };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberValidator.validateMemberExists as jest.Mock).mockResolvedValue(
+ existingMember,
+ );
+ (MemberValidator.validateUser as jest.Mock).mockResolvedValue(undefined);
+ (MemberValidator.validateUserNotMember as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.updateMember as jest.Mock).mockResolvedValue(
+ updatedMember,
+ );
+
+ await service.updateById('mem_1', 'org_123', updateData as any);
+
+ expect(MemberValidator.validateUser).toHaveBeenCalledWith('usr_new');
+ expect(MemberValidator.validateUserNotMember).toHaveBeenCalledWith(
+ 'usr_new',
+ 'org_123',
+ 'mem_1',
+ );
+ });
+
+ it('should throw NotFoundException when member does not exist', async () => {
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberValidator.validateMemberExists as jest.Mock).mockRejectedValue(
+ new NotFoundException('Member not found'),
+ );
+
+ await expect(
+ service.updateById('mem_none', 'org_123', {} as any),
+ ).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('deleteById', () => {
+ const mockMember = {
+ id: 'mem_1',
+ userId: 'usr_1',
+ role: 'employee',
+ fleetDmLabelId: null,
+ user: {
+ id: 'usr_1',
+ name: 'Alice',
+ email: 'alice@test.com',
+ isPlatformAdmin: false,
+ },
+ };
+
+ beforeEach(() => {
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ // Mock empty assignments
+ (db.task.findMany as jest.Mock).mockResolvedValue([]);
+ (db.policy.findMany as jest.Mock).mockResolvedValue([]);
+ (db.risk.findMany as jest.Mock).mockResolvedValue([]);
+ (db.vendor.findMany as jest.Mock).mockResolvedValue([]);
+ (db.task.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
+ (db.policy.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
+ (db.risk.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
+ (db.vendor.updateMany as jest.Mock).mockResolvedValue({ count: 0 });
+ (db.member.update as jest.Mock).mockResolvedValue({});
+ (db.session.deleteMany as jest.Mock).mockResolvedValue({ count: 0 });
+ (db.organization.findUnique as jest.Mock).mockResolvedValue({
+ name: 'Test Org',
+ });
+ (db.member.findFirst as jest.Mock).mockResolvedValue(mockMember);
+ });
+
+ it('should deactivate a member successfully', async () => {
+ const result = await service.deleteById('mem_1', 'org_123', 'usr_actor');
+
+ expect(result.success).toBe(true);
+ expect(result.deletedMember.id).toBe('mem_1');
+ expect(db.member.update).toHaveBeenCalledWith({
+ where: { id: 'mem_1' },
+ data: { deactivated: true, isActive: false },
+ });
+ expect(db.session.deleteMany).toHaveBeenCalledWith({
+ where: { userId: 'usr_1' },
+ });
+ });
+
+ it('should throw ForbiddenException when deleting an owner', async () => {
+ (db.member.findFirst as jest.Mock).mockResolvedValue({
+ ...mockMember,
+ role: 'owner',
+ });
+
+ await expect(
+ service.deleteById('mem_1', 'org_123', 'usr_actor'),
+ ).rejects.toThrow(ForbiddenException);
+ });
+
+ it('should throw ForbiddenException when deleting a platform admin', async () => {
+ (db.member.findFirst as jest.Mock).mockResolvedValue({
+ ...mockMember,
+ user: { ...mockMember.user, isPlatformAdmin: true },
+ });
+
+ await expect(
+ service.deleteById('mem_1', 'org_123', 'usr_actor'),
+ ).rejects.toThrow(ForbiddenException);
+ });
+
+ it('should throw ForbiddenException when deleting yourself', async () => {
+ await expect(
+ service.deleteById('mem_1', 'org_123', 'usr_1'),
+ ).rejects.toThrow(ForbiddenException);
+ });
+
+ it('should throw NotFoundException when member does not exist', async () => {
+ (db.member.findFirst as jest.Mock).mockResolvedValue(null);
+
+ await expect(
+ service.deleteById('mem_none', 'org_123', 'usr_actor'),
+ ).rejects.toThrow(NotFoundException);
+ });
+
+ it('should clear assignments and notify owner', async () => {
+ const tasks = [{ id: 't1', title: 'Task 1' }];
+ const policies = [{ id: 'p1', name: 'Policy 1' }];
+ (db.task.findMany as jest.Mock).mockResolvedValue(tasks);
+ (db.policy.findMany as jest.Mock).mockResolvedValue(policies);
+
+ await service.deleteById('mem_1', 'org_123', 'usr_actor');
+
+ expect(db.task.updateMany).toHaveBeenCalledWith({
+ where: { assigneeId: 'mem_1', organizationId: 'org_123' },
+ data: { assigneeId: null },
+ });
+ expect(db.policy.updateMany).toHaveBeenCalledWith({
+ where: { assigneeId: 'mem_1', organizationId: 'org_123' },
+ data: { assigneeId: null },
+ });
+ });
+
+ it('should remove fleet hosts when fleetDmLabelId exists', async () => {
+ (db.member.findFirst as jest.Mock).mockResolvedValue({
+ ...mockMember,
+ fleetDmLabelId: 42,
+ });
+ mockFleetService.removeHostsByLabel.mockResolvedValue({
+ deletedCount: 2,
+ failedCount: 0,
+ });
+
+ await service.deleteById('mem_1', 'org_123', 'usr_actor');
+
+ expect(fleetService.removeHostsByLabel).toHaveBeenCalledWith(42);
+ });
+ });
+
+ describe('unlinkDevice', () => {
+ it('should unlink a device from a member', async () => {
+ const member = {
+ id: 'mem_1',
+ fleetDmLabelId: 42,
+ user: { name: 'Alice' },
+ };
+ const unlinked = {
+ id: 'mem_1',
+ fleetDmLabelId: null,
+ user: { name: 'Alice' },
+ };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ member,
+ );
+ (MemberQueries.unlinkDevice as jest.Mock).mockResolvedValue(unlinked);
+ mockFleetService.removeHostsByLabel.mockResolvedValue({
+ deletedCount: 1,
+ failedCount: 0,
+ });
+
+ const result = await service.unlinkDevice('mem_1', 'org_123');
+
+ expect(result.fleetDmLabelId).toBeNull();
+ expect(fleetService.removeHostsByLabel).toHaveBeenCalledWith(42);
+ });
+
+ it('should skip fleet removal when no label exists', async () => {
+ const member = {
+ id: 'mem_1',
+ fleetDmLabelId: null,
+ user: { name: 'Alice' },
+ };
+ const unlinked = { ...member };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ member,
+ );
+ (MemberQueries.unlinkDevice as jest.Mock).mockResolvedValue(unlinked);
+
+ await service.unlinkDevice('mem_1', 'org_123');
+
+ expect(fleetService.removeHostsByLabel).not.toHaveBeenCalled();
+ });
+
+ it('should throw NotFoundException when member not found', async () => {
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ null,
+ );
+
+ await expect(
+ service.unlinkDevice('mem_none', 'org_123'),
+ ).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('removeHostById', () => {
+ it('should remove a specific host', async () => {
+ const member = {
+ id: 'mem_1',
+ fleetDmLabelId: 42,
+ user: { name: 'Alice' },
+ };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ member,
+ );
+ mockFleetService.getHostsByLabel.mockResolvedValue({
+ hosts: [{ id: 100 }, { id: 200 }],
+ });
+ mockFleetService.removeHostById.mockResolvedValue(undefined);
+
+ const result = await service.removeHostById('mem_1', 'org_123', 100);
+
+ expect(result).toEqual({ success: true });
+ expect(fleetService.removeHostById).toHaveBeenCalledWith(100);
+ });
+
+ it('should throw NotFoundException when host not found for member', async () => {
+ const member = {
+ id: 'mem_1',
+ fleetDmLabelId: 42,
+ user: { name: 'Alice' },
+ };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ member,
+ );
+ mockFleetService.getHostsByLabel.mockResolvedValue({
+ hosts: [{ id: 100 }],
+ });
+
+ await expect(
+ service.removeHostById('mem_1', 'org_123', 999),
+ ).rejects.toThrow(NotFoundException);
+ });
+
+ it('should throw BadRequestException when member has no fleet label', async () => {
+ const member = {
+ id: 'mem_1',
+ fleetDmLabelId: null,
+ user: { name: 'Alice' },
+ };
+
+ (MemberValidator.validateOrganization as jest.Mock).mockResolvedValue(
+ undefined,
+ );
+ (MemberQueries.findByIdInOrganization as jest.Mock).mockResolvedValue(
+ member,
+ );
+
+ await expect(
+ service.removeHostById('mem_1', 'org_123', 100),
+ ).rejects.toThrow(BadRequestException);
+ });
+ });
+
+ describe('updateEmailPreferences', () => {
+ it('should update preferences and set unsubscribed to false when any enabled', async () => {
+ const prefs = {
+ policyNotifications: true,
+ taskReminders: false,
+ weeklyTaskDigest: true,
+ unassignedItemsNotifications: false,
+ taskMentions: true,
+ taskAssignments: true,
+ };
+
+ (db.user.update as jest.Mock).mockResolvedValue({});
+
+ const result = await service.updateEmailPreferences('usr_1', prefs);
+
+ expect(result).toEqual({ success: true });
+ expect(db.user.update).toHaveBeenCalledWith({
+ where: { id: 'usr_1' },
+ data: {
+ emailPreferences: prefs,
+ emailNotificationsUnsubscribed: false,
+ },
+ });
+ });
+
+ it('should set unsubscribed to true when all preferences disabled', async () => {
+ const prefs = {
+ policyNotifications: false,
+ taskReminders: false,
+ weeklyTaskDigest: false,
+ unassignedItemsNotifications: false,
+ taskMentions: false,
+ taskAssignments: false,
+ };
+
+ (db.user.update as jest.Mock).mockResolvedValue({});
+
+ await service.updateEmailPreferences('usr_1', prefs);
+
+ expect(db.user.update).toHaveBeenCalledWith({
+ where: { id: 'usr_1' },
+ data: {
+ emailPreferences: prefs,
+ emailNotificationsUnsubscribed: true,
+ },
+ });
+ });
+ });
+});
diff --git a/apps/api/src/people/people.service.ts b/apps/api/src/people/people.service.ts
index 1b0a0d36d4..457ef351f4 100644
--- a/apps/api/src/people/people.service.ts
+++ b/apps/api/src/people/people.service.ts
@@ -3,15 +3,23 @@ import {
NotFoundException,
Logger,
BadRequestException,
+ ForbiddenException,
} from '@nestjs/common';
import { db } from '@trycompai/db';
import { FleetService } from '../lib/fleet.service';
+import { BUILT_IN_ROLE_PERMISSIONS } from '@comp/auth';
import type { PeopleResponseDto } from './dto/people-responses.dto';
import type { CreatePeopleDto } from './dto/create-people.dto';
import type { UpdatePeopleDto } from './dto/update-people.dto';
import type { BulkCreatePeopleDto } from './dto/bulk-create-people.dto';
import { MemberValidator } from './utils/member-validator';
import { MemberQueries } from './utils/member-queries';
+import {
+ collectAssignedItems,
+ clearAssignments,
+ removeMemberFromOrgChart,
+ notifyOwnerOfUnassignedItems,
+} from './utils/member-deactivation';
@Injectable()
export class PeopleService {
@@ -21,10 +29,14 @@ export class PeopleService {
async findAllByOrganization(
organizationId: string,
+ includeDeactivated?: boolean,
): Promise {
try {
await MemberValidator.validateOrganization(organizationId);
- const members = await MemberQueries.findAllByOrganization(organizationId);
+ const members = await MemberQueries.findAllByOrganization(
+ organizationId,
+ includeDeactivated,
+ );
this.logger.log(
`Retrieved ${members.length} members for organization ${organizationId}`,
@@ -42,6 +54,64 @@ export class PeopleService {
}
}
+ async findMentionableMembers(
+ organizationId: string,
+ resource: string,
+ ): Promise {
+ const members = await MemberQueries.findAllByOrganization(
+ organizationId,
+ false,
+ );
+
+ // Collect all unique role names across members
+ const allRoleNames = new Set();
+ for (const member of members) {
+ const roles = member.role.split(',').map((r) => r.trim()).filter(Boolean);
+ for (const role of roles) {
+ allRoleNames.add(role);
+ }
+ }
+
+ // Batch-resolve permissions: built-in from constants, custom from DB
+ const builtInRoleNames = [...allRoleNames].filter(
+ (name) => BUILT_IN_ROLE_PERMISSIONS[name] !== undefined,
+ );
+ const customRoleNames = [...allRoleNames].filter(
+ (name) => BUILT_IN_ROLE_PERMISSIONS[name] === undefined,
+ );
+
+ // Build permission map for all roles
+ const permissionMap = new Map>();
+ for (const name of builtInRoleNames) {
+ permissionMap.set(name, BUILT_IN_ROLE_PERMISSIONS[name]);
+ }
+
+ // Batch-fetch custom role permissions in one query
+ if (customRoleNames.length > 0) {
+ const customRoles = await db.organizationRole.findMany({
+ where: { organizationId, name: { in: customRoleNames } },
+ });
+ for (const role of customRoles) {
+ const perms = typeof role.permissions === 'string'
+ ? JSON.parse(role.permissions) as Record
+ : role.permissions as Record;
+ permissionMap.set(role.name, perms);
+ }
+ }
+
+ // Filter members whose combined permissions include the required permission
+ return members.filter((member) => {
+ const roles = member.role.split(',').map((r) => r.trim()).filter(Boolean);
+ for (const role of roles) {
+ const perms = permissionMap.get(role);
+ if (perms && perms[resource]?.includes('read')) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+
async findById(
memberId: string,
organizationId: string,
@@ -236,46 +306,108 @@ export class PeopleService {
async deleteById(
memberId: string,
organizationId: string,
+ callerUserId?: string,
): Promise<{
success: boolean;
deletedMember: { id: string; name: string; email: string };
}> {
- try {
- await MemberValidator.validateOrganization(organizationId);
- const member = await MemberQueries.findMemberForDeletion(
- memberId,
- organizationId,
+ await MemberValidator.validateOrganization(organizationId);
+
+ const member = await db.member.findFirst({
+ where: { id: memberId, organizationId },
+ include: { user: true },
+ });
+
+ if (!member) {
+ throw new NotFoundException(
+ `Member with ID ${memberId} not found in organization ${organizationId}`,
);
+ }
- if (!member) {
- throw new NotFoundException(
- `Member with ID ${memberId} not found in organization ${organizationId}`,
- );
- }
+ if (callerUserId && member.user.id === callerUserId) {
+ throw new ForbiddenException('You cannot remove yourself');
+ }
- await MemberQueries.deleteMember(memberId);
+ if (member.role.includes('owner')) {
+ throw new ForbiddenException('Cannot remove the organization owner');
+ }
- this.logger.log(
- `Deleted member: ${member.user.name} (${memberId}) from organization ${organizationId}`,
- );
- return {
- success: true,
- deletedMember: {
- id: member.id,
- name: member.user.name,
- email: member.user.email,
- },
- };
- } catch (error) {
- if (error instanceof NotFoundException) {
- throw error;
+ if (member.user.isPlatformAdmin) {
+ throw new ForbiddenException('Cannot remove a platform admin');
+ }
+
+ const unassignedItems = await collectAssignedItems({
+ memberId,
+ organizationId,
+ });
+
+ await clearAssignments({ memberId, organizationId });
+ await removeMemberFromOrgChart({ organizationId, memberId });
+
+ await db.member.update({
+ where: { id: memberId },
+ data: { deactivated: true, isActive: false },
+ });
+
+ await db.session.deleteMany({ where: { userId: member.userId } });
+
+ if (member.fleetDmLabelId) {
+ try {
+ await this.fleetService.removeHostsByLabel(member.fleetDmLabelId);
+ } catch (fleetError) {
+ this.logger.error('Failed to remove Fleet hosts:', fleetError);
}
- this.logger.error(
- `Failed to delete member ${memberId} from organization ${organizationId}:`,
- error,
+ }
+
+ await notifyOwnerOfUnassignedItems({
+ organizationId,
+ removedMemberName: member.user.name || member.user.email || 'Member',
+ unassignedItems,
+ });
+
+ this.logger.log(
+ `Deactivated member: ${member.user.name} (${memberId}) from organization ${organizationId}`,
+ );
+
+ return {
+ success: true,
+ deletedMember: {
+ id: member.id,
+ name: member.user.name,
+ email: member.user.email,
+ },
+ };
+ }
+
+ async reactivateById(
+ memberId: string,
+ organizationId: string,
+ ): Promise {
+ const member = await MemberQueries.findByIdInOrganization(
+ memberId,
+ organizationId,
+ );
+
+ if (member) {
+ throw new BadRequestException('Member is already active');
+ }
+
+ // Look for deactivated member
+ const deactivatedMember = await db.member.findFirst({
+ where: { id: memberId, organizationId },
+ });
+
+ if (!deactivatedMember) {
+ throw new NotFoundException(
+ `Member with ID ${memberId} not found in organization ${organizationId}`,
);
- throw new Error(`Failed to delete member: ${error.message}`);
}
+
+ return db.member.update({
+ where: { id: memberId },
+ data: { deactivated: false, isActive: true },
+ select: MemberQueries.MEMBER_SELECT,
+ });
}
async unlinkDevice(
@@ -417,4 +549,95 @@ export class PeopleService {
throw new Error(`Failed to remove host: ${error.message}`);
}
}
+
+ async getDevices(organizationId: string) {
+ return db.device.findMany({
+ where: { organizationId },
+ include: { member: { include: { user: true } } },
+ orderBy: { installedAt: 'desc' },
+ });
+ }
+
+ async getTestStatsByAssignee(organizationId: string) {
+ const tasks = await db.task.findMany({
+ where: { organizationId },
+ select: { assigneeId: true, status: true },
+ });
+ const stats = new Map();
+ for (const task of tasks) {
+ if (!task.assigneeId) continue;
+ const existing = stats.get(task.assigneeId) || { total: 0, done: 0 };
+ existing.total++;
+ if (task.status === 'done') existing.done++;
+ stats.set(task.assigneeId, existing);
+ }
+ return Object.fromEntries(stats);
+ }
+
+ async getTrainingVideos(memberId: string, organizationId: string) {
+ const member = await db.member.findFirst({
+ where: { id: memberId, organizationId },
+ });
+ if (!member) throw new NotFoundException('Member not found');
+ return db.employeeTrainingVideoCompletion.findMany({
+ where: { memberId },
+ orderBy: { completedAt: 'desc' },
+ });
+ }
+
+ async getFleetCompliance(memberId: string, organizationId: string) {
+ const member = await db.member.findFirst({
+ where: { id: memberId, organizationId },
+ select: { id: true, userId: true, fleetDmLabelId: true },
+ });
+ if (!member) throw new NotFoundException('Member not found');
+ if (!member.fleetDmLabelId) return { hosts: [], policyResults: [] };
+
+ const [hosts, policyResults] = await Promise.all([
+ this.fleetService
+ .getHostsByLabel(member.fleetDmLabelId)
+ .then((r) => r?.hosts ?? []),
+ db.fleetPolicyResult.findMany({
+ where: { userId: member.userId, organizationId },
+ }),
+ ]);
+ return { hosts, policyResults };
+ }
+
+ async getEmailPreferences(
+ userId: string,
+ userEmail: string,
+ organizationId: string,
+ ) {
+ const user = await db.user.findUnique({
+ where: { id: userId },
+ select: {
+ emailPreferences: true,
+ emailNotificationsUnsubscribed: true,
+ },
+ });
+ if (!user) throw new NotFoundException('User not found');
+ return {
+ email: userEmail,
+ preferences: user.emailPreferences ?? {},
+ unsubscribed: user.emailNotificationsUnsubscribed ?? false,
+ };
+ }
+
+ async updateEmailPreferences(
+ userId: string,
+ preferences: Record,
+ ) {
+ const allUnsubscribed = Object.values(preferences).every(
+ (v) => v === false,
+ );
+ await db.user.update({
+ where: { id: userId },
+ data: {
+ emailPreferences: preferences,
+ emailNotificationsUnsubscribed: allUnsubscribed,
+ },
+ });
+ return { success: true };
+ }
}
diff --git a/apps/api/src/people/schemas/people-operations.ts b/apps/api/src/people/schemas/people-operations.ts
index f1d7004721..cc285a1ab2 100644
--- a/apps/api/src/people/schemas/people-operations.ts
+++ b/apps/api/src/people/schemas/people-operations.ts
@@ -4,41 +4,41 @@ export const PEOPLE_OPERATIONS: Record = {
getAllPeople: {
summary: 'Get all people',
description:
- 'Returns all members for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns all members for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
createMember: {
summary: 'Create a new member',
description:
- 'Adds a new member to the authenticated organization. The user must already exist in the system. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Adds a new member to the authenticated organization. The user must already exist in the system. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
bulkCreateMembers: {
summary: 'Add multiple members to organization',
description:
- 'Bulk adds multiple members to the authenticated organization. Each member must have a valid user ID that exists in the system. Members who already exist in the organization or have invalid data will be skipped with error details returned. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Bulk adds multiple members to the authenticated organization. Each member must have a valid user ID that exists in the system. Members who already exist in the organization or have invalid data will be skipped with error details returned. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
getPersonById: {
summary: 'Get person by ID',
description:
- 'Returns a specific member by ID for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns a specific member by ID for the authenticated organization with their user information. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
updateMember: {
summary: 'Update member',
description:
- 'Partially updates a member. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Partially updates a member. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
deleteMember: {
summary: 'Delete member',
description:
- 'Permanently removes a member from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Permanently removes a member from the organization. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
unlinkDevice: {
summary: 'Unlink device from member',
description:
- 'Resets the fleetDmLabelId for a member, effectively unlinking their device from FleetDM. This will disconnect the device from the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Resets the fleetDmLabelId for a member, effectively unlinking their device from FleetDM. This will disconnect the device from the organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
removeHost: {
summary: 'Remove host (device) from Fleet',
description:
- 'Removes a single host (device) from FleetDM by host ID. Only organization owners can perform this action. Validates that the organization exists and the member exists within the organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Removes a single host (device) from FleetDM by host ID. Only organization owners can perform this action. Validates that the organization exists and the member exists within the organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
};
diff --git a/apps/api/src/people/utils/member-deactivation.ts b/apps/api/src/people/utils/member-deactivation.ts
new file mode 100644
index 0000000000..5965b5b86a
--- /dev/null
+++ b/apps/api/src/people/utils/member-deactivation.ts
@@ -0,0 +1,189 @@
+import { db, Prisma } from '@trycompai/db';
+import { isUserUnsubscribed } from '@trycompai/email';
+import { Logger } from '@nestjs/common';
+import { triggerEmail } from '../../email/trigger-email';
+import { UnassignedItemsNotificationEmail } from '../../email/templates/unassigned-items-notification';
+
+export interface UnassignedItem {
+ type: 'task' | 'policy' | 'risk' | 'vendor';
+ id: string;
+ name: string;
+}
+
+const logger = new Logger('MemberDeactivation');
+
+
+/**
+ * Collect all items assigned to a member (tasks, policies, risks, vendors).
+ */
+export async function collectAssignedItems({
+ memberId,
+ organizationId,
+}: {
+ memberId: string;
+ organizationId: string;
+}): Promise {
+ const [tasks, policies, risks, vendors] = await Promise.all([
+ db.task.findMany({
+ where: { assigneeId: memberId, organizationId },
+ select: { id: true, title: true },
+ }),
+ db.policy.findMany({
+ where: { assigneeId: memberId, organizationId },
+ select: { id: true, name: true },
+ }),
+ db.risk.findMany({
+ where: { assigneeId: memberId, organizationId },
+ select: { id: true, title: true },
+ }),
+ db.vendor.findMany({
+ where: { assigneeId: memberId, organizationId },
+ select: { id: true, name: true },
+ }),
+ ]);
+
+ const items: UnassignedItem[] = [];
+ for (const t of tasks) items.push({ type: 'task', id: t.id, name: t.title });
+ for (const p of policies) items.push({ type: 'policy', id: p.id, name: p.name });
+ for (const r of risks) items.push({ type: 'risk', id: r.id, name: r.title });
+ for (const v of vendors) items.push({ type: 'vendor', id: v.id, name: v.name });
+ return items;
+}
+
+/**
+ * Clear all assigneeId references for a member across tasks, policies, risks, vendors.
+ */
+export async function clearAssignments({
+ memberId,
+ organizationId,
+}: {
+ memberId: string;
+ organizationId: string;
+}): Promise {
+ await Promise.all([
+ db.task.updateMany({
+ where: { assigneeId: memberId, organizationId },
+ data: { assigneeId: null },
+ }),
+ db.policy.updateMany({
+ where: { assigneeId: memberId, organizationId },
+ data: { assigneeId: null },
+ }),
+ db.risk.updateMany({
+ where: { assigneeId: memberId, organizationId },
+ data: { assigneeId: null },
+ }),
+ db.vendor.updateMany({
+ where: { assigneeId: memberId, organizationId },
+ data: { assigneeId: null },
+ }),
+ ]);
+}
+
+/**
+ * Remove a member's node (and connected edges) from the organization chart.
+ */
+export async function removeMemberFromOrgChart({
+ organizationId,
+ memberId,
+}: {
+ organizationId: string;
+ memberId: string;
+}): Promise {
+ const orgChart = await db.organizationChart.findUnique({
+ where: { organizationId },
+ });
+ if (!orgChart) return;
+
+ const chartNodes = (
+ Array.isArray(orgChart.nodes) ? orgChart.nodes : []
+ ) as Array>;
+ const chartEdges = (
+ Array.isArray(orgChart.edges) ? orgChart.edges : []
+ ) as Array>;
+
+ const removedNodeIds = new Set(
+ chartNodes
+ .filter((n) => {
+ const data = n.data as Record | undefined;
+ return data?.memberId === memberId;
+ })
+ .map((n) => n.id as string),
+ );
+
+ if (removedNodeIds.size === 0) return;
+
+ const updatedNodes = chartNodes.filter(
+ (n) => !removedNodeIds.has(n.id as string),
+ );
+ const updatedEdges = chartEdges.filter(
+ (e) =>
+ !removedNodeIds.has(e.source as string) &&
+ !removedNodeIds.has(e.target as string),
+ );
+
+ await db.organizationChart.update({
+ where: { organizationId },
+ data: {
+ nodes: updatedNodes as unknown as Prisma.InputJsonValue,
+ edges: updatedEdges as unknown as Prisma.InputJsonValue,
+ },
+ });
+}
+
+/**
+ * Send an email notification to the org owner about unassigned items.
+ * Non-fatal: errors are logged but not thrown.
+ */
+export async function notifyOwnerOfUnassignedItems({
+ organizationId,
+ removedMemberName,
+ unassignedItems,
+}: {
+ organizationId: string;
+ removedMemberName: string;
+ unassignedItems: UnassignedItem[];
+}): Promise {
+ if (unassignedItems.length === 0) return;
+
+ try {
+ const organization = await db.organization.findUnique({
+ where: { id: organizationId },
+ select: { name: true },
+ });
+ if (!organization) return;
+
+ const owner = await db.member.findFirst({
+ where: {
+ organizationId,
+ role: { contains: 'owner' },
+ deactivated: false,
+ },
+ include: { user: true },
+ });
+ if (!owner) return;
+
+ const unsubscribed = await isUserUnsubscribed(
+ db,
+ owner.user.email,
+ 'unassignedItemsNotifications',
+ );
+ if (unsubscribed) return;
+
+ const userName = owner.user.name || owner.user.email || 'Owner';
+ await triggerEmail({
+ to: owner.user.email,
+ subject: `Member removed from ${organization.name} - items require reassignment`,
+ react: UnassignedItemsNotificationEmail({
+ email: owner.user.email,
+ userName,
+ organizationName: organization.name,
+ organizationId,
+ removedMemberName,
+ unassignedItems,
+ }),
+ });
+ } catch (emailError) {
+ logger.error('Failed to send unassigned items notification:', emailError);
+ }
+}
diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts
index a5b7a7df62..f0bb09f82c 100644
--- a/apps/api/src/people/utils/member-queries.ts
+++ b/apps/api/src/people/utils/member-queries.ts
@@ -30,6 +30,7 @@ export class MemberQueries {
createdAt: true,
updatedAt: true,
lastLogin: true,
+ isPlatformAdmin: true,
},
},
} as const;
@@ -39,9 +40,13 @@ export class MemberQueries {
*/
static async findAllByOrganization(
organizationId: string,
+ includeDeactivated = false,
): Promise {
return db.member.findMany({
- where: { organizationId, deactivated: false },
+ where: {
+ organizationId,
+ ...(includeDeactivated ? {} : { deactivated: false }),
+ },
select: this.MEMBER_SELECT,
orderBy: { createdAt: 'desc' },
});
@@ -58,6 +63,7 @@ export class MemberQueries {
where: {
id: memberId,
organizationId,
+ deactivated: false,
},
select: this.MEMBER_SELECT,
});
@@ -91,17 +97,66 @@ export class MemberQueries {
memberId: string,
updateData: UpdatePeopleDto,
): Promise {
- // Prepare update data with defaults for optional fields
- const updatePayload: any = { ...updateData };
+ // Separate user-level fields from member-level fields
+ const { name, email, createdAt, ...memberFields } = updateData;
+
+ // Prepare member update data
+ const updatePayload: any = { ...memberFields };
+
+ // Convert createdAt string to Date for Prisma
+ if (createdAt !== undefined) {
+ updatePayload.createdAt = new Date(createdAt);
+ }
// Handle fleetDmLabelId: convert undefined to null for database
if (
- updateData.fleetDmLabelId === undefined &&
- 'fleetDmLabelId' in updateData
+ memberFields.fleetDmLabelId === undefined &&
+ 'fleetDmLabelId' in memberFields
) {
updatePayload.fleetDmLabelId = null;
}
+ const hasUserUpdates =
+ name !== undefined || email !== undefined;
+ const hasMemberUpdates = Object.keys(updatePayload).length > 0;
+
+ // If we need to update both user and member, use a transaction
+ if (hasUserUpdates) {
+ return db.$transaction(async (tx) => {
+ // Get the member to find the associated userId
+ const member = await tx.member.findUniqueOrThrow({
+ where: { id: memberId },
+ select: { userId: true },
+ });
+
+ // Update user fields
+ const userUpdateData: { name?: string; email?: string } = {};
+ if (name !== undefined) userUpdateData.name = name;
+ if (email !== undefined) userUpdateData.email = email;
+
+ await tx.user.update({
+ where: { id: member.userId },
+ data: userUpdateData,
+ });
+
+ // Update member fields if any
+ if (hasMemberUpdates) {
+ return tx.member.update({
+ where: { id: memberId },
+ data: updatePayload,
+ select: this.MEMBER_SELECT,
+ });
+ }
+
+ // Return updated member with fresh user data
+ return tx.member.findUniqueOrThrow({
+ where: { id: memberId },
+ select: this.MEMBER_SELECT,
+ });
+ });
+ }
+
+ // Only member-level updates
return db.member.update({
where: { id: memberId },
data: updatePayload,
diff --git a/apps/api/src/policies/dto/update-policy.dto.ts b/apps/api/src/policies/dto/update-policy.dto.ts
index 0bb03bfb0b..5defca8515 100644
--- a/apps/api/src/policies/dto/update-policy.dto.ts
+++ b/apps/api/src/policies/dto/update-policy.dto.ts
@@ -1,7 +1,12 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
-import { IsOptional, IsBoolean } from 'class-validator';
+import { IsOptional, IsBoolean, IsString, IsEnum } from 'class-validator';
import { CreatePolicyDto } from './create-policy.dto';
+export enum DisplayFormat {
+ EDITOR = 'EDITOR',
+ PDF = 'PDF',
+}
+
export class UpdatePolicyDto extends PartialType(CreatePolicyDto) {
@ApiProperty({
description: 'Whether to archive this policy',
@@ -11,4 +16,14 @@ export class UpdatePolicyDto extends PartialType(CreatePolicyDto) {
@IsOptional()
@IsBoolean()
isArchived?: boolean;
+
+ @ApiProperty({
+ description: 'Display format for this policy',
+ enum: DisplayFormat,
+ example: DisplayFormat.EDITOR,
+ required: false,
+ })
+ @IsOptional()
+ @IsEnum(DisplayFormat)
+ displayFormat?: DisplayFormat;
}
diff --git a/apps/api/src/policies/dto/upload-policy-pdf.dto.ts b/apps/api/src/policies/dto/upload-policy-pdf.dto.ts
new file mode 100644
index 0000000000..3cffd3254b
--- /dev/null
+++ b/apps/api/src/policies/dto/upload-policy-pdf.dto.ts
@@ -0,0 +1,19 @@
+import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
+export class UploadPolicyPdfDto {
+ @IsOptional()
+ @IsString()
+ versionId?: string;
+
+ @IsNotEmpty()
+ @IsString()
+ fileName!: string;
+
+ @IsNotEmpty()
+ @IsString()
+ fileType!: string;
+
+ @IsNotEmpty()
+ @IsString()
+ fileData!: string; // Base64 encoded file content
+}
diff --git a/apps/api/src/policies/dto/version.dto.ts b/apps/api/src/policies/dto/version.dto.ts
index b69297e02f..9b067f83fa 100644
--- a/apps/api/src/policies/dto/version.dto.ts
+++ b/apps/api/src/policies/dto/version.dto.ts
@@ -36,6 +36,7 @@ export class UpdateVersionContentDto {
type: 'array',
items: { type: 'object', additionalProperties: true },
})
+ @Transform(({ value }) => value) // Preserve raw JSON, don't let class-transformer mangle it
@IsArray()
@Transform(({ value }) => value)
content: unknown[];
diff --git a/apps/api/src/policies/policies.controller.spec.ts b/apps/api/src/policies/policies.controller.spec.ts
new file mode 100644
index 0000000000..3203082313
--- /dev/null
+++ b/apps/api/src/policies/policies.controller.spec.ts
@@ -0,0 +1,623 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import type { AuthContext } from '../auth/types';
+import { PoliciesController } from './policies.controller';
+import { PoliciesService } from './policies.service';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: {
+ api: {
+ getSession: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ policy: ['create', 'read', 'update', 'delete'],
+ control: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+jest.mock('@trycompai/db', () => ({
+ db: {
+ policy: {
+ findFirst: jest.fn(),
+ update: jest.fn(),
+ },
+ control: {
+ findMany: jest.fn(),
+ },
+ member: {
+ findFirst: jest.fn(),
+ },
+ frameworkInstance: {
+ findMany: jest.fn(),
+ },
+ context: {
+ findMany: jest.fn(),
+ },
+ policyVersion: {
+ findFirst: jest.fn(),
+ update: jest.fn(),
+ },
+ },
+ Frequency: {
+ monthly: 'monthly',
+ quarterly: 'quarterly',
+ yearly: 'yearly',
+ },
+ PolicyStatus: {
+ draft: 'draft',
+ published: 'published',
+ },
+}));
+
+jest.mock('@trigger.dev/sdk', () => ({
+ auth: { createPublicToken: jest.fn() },
+ tasks: { trigger: jest.fn() },
+}));
+
+jest.mock('@ai-sdk/openai', () => ({
+ openai: jest.fn(),
+}));
+
+jest.mock('ai', () => ({
+ streamText: jest.fn(),
+ convertToModelMessages: jest.fn(),
+}));
+
+describe('PoliciesController', () => {
+ let controller: PoliciesController;
+ let policiesService: jest.Mocked;
+
+ const mockPoliciesService = {
+ findAll: jest.fn(),
+ findById: jest.fn(),
+ create: jest.fn(),
+ updateById: jest.fn(),
+ deleteById: jest.fn(),
+ publishAll: jest.fn(),
+ downloadAllPoliciesPdf: jest.fn(),
+ getVersions: jest.fn(),
+ getVersionById: jest.fn(),
+ createVersion: jest.fn(),
+ updateVersionContent: jest.fn(),
+ deleteVersion: jest.fn(),
+ publishVersion: jest.fn(),
+ setActiveVersion: jest.fn(),
+ submitForApproval: jest.fn(),
+ acceptChanges: jest.fn(),
+ denyChanges: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContext = {
+ organizationId: 'org_123',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'usr_123',
+ userEmail: 'test@example.com',
+ userRoles: ['admin'],
+ };
+
+ const orgId = 'org_123';
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [PoliciesController],
+ providers: [
+ { provide: PoliciesService, useValue: mockPoliciesService },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(PoliciesController);
+ policiesService = module.get(PoliciesService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('getAllPolicies', () => {
+ it('should call policiesService.findAll and return wrapped response', async () => {
+ const mockPolicies = [{ id: 'pol_1', name: 'Policy 1' }];
+ mockPoliciesService.findAll.mockResolvedValue(mockPolicies);
+
+ const result = await controller.getAllPolicies(orgId, mockAuthContext);
+
+ expect(policiesService.findAll).toHaveBeenCalledWith(orgId);
+ expect(result).toEqual({
+ data: mockPolicies,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+
+ it('should omit authenticatedUser when userId is not present', async () => {
+ const noUserContext: AuthContext = {
+ ...mockAuthContext,
+ userId: undefined,
+ userEmail: undefined,
+ };
+ mockPoliciesService.findAll.mockResolvedValue([]);
+
+ const result = await controller.getAllPolicies(orgId, noUserContext);
+
+ expect(result).toEqual({
+ data: [],
+ authType: 'session',
+ });
+ expect(result).not.toHaveProperty('authenticatedUser');
+ });
+ });
+
+ describe('publishAllPolicies', () => {
+ it('should call policiesService.publishAll with correct params', async () => {
+ const mockResult = { count: 3 };
+ mockPoliciesService.publishAll.mockResolvedValue(mockResult);
+
+ const result = await controller.publishAllPolicies(
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.publishAll).toHaveBeenCalledWith(
+ orgId,
+ 'usr_123',
+ undefined,
+ );
+ expect(result).toEqual({
+ count: 3,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('downloadAllPolicies', () => {
+ it('should call policiesService.downloadAllPoliciesPdf', async () => {
+ const mockResult = { url: 'https://s3.example.com/bundle.pdf' };
+ mockPoliciesService.downloadAllPoliciesPdf.mockResolvedValue(mockResult);
+
+ const result = await controller.downloadAllPolicies(
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.downloadAllPoliciesPdf).toHaveBeenCalledWith(
+ orgId,
+ );
+ expect(result).toEqual({
+ url: 'https://s3.example.com/bundle.pdf',
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('getPolicy', () => {
+ it('should call policiesService.findById with id and orgId', async () => {
+ const mockPolicy = { id: 'pol_1', name: 'Test Policy' };
+ mockPoliciesService.findById.mockResolvedValue(mockPolicy);
+
+ const result = await controller.getPolicy('pol_1', orgId, mockAuthContext);
+
+ expect(policiesService.findById).toHaveBeenCalledWith('pol_1', orgId);
+ expect(result).toEqual({
+ id: 'pol_1',
+ name: 'Test Policy',
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('createPolicy', () => {
+ it('should call policiesService.create with orgId and createData', async () => {
+ const createData = { name: 'New Policy' };
+ const mockPolicy = { id: 'pol_2', name: 'New Policy' };
+ mockPoliciesService.create.mockResolvedValue(mockPolicy);
+
+ const result = await controller.createPolicy(
+ createData as never,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.create).toHaveBeenCalledWith(orgId, createData);
+ expect(result).toEqual({
+ id: 'pol_2',
+ name: 'New Policy',
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('updatePolicy', () => {
+ it('should call policiesService.updateById with correct params', async () => {
+ const updateData = { name: 'Updated Policy' };
+ const mockPolicy = { id: 'pol_1', name: 'Updated Policy' };
+ mockPoliciesService.updateById.mockResolvedValue(mockPolicy);
+
+ const result = await controller.updatePolicy(
+ 'pol_1',
+ updateData as never,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.updateById).toHaveBeenCalledWith(
+ 'pol_1',
+ orgId,
+ updateData,
+ );
+ expect(result).toEqual({
+ id: 'pol_1',
+ name: 'Updated Policy',
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('deletePolicy', () => {
+ it('should call policiesService.deleteById with correct params', async () => {
+ const mockResult = { deleted: true };
+ mockPoliciesService.deleteById.mockResolvedValue(mockResult);
+
+ const result = await controller.deletePolicy(
+ 'pol_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.deleteById).toHaveBeenCalledWith('pol_1', orgId);
+ expect(result).toEqual({
+ deleted: true,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('getPolicyControls', () => {
+ it('should return mapped and all controls', async () => {
+ const { db } = require('@trycompai/db');
+ const mappedControls = [
+ { id: 'ctrl_1', name: 'Control 1', description: 'desc' },
+ ];
+ const allControls = [
+ { id: 'ctrl_1', name: 'Control 1', description: 'desc' },
+ { id: 'ctrl_2', name: 'Control 2', description: 'desc2' },
+ ];
+ db.policy.findFirst.mockResolvedValue({
+ id: 'pol_1',
+ controls: mappedControls,
+ });
+ db.control.findMany.mockResolvedValue(allControls);
+
+ const result = await controller.getPolicyControls(
+ 'pol_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(result.mappedControls).toEqual(mappedControls);
+ expect(result.allControls).toEqual(allControls);
+ expect(result.authType).toBe('session');
+ });
+
+ it('should return empty mappedControls when policy not found', async () => {
+ const { db } = require('@trycompai/db');
+ db.policy.findFirst.mockResolvedValue(null);
+ db.control.findMany.mockResolvedValue([]);
+
+ const result = await controller.getPolicyControls(
+ 'pol_999',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(result.mappedControls).toEqual([]);
+ });
+ });
+
+ describe('addPolicyControls', () => {
+ it('should connect controls to policy and return success', async () => {
+ const { db } = require('@trycompai/db');
+ db.policy.update.mockResolvedValue({});
+
+ const result = await controller.addPolicyControls(
+ 'pol_1',
+ { controlIds: ['ctrl_1', 'ctrl_2'] },
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(db.policy.update).toHaveBeenCalledWith({
+ where: { id: 'pol_1', organizationId: orgId },
+ data: {
+ controls: {
+ connect: [{ id: 'ctrl_1' }, { id: 'ctrl_2' }],
+ },
+ },
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('removePolicyControl', () => {
+ it('should disconnect control from policy and return success', async () => {
+ const { db } = require('@trycompai/db');
+ db.policy.update.mockResolvedValue({});
+
+ const result = await controller.removePolicyControl(
+ 'pol_1',
+ 'ctrl_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(db.policy.update).toHaveBeenCalledWith({
+ where: { id: 'pol_1', organizationId: orgId },
+ data: {
+ controls: {
+ disconnect: { id: 'ctrl_1' },
+ },
+ },
+ });
+ expect(result.success).toBe(true);
+ });
+ });
+
+ describe('getPolicyVersions', () => {
+ it('should call policiesService.getVersions with correct params', async () => {
+ const mockVersions = [{ id: 'ver_1', version: 1 }];
+ mockPoliciesService.getVersions.mockResolvedValue(mockVersions);
+
+ const result = await controller.getPolicyVersions(
+ 'pol_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.getVersions).toHaveBeenCalledWith('pol_1', orgId);
+ expect(result).toEqual({
+ data: mockVersions,
+ authType: 'session',
+ authenticatedUser: { id: 'usr_123', email: 'test@example.com' },
+ });
+ });
+ });
+
+ describe('getPolicyVersionById', () => {
+ it('should call policiesService.getVersionById with correct params', async () => {
+ const mockVersion = { id: 'ver_1', version: 1, content: [] };
+ mockPoliciesService.getVersionById.mockResolvedValue(mockVersion);
+
+ const result = await controller.getPolicyVersionById(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.getVersionById).toHaveBeenCalledWith(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ );
+ expect(result.data).toEqual(mockVersion);
+ });
+ });
+
+ describe('createPolicyVersion', () => {
+ it('should call policiesService.createVersion with correct params', async () => {
+ const body = { content: [{ type: 'paragraph' }] };
+ const mockVersion = { id: 'ver_2', version: 2 };
+ mockPoliciesService.createVersion.mockResolvedValue(mockVersion);
+
+ const result = await controller.createPolicyVersion(
+ 'pol_1',
+ body as never,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.createVersion).toHaveBeenCalledWith(
+ 'pol_1',
+ orgId,
+ body,
+ 'usr_123',
+ );
+ expect(result.data).toEqual(mockVersion);
+ });
+ });
+
+ describe('updateVersionContent', () => {
+ it('should call policiesService.updateVersionContent using req.body', async () => {
+ const mockData = { id: 'ver_1', content: [{ type: 'paragraph' }] };
+ mockPoliciesService.updateVersionContent.mockResolvedValue(mockData);
+ const req = { body: { content: [{ type: 'paragraph' }] } };
+
+ const result = await controller.updateVersionContent(
+ 'pol_1',
+ 'ver_1',
+ req,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.updateVersionContent).toHaveBeenCalledWith(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ { content: [{ type: 'paragraph' }] },
+ );
+ expect(result.data).toEqual(mockData);
+ });
+
+ it('should default to empty array when content is not provided', async () => {
+ mockPoliciesService.updateVersionContent.mockResolvedValue({});
+ const req = { body: {} };
+
+ await controller.updateVersionContent(
+ 'pol_1',
+ 'ver_1',
+ req,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.updateVersionContent).toHaveBeenCalledWith(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ { content: [] },
+ );
+ });
+ });
+
+ describe('deletePolicyVersion', () => {
+ it('should call policiesService.deleteVersion with correct params', async () => {
+ const mockResult = { deleted: true };
+ mockPoliciesService.deleteVersion.mockResolvedValue(mockResult);
+
+ const result = await controller.deletePolicyVersion(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.deleteVersion).toHaveBeenCalledWith(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ );
+ expect(result.data).toEqual(mockResult);
+ });
+ });
+
+ describe('publishPolicyVersion', () => {
+ it('should call policiesService.publishVersion with correct params', async () => {
+ const body = { versionId: 'ver_1' };
+ const mockResult = { published: true };
+ mockPoliciesService.publishVersion.mockResolvedValue(mockResult);
+
+ const result = await controller.publishPolicyVersion(
+ 'pol_1',
+ body as never,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.publishVersion).toHaveBeenCalledWith(
+ 'pol_1',
+ orgId,
+ body,
+ 'usr_123',
+ );
+ expect(result.data).toEqual(mockResult);
+ });
+ });
+
+ describe('setActivePolicyVersion', () => {
+ it('should call policiesService.setActiveVersion with correct params', async () => {
+ const mockResult = { activated: true };
+ mockPoliciesService.setActiveVersion.mockResolvedValue(mockResult);
+
+ const result = await controller.setActivePolicyVersion(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.setActiveVersion).toHaveBeenCalledWith(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ );
+ expect(result.data).toEqual(mockResult);
+ });
+ });
+
+ describe('submitVersionForApproval', () => {
+ it('should call policiesService.submitForApproval with correct params', async () => {
+ const body = { approverId: 'mem_123' };
+ const mockResult = { submitted: true };
+ mockPoliciesService.submitForApproval.mockResolvedValue(mockResult);
+
+ const result = await controller.submitVersionForApproval(
+ 'pol_1',
+ 'ver_1',
+ body as never,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.submitForApproval).toHaveBeenCalledWith(
+ 'pol_1',
+ 'ver_1',
+ orgId,
+ body,
+ );
+ expect(result.data).toEqual(mockResult);
+ });
+ });
+
+ describe('acceptPolicyChanges', () => {
+ it('should call policiesService.acceptChanges with correct params', async () => {
+ const body = { approverId: 'mem_123', comment: 'Looks good' };
+ const mockResult = { accepted: true };
+ mockPoliciesService.acceptChanges.mockResolvedValue(mockResult);
+
+ const result = await controller.acceptPolicyChanges(
+ 'pol_1',
+ body,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.acceptChanges).toHaveBeenCalledWith(
+ 'pol_1',
+ orgId,
+ body,
+ 'usr_123',
+ );
+ expect(result.data).toEqual(mockResult);
+ });
+ });
+
+ describe('denyPolicyChanges', () => {
+ it('should call policiesService.denyChanges with correct params', async () => {
+ const body = { approverId: 'mem_123', comment: 'Needs revision' };
+ const mockResult = { denied: true };
+ mockPoliciesService.denyChanges.mockResolvedValue(mockResult);
+
+ const result = await controller.denyPolicyChanges(
+ 'pol_1',
+ body,
+ orgId,
+ mockAuthContext,
+ );
+
+ expect(policiesService.denyChanges).toHaveBeenCalledWith(
+ 'pol_1',
+ orgId,
+ body,
+ );
+ expect(result.data).toEqual(mockResult);
+ });
+ });
+});
diff --git a/apps/api/src/policies/policies.controller.ts b/apps/api/src/policies/policies.controller.ts
index 9ff053d71e..02a43d63b0 100644
--- a/apps/api/src/policies/policies.controller.ts
+++ b/apps/api/src/policies/policies.controller.ts
@@ -1,22 +1,27 @@
import {
+ BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
+ HttpException,
+ HttpStatus,
+ NotFoundException,
Param,
Patch,
Post,
+ Query,
+ Req,
Res,
UseGuards,
- HttpException,
- HttpStatus,
} from '@nestjs/common';
import {
ApiBody,
ApiHeader,
ApiOperation,
ApiParam,
+ ApiQuery,
ApiResponse,
ApiSecurity,
ApiTags,
@@ -25,8 +30,14 @@ import {
import type { Response } from 'express';
import { openai } from '@ai-sdk/openai';
import { streamText, convertToModelMessages, type UIMessage } from 'ai';
+import { db } from '@trycompai/db';
+import { auth as triggerAuth, tasks } from '@trigger.dev/sdk';
+import type { updatePolicy } from '../trigger/policies/update-policy';
+import { AuditRead } from '../audit/skip-audit-log.decorator';
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 { CreatePolicyDto } from './dto/create-policy.dto';
import { UpdatePolicyDto } from './dto/update-policy.dto';
@@ -64,7 +75,7 @@ import { PolicyResponseDto } from './dto/policy-responses.dto';
@ApiTags('Policies')
@ApiExtraModels(PolicyResponseDto)
@Controller({ path: 'policies', version: '1' })
-@UseGuards(HybridAuthGuard)
+@UseGuards(HybridAuthGuard, PermissionGuard)
@ApiSecurity('apikey')
@ApiHeader({
name: 'X-Organization-Id',
@@ -76,6 +87,7 @@ export class PoliciesController {
constructor(private readonly policiesService: PoliciesService) {}
@Get()
+ @RequirePermission('policy', 'read')
@ApiOperation(POLICY_OPERATIONS.getAllPolicies)
@ApiResponse(GET_ALL_POLICIES_RESPONSES[200])
@ApiResponse(GET_ALL_POLICIES_RESPONSES[401])
@@ -97,7 +109,34 @@ export class PoliciesController {
};
}
+ @Post('publish-all')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Publish all draft policies' })
+ async publishAllPolicies(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.policiesService.publishAll(
+ organizationId,
+ authContext.userId,
+ authContext.memberId,
+ );
+
+ return {
+ ...data,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
@Get('download-all')
+ @RequirePermission('policy', 'read')
+ @AuditRead()
@HttpCode(HttpStatus.OK)
@ApiOperation({
summary: 'Download all published policies as a single PDF',
@@ -131,7 +170,390 @@ export class PoliciesController {
};
}
+ @Get(':id/controls')
+ @RequirePermission('policy', 'read')
+ @ApiOperation({ summary: 'Get mapped and all controls for a policy' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async getPolicyControls(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const [policy, allControls] = await Promise.all([
+ db.policy.findFirst({
+ where: { id, organizationId },
+ select: {
+ id: true,
+ controls: { select: { id: true, name: true, description: true } },
+ },
+ }),
+ db.control.findMany({
+ where: { organizationId },
+ select: { id: true, name: true, description: true },
+ orderBy: { name: 'asc' },
+ }),
+ ]);
+
+ return {
+ mappedControls: policy?.controls ?? [],
+ allControls,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Post(':id/regenerate')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Regenerate policy content using AI' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async regeneratePolicy(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const member = authContext.userId
+ ? await db.member.findFirst({
+ where: { organizationId, userId: authContext.userId },
+ select: { id: true },
+ })
+ : null;
+
+ const instances = await db.frameworkInstance.findMany({
+ where: { organizationId },
+ include: { framework: true },
+ });
+
+ const uniqueFrameworks = Array.from(
+ new Map(instances.map((fi) => [fi.framework.id, fi.framework])).values(),
+ ).map((f) => ({
+ id: f.id,
+ name: f.name,
+ version: f.version,
+ description: f.description,
+ visible: f.visible,
+ createdAt: f.createdAt,
+ updatedAt: f.updatedAt,
+ }));
+
+ const contextEntries = await db.context.findMany({
+ where: { organizationId },
+ orderBy: { createdAt: 'asc' },
+ });
+ const contextHub = contextEntries.map((c) => `${c.question}\n${c.answer}`).join('\n');
+
+ const handle = await tasks.trigger('update-policy', {
+ organizationId,
+ policyId: id,
+ contextHub,
+ frameworks: uniqueFrameworks,
+ memberId: member?.id,
+ });
+
+ const publicAccessToken = await triggerAuth.createPublicToken({
+ scopes: { read: { runs: [handle.id] } },
+ });
+
+ return {
+ data: { runId: handle.id, publicAccessToken },
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Get(':id/pdf/signed-url')
+ @RequirePermission('policy', 'read')
+ @AuditRead()
+ @ApiOperation({ summary: 'Get a signed URL for the policy PDF' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ @ApiQuery({ name: 'versionId', required: false })
+ async getPdfSignedUrl(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ @Query('versionId') versionId?: string,
+ ) {
+ // Find the PDF URL from version or policy
+ let pdfUrl: string | null = null;
+
+ if (versionId) {
+ const version = await db.policyVersion.findFirst({
+ where: { id: versionId, policy: { id, organizationId } },
+ select: { pdfUrl: true },
+ });
+ pdfUrl = version?.pdfUrl ?? null;
+ }
+
+ if (!pdfUrl) {
+ const policy = await db.policy.findFirst({
+ where: { id, organizationId },
+ select: { pdfUrl: true },
+ });
+ pdfUrl = policy?.pdfUrl ?? null;
+ }
+
+ if (!pdfUrl) {
+ return {
+ url: null,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ // Generate signed URL
+ const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
+ const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
+ const bucketName = process.env.APP_AWS_BUCKET_NAME;
+
+ if (!bucketName) {
+ return { url: null };
+ }
+
+ const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
+ const command = new GetObjectCommand({ Bucket: bucketName, Key: pdfUrl });
+ const url = await getSignedUrl(s3, command, { expiresIn: 900 });
+
+ return {
+ url,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Post(':id/pdf')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Upload a PDF to a policy or version' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async uploadPolicyPdf(
+ @Param('id') id: string,
+ @Body() body: { versionId?: string; fileName: string; fileType: string; fileData: string },
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
+ const bucketName = process.env.APP_AWS_BUCKET_NAME;
+ if (!bucketName) throw new BadRequestException('File storage is not configured');
+
+ const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
+
+ const policy = await db.policy.findFirst({
+ where: { id, organizationId },
+ select: { id: true, status: true, pdfUrl: true, currentVersionId: true, pendingVersionId: true },
+ });
+ if (!policy) throw new NotFoundException('Policy not found');
+
+ const sanitizedFileName = body.fileName.replace(/[^a-zA-Z0-9.-]/g, '_');
+ const fileBuffer = Buffer.from(body.fileData, 'base64');
+
+ if (body.versionId) {
+ const version = await db.policyVersion.findFirst({
+ where: { id: body.versionId, policyId: id },
+ select: { id: true, pdfUrl: true, version: true },
+ });
+ if (!version) throw new NotFoundException('Version not found');
+ if (version.id === policy.currentVersionId && policy.status !== 'draft') {
+ throw new BadRequestException('Cannot upload PDF to the published version');
+ }
+ if (version.id === policy.pendingVersionId) {
+ throw new BadRequestException('Cannot upload PDF to a version pending approval');
+ }
+
+ const s3Key = `${organizationId}/policies/${id}/v${version.version}-${Date.now()}-${sanitizedFileName}`;
+ await s3.send(new PutObjectCommand({ Bucket: bucketName, Key: s3Key, Body: fileBuffer, ContentType: body.fileType }));
+ const oldPdfUrl = version.pdfUrl;
+ await db.policyVersion.update({ where: { id: body.versionId }, data: { pdfUrl: s3Key } });
+
+ if (oldPdfUrl && oldPdfUrl !== s3Key) {
+ try { await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: oldPdfUrl })); } catch { /* ignore */ }
+ }
+
+ return { data: { s3Key }, authType: authContext.authType };
+ }
+
+ // Legacy: upload to policy level
+ const s3Key = `${organizationId}/policies/${id}/${Date.now()}-${sanitizedFileName}`;
+ await s3.send(new PutObjectCommand({ Bucket: bucketName, Key: s3Key, Body: fileBuffer, ContentType: body.fileType }));
+ const oldPdfUrl = policy.pdfUrl;
+ await db.policy.update({ where: { id }, data: { pdfUrl: s3Key, displayFormat: 'PDF' } });
+
+ if (oldPdfUrl && oldPdfUrl !== s3Key) {
+ try { await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: oldPdfUrl })); } catch { /* ignore */ }
+ }
+
+ return { data: { s3Key }, authType: authContext.authType };
+ }
+
+ @Delete(':id/pdf')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Delete a policy PDF' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ @ApiQuery({ name: 'versionId', required: false })
+ async deletePolicyPdf(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ @Query('versionId') versionId?: string,
+ ) {
+ const { S3Client, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
+ const bucketName = process.env.APP_AWS_BUCKET_NAME;
+ if (!bucketName) throw new BadRequestException('File storage is not configured');
+
+ const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
+
+ if (versionId) {
+ const version = await db.policyVersion.findFirst({
+ where: { id: versionId, policy: { id, organizationId } },
+ select: { id: true, pdfUrl: true },
+ });
+ if (!version) throw new NotFoundException('Version not found');
+ if (version.pdfUrl) {
+ try { await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: version.pdfUrl })); } catch { /* ignore */ }
+ await db.policyVersion.update({ where: { id: versionId }, data: { pdfUrl: null } });
+ }
+ } else {
+ const policy = await db.policy.findFirst({
+ where: { id, organizationId },
+ select: { id: true, pdfUrl: true },
+ });
+ if (!policy) throw new NotFoundException('Policy not found');
+ if (policy.pdfUrl) {
+ try { await s3.send(new DeleteObjectCommand({ Bucket: bucketName, Key: policy.pdfUrl })); } catch { /* ignore */ }
+ await db.policy.update({ where: { id }, data: { pdfUrl: null } });
+ }
+ }
+
+ return {
+ success: true,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: { id: authContext.userId, email: authContext.userEmail },
+ }),
+ };
+ }
+
+ @Get(':id/pdf-url')
+ @RequirePermission('policy', 'read')
+ @ApiOperation({ summary: 'Get signed URL for policy PDF (alternate path)' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ @ApiQuery({ name: 'versionId', required: false })
+ async getPdfUrl(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ @Query('versionId') versionId?: string,
+ ) {
+ let pdfUrl: string | null = null;
+
+ if (versionId) {
+ const version = await db.policyVersion.findFirst({
+ where: { id: versionId, policy: { id, organizationId } },
+ select: { pdfUrl: true },
+ });
+ pdfUrl = version?.pdfUrl ?? null;
+ }
+ if (!pdfUrl) {
+ const policy = await db.policy.findFirst({
+ where: { id, organizationId },
+ select: { pdfUrl: true },
+ });
+ pdfUrl = policy?.pdfUrl ?? null;
+ }
+ if (!pdfUrl) return { url: null };
+
+ const { S3Client, GetObjectCommand } = await import('@aws-sdk/client-s3');
+ const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner');
+ const bucketName = process.env.APP_AWS_BUCKET_NAME;
+ if (!bucketName) return { url: null };
+
+ const s3 = new S3Client({ region: process.env.AWS_REGION || 'us-east-1' });
+ const url = await getSignedUrl(s3, new GetObjectCommand({ Bucket: bucketName, Key: pdfUrl }), { expiresIn: 900 });
+
+ return { url };
+ }
+
+ @Post(':id/controls')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Map controls to a policy' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async addPolicyControls(
+ @Param('id') id: string,
+ @Body() body: { controlIds: string[] },
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ await db.policy.update({
+ where: { id, organizationId },
+ data: {
+ controls: {
+ connect: body.controlIds.map((cid) => ({ id: cid })),
+ },
+ },
+ });
+
+ return {
+ success: true,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Delete(':id/controls/:controlId')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Remove a control mapping from a policy' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async removePolicyControl(
+ @Param('id') id: string,
+ @Param('controlId') controlId: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ await db.policy.update({
+ where: { id, organizationId },
+ data: {
+ controls: {
+ disconnect: { id: controlId },
+ },
+ },
+ });
+
+ return {
+ success: true,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
@Get(':id')
+ @RequirePermission('policy', 'read')
@ApiOperation(POLICY_OPERATIONS.getPolicyById)
@ApiParam(POLICY_PARAMS.policyId)
@ApiResponse(GET_POLICY_BY_ID_RESPONSES[200])
@@ -157,6 +579,7 @@ export class PoliciesController {
}
@Post()
+ @RequirePermission('policy', 'create')
@ApiOperation(POLICY_OPERATIONS.createPolicy)
@ApiBody(POLICY_BODIES.createPolicy)
@ApiResponse(CREATE_POLICY_RESPONSES[201])
@@ -185,6 +608,7 @@ export class PoliciesController {
}
@Patch(':id')
+ @RequirePermission('policy', 'update')
@ApiOperation(POLICY_OPERATIONS.updatePolicy)
@ApiParam(POLICY_PARAMS.policyId)
@ApiBody(POLICY_BODIES.updatePolicy)
@@ -217,6 +641,7 @@ export class PoliciesController {
}
@Delete(':id')
+ @RequirePermission('policy', 'delete')
@ApiOperation(POLICY_OPERATIONS.deletePolicy)
@ApiParam(POLICY_PARAMS.policyId)
@ApiResponse(DELETE_POLICY_RESPONSES[200])
@@ -242,6 +667,7 @@ export class PoliciesController {
}
@Get(':id/versions')
+ @RequirePermission('policy', 'read')
@ApiOperation(VERSION_OPERATIONS.getPolicyVersions)
@ApiParam(VERSION_PARAMS.policyId)
@ApiResponse(GET_POLICY_VERSIONS_RESPONSES[200])
@@ -267,6 +693,7 @@ export class PoliciesController {
}
@Get(':id/versions/:versionId')
+ @RequirePermission('policy', 'read')
@ApiOperation(VERSION_OPERATIONS.getPolicyVersionById)
@ApiParam(VERSION_PARAMS.policyId)
@ApiParam(VERSION_PARAMS.versionId)
@@ -298,6 +725,7 @@ export class PoliciesController {
}
@Post(':id/versions')
+ @RequirePermission('policy', 'update')
@ApiOperation(VERSION_OPERATIONS.createPolicyVersion)
@ApiParam(VERSION_PARAMS.policyId)
@ApiBody(VERSION_BODIES.createVersion)
@@ -331,6 +759,7 @@ export class PoliciesController {
}
@Patch(':id/versions/:versionId')
+ @RequirePermission('policy', 'update')
@ApiOperation(VERSION_OPERATIONS.updateVersionContent)
@ApiParam(VERSION_PARAMS.policyId)
@ApiParam(VERSION_PARAMS.versionId)
@@ -342,15 +771,16 @@ export class PoliciesController {
async updateVersionContent(
@Param('id') id: string,
@Param('versionId') versionId: string,
- @Body() body: UpdateVersionContentDto,
+ @Req() req: { body: { content?: unknown[] } },
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
) {
+ // Use req.body directly to avoid class-transformer mangling TipTap JSON
const data = await this.policiesService.updateVersionContent(
id,
versionId,
organizationId,
- body,
+ { content: req.body.content ?? [] },
);
return {
@@ -366,6 +796,7 @@ export class PoliciesController {
}
@Delete(':id/versions/:versionId')
+ @RequirePermission('policy', 'delete')
@ApiOperation(VERSION_OPERATIONS.deletePolicyVersion)
@ApiParam(VERSION_PARAMS.policyId)
@ApiParam(VERSION_PARAMS.versionId)
@@ -398,6 +829,7 @@ export class PoliciesController {
}
@Post(':id/versions/publish')
+ @RequirePermission('policy', 'update')
@ApiOperation(VERSION_OPERATIONS.publishPolicyVersion)
@ApiParam(VERSION_PARAMS.policyId)
@ApiBody(VERSION_BODIES.publishVersion)
@@ -431,6 +863,7 @@ export class PoliciesController {
}
@Post(':id/versions/:versionId/activate')
+ @RequirePermission('policy', 'update')
@ApiOperation(VERSION_OPERATIONS.setActivePolicyVersion)
@ApiParam(VERSION_PARAMS.policyId)
@ApiParam(VERSION_PARAMS.versionId)
@@ -463,6 +896,7 @@ export class PoliciesController {
}
@Post(':id/versions/:versionId/submit-for-approval')
+ @RequirePermission('policy', 'update')
@ApiOperation(VERSION_OPERATIONS.submitVersionForApproval)
@ApiParam(VERSION_PARAMS.policyId)
@ApiParam(VERSION_PARAMS.versionId)
@@ -497,7 +931,65 @@ export class PoliciesController {
};
}
+ @Post(':id/accept-changes')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Accept pending policy changes and publish the version' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async acceptPolicyChanges(
+ @Param('id') id: string,
+ @Body() body: { approverId: string; comment?: string },
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.policiesService.acceptChanges(
+ id,
+ organizationId,
+ body,
+ authContext.userId,
+ );
+
+ return {
+ data,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Post(':id/deny-changes')
+ @RequirePermission('policy', 'update')
+ @ApiOperation({ summary: 'Deny pending policy changes' })
+ @ApiParam(POLICY_PARAMS.policyId)
+ async denyPolicyChanges(
+ @Param('id') id: string,
+ @Body() body: { approverId: string; comment?: string },
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.policiesService.denyChanges(
+ id,
+ organizationId,
+ body,
+ );
+
+ return {
+ data,
+ authType: authContext.authType,
+ ...(authContext.userId && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
@Post(':id/ai-chat')
+ @RequirePermission('policy', 'read')
@ApiOperation({
summary: 'Chat with AI about a policy',
description:
diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts
index cc2b019de6..4e9e2cbbae 100644
--- a/apps/api/src/policies/policies.service.ts
+++ b/apps/api/src/policies/policies.service.ts
@@ -4,7 +4,7 @@ import {
Logger,
NotFoundException,
} from '@nestjs/common';
-import { db, PolicyStatus, Prisma } from '@trycompai/db';
+import { db, Frequency, PolicyStatus, Prisma } from '@trycompai/db';
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import { AttachmentsService } from '../attachments/attachments.service';
import { PolicyPdfRendererService } from '../trust-portal/policy-pdf-renderer.service';
@@ -17,6 +17,20 @@ import type {
UpdateVersionContentDto,
} from './dto/version.dto';
+function computeNextReviewDate(frequency: Frequency | null | undefined): Date {
+ const now = new Date();
+ switch (frequency) {
+ case Frequency.monthly:
+ return new Date(now.getFullYear(), now.getMonth() + 1, now.getDate());
+ case Frequency.quarterly:
+ return new Date(now.getFullYear(), now.getMonth() + 3, now.getDate());
+ case Frequency.yearly:
+ return new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
+ default:
+ return new Date(now.getFullYear() + 1, now.getMonth(), now.getDate());
+ }
+}
+
@Injectable()
export class PoliciesService {
private readonly logger = new Logger(PoliciesService.name);
@@ -83,6 +97,81 @@ export class PoliciesService {
}
}
+ async publishAll(
+ organizationId: string,
+ userId?: string,
+ memberId?: string,
+ ) {
+ const draftPolicies = await db.policy.findMany({
+ where: { organizationId, status: 'draft', isArchived: false },
+ select: { id: true, name: true, frequency: true },
+ });
+
+ if (draftPolicies.length === 0) {
+ return { success: true, publishedCount: 0, members: [] };
+ }
+
+ const now = new Date();
+
+ await db.$transaction(
+ draftPolicies.map((p) =>
+ db.policy.update({
+ where: { id: p.id },
+ data: {
+ status: 'published',
+ lastPublishedAt: now,
+ reviewDate: computeNextReviewDate(p.frequency),
+ },
+ }),
+ ),
+ );
+
+ // Create audit log entry for each published policy
+ if (userId) {
+ await db.auditLog.createMany({
+ data: draftPolicies.map((p) => ({
+ organizationId,
+ userId,
+ memberId: memberId ?? null,
+ entityType: 'policy' as const,
+ entityId: p.id,
+ description: `Published policy via bulk publish`,
+ data: {
+ action: 'Published policy',
+ method: 'POST',
+ path: '/v1/policies/publish-all',
+ resource: 'policy',
+ permission: 'update',
+ },
+ })),
+ });
+ }
+
+ // Fetch employee/contractor members for email notifications
+ const members = await db.member.findMany({
+ where: {
+ organizationId,
+ deactivated: false,
+ role: { in: ['employee', 'contractor'] },
+ },
+ include: {
+ user: { select: { email: true, name: true } },
+ organization: { select: { name: true, id: true } },
+ },
+ });
+
+ return {
+ success: true,
+ publishedCount: draftPolicies.length,
+ members: members.map((m) => ({
+ email: m.user.email,
+ userName: m.user.name || '',
+ organizationName: m.organization.name || '',
+ organizationId: m.organization.id,
+ })),
+ };
+ }
+
async findById(id: string, organizationId: string) {
try {
const policy = await db.policy.findFirst({
@@ -148,6 +237,15 @@ export class PoliciesService {
async create(organizationId: string, createData: CreatePolicyDto) {
try {
+ if (createData.assigneeId) {
+ const assignee = await db.member.findFirst({
+ where: { id: createData.assigneeId, organizationId },
+ include: { user: { select: { isPlatformAdmin: true } } },
+ });
+ if (assignee?.user.isPlatformAdmin) {
+ throw new BadRequestException('Cannot assign a platform admin as assignee');
+ }
+ }
const contentValue = createData.content as Prisma.InputJsonValue[];
// Create policy with version 1 in a transaction
@@ -756,6 +854,7 @@ export class PoliciesService {
content: contentToPublish,
draftContent: contentToPublish,
lastPublishedAt: new Date(),
+ reviewDate: computeNextReviewDate(policy.frequency),
status: 'published',
// Clear any pending approval since we're publishing directly
pendingVersionId: null,
@@ -819,12 +918,11 @@ export class PoliciesService {
data: {
currentVersionId: versionId,
content: version.content as Prisma.InputJsonValue[],
- draftContent: version.content as Prisma.InputJsonValue[], // Sync draft to prevent "unpublished changes" UI bug
+ draftContent: version.content as Prisma.InputJsonValue[],
status: 'published',
- // Clear pending approval state since we're directly activating a version
+ reviewDate: computeNextReviewDate(policy.frequency),
pendingVersionId: null,
approverId: null,
- // Clear signatures - employees must re-acknowledge new content
signedBy: [],
},
});
@@ -857,8 +955,11 @@ export class PoliciesService {
throw new NotFoundException('Version not found');
}
- // Cannot submit the already-active version for approval
- if (versionId === policy.currentVersionId) {
+ // Cannot re-submit the already-published version for approval
+ if (
+ versionId === policy.currentVersionId &&
+ policy.status === PolicyStatus.published
+ ) {
throw new BadRequestException(
'Cannot submit the currently published version for approval',
);
@@ -879,6 +980,17 @@ export class PoliciesService {
);
}
+ // Cannot assign a platform admin as approver
+ const approverUser = await db.user.findUnique({
+ where: { id: approver.userId },
+ select: { isPlatformAdmin: true },
+ });
+ if (approverUser?.isPlatformAdmin) {
+ throw new BadRequestException(
+ 'Cannot assign a platform admin as approver',
+ );
+ }
+
await db.policy.update({
where: { id: policyId },
data: {
@@ -894,6 +1006,104 @@ export class PoliciesService {
};
}
+ async acceptChanges(
+ policyId: string,
+ organizationId: string,
+ dto: { approverId: string; comment?: string },
+ userId?: string,
+ ) {
+ const policy = await db.policy.findUnique({
+ where: { id: policyId, organizationId },
+ });
+
+ if (!policy) {
+ throw new NotFoundException(`Policy with ID ${policyId} not found`);
+ }
+
+ if (!policy.pendingVersionId) {
+ throw new BadRequestException('No pending version to approve');
+ }
+
+ if (policy.approverId !== dto.approverId) {
+ throw new BadRequestException('Only the assigned approver can accept changes');
+ }
+
+ const version = await db.policyVersion.findUnique({
+ where: { id: policy.pendingVersionId },
+ });
+
+ if (!version) {
+ throw new NotFoundException('Pending version not found');
+ }
+
+ const memberId = await this.getMemberId(organizationId, userId);
+
+ await db.$transaction(async (tx) => {
+ // Update the version with the publisher
+ await tx.policyVersion.update({
+ where: { id: version.id },
+ data: { publishedById: memberId },
+ });
+
+ // Publish the pending version
+ await tx.policy.update({
+ where: { id: policyId },
+ data: {
+ currentVersionId: version.id,
+ content: version.content as Prisma.InputJsonValue[],
+ draftContent: version.content as Prisma.InputJsonValue[],
+ status: PolicyStatus.published,
+ lastPublishedAt: new Date(),
+ reviewDate: computeNextReviewDate(policy.frequency),
+ pendingVersionId: null,
+ approverId: null,
+ // Clear signatures — employees must re-acknowledge new content
+ signedBy: [],
+ },
+ });
+ });
+
+ return { versionId: version.id, version: version.version };
+ }
+
+ async denyChanges(
+ policyId: string,
+ organizationId: string,
+ dto: { approverId: string; comment?: string },
+ ) {
+ const policy = await db.policy.findUnique({
+ where: { id: policyId, organizationId },
+ });
+
+ if (!policy) {
+ throw new NotFoundException(`Policy with ID ${policyId} not found`);
+ }
+
+ if (!policy.pendingVersionId) {
+ throw new BadRequestException('No pending version to deny');
+ }
+
+ if (policy.approverId !== dto.approverId) {
+ throw new BadRequestException('Only the assigned approver can deny changes');
+ }
+
+ // Revert policy to previous state (draft if never published, published if it was)
+ const newStatus = policy.lastPublishedAt
+ ? PolicyStatus.published
+ : PolicyStatus.draft;
+
+ await db.policy.update({
+ where: { id: policyId },
+ data: {
+ status: newStatus,
+ pendingVersionId: null,
+ approverId: null,
+ },
+ });
+
+ return { status: newStatus };
+ }
+
private async getMemberId(
organizationId: string,
userId?: string,
diff --git a/apps/api/src/policies/schemas/get-policy-by-id.responses.ts b/apps/api/src/policies/schemas/get-policy-by-id.responses.ts
index 74a6b28d6a..151c7b972f 100644
--- a/apps/api/src/policies/schemas/get-policy-by-id.responses.ts
+++ b/apps/api/src/policies/schemas/get-policy-by-id.responses.ts
@@ -38,6 +38,23 @@ export const GET_POLICY_BY_ID_RESPONSES: Record = {
},
},
},
+ 403: {
+ status: 403,
+ description: 'Forbidden - User does not have permission to access this policy',
+ content: {
+ 'application/json': {
+ schema: {
+ type: 'object',
+ properties: {
+ message: {
+ type: 'string',
+ example: 'You do not have access to view this policy',
+ },
+ },
+ },
+ },
+ },
+ },
404: {
status: 404,
description: 'Policy not found',
diff --git a/apps/api/src/policies/schemas/policy-operations.ts b/apps/api/src/policies/schemas/policy-operations.ts
index 4c7076551c..7381f7febb 100644
--- a/apps/api/src/policies/schemas/policy-operations.ts
+++ b/apps/api/src/policies/schemas/policy-operations.ts
@@ -4,26 +4,26 @@ export const POLICY_OPERATIONS: Record = {
getAllPolicies: {
summary: 'Get all policies',
description:
- 'Returns all policies for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns all policies for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
getPolicyById: {
summary: 'Get policy by ID',
description:
- 'Returns a specific policy by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Returns a specific policy by ID for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
createPolicy: {
summary: 'Create a new policy',
description:
- 'Creates a new policy for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Creates a new policy for the authenticated organization. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
updatePolicy: {
summary: 'Update policy',
description:
- 'Partially updates a policy. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Partially updates a policy. Only provided fields will be updated. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
deletePolicy: {
summary: 'Delete policy',
description:
- 'Permanently deletes a policy. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (cookies + X-Organization-Id header).',
+ 'Permanently deletes a policy. This action cannot be undone. Supports both API key authentication (X-API-Key header) and session authentication (Bearer token or cookies).',
},
};
diff --git a/apps/api/src/questionnaire/questionnaire.controller.spec.ts b/apps/api/src/questionnaire/questionnaire.controller.spec.ts
new file mode 100644
index 0000000000..86d8572469
--- /dev/null
+++ b/apps/api/src/questionnaire/questionnaire.controller.spec.ts
@@ -0,0 +1,274 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { NotFoundException } from '@nestjs/common';
+
+jest.mock('../auth/auth.server', () => ({
+ auth: { api: { getSession: jest.fn() } },
+}));
+
+jest.mock('@/vector-store/lib', () => ({
+ syncOrganizationEmbeddings: jest.fn(),
+ findSimilarContentBatch: jest.fn(),
+}));
+
+jest.mock('@/trigger/questionnaire/answer-question-helpers', () => ({
+ generateAnswerFromContent: jest.fn(),
+}));
+
+jest.mock('../trust-portal/email.service', () => ({
+ TrustPortalEmailService: jest.fn(),
+}));
+
+jest.mock('../email/resend', () => ({
+ sendEmail: jest.fn(),
+}));
+
+import { QuestionnaireController } from './questionnaire.controller';
+import { QuestionnaireService } from './questionnaire.service';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { TrustAccessService } from '../trust-portal/trust-access.service';
+import type { AuthContext } from '../auth/types';
+
+describe('QuestionnaireController', () => {
+ let controller: QuestionnaireController;
+ let service: jest.Mocked;
+
+ const mockService = {
+ findAll: jest.fn(),
+ findById: jest.fn(),
+ deleteById: jest.fn(),
+ parseQuestionnaire: jest.fn(),
+ answerSingleQuestion: jest.fn(),
+ saveAnswer: jest.fn(),
+ deleteAnswer: jest.fn(),
+ exportById: jest.fn(),
+ uploadAndParse: jest.fn(),
+ autoAnswerAndExport: jest.fn(),
+ saveGeneratedAnswerPublic: jest.fn(),
+ };
+
+ const mockTrustAccessService = {
+ validateAccessTokenAndGetOrganizationId: jest.fn(),
+ };
+
+ const mockGuard = { canActivate: jest.fn().mockReturnValue(true) };
+
+ const mockAuthContext: AuthContext = {
+ organizationId: 'org_1',
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userId: 'usr_1',
+ userEmail: 'test@example.com',
+ userRoles: ['owner'],
+ };
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [QuestionnaireController],
+ providers: [
+ { provide: QuestionnaireService, useValue: mockService },
+ { provide: TrustAccessService, useValue: mockTrustAccessService },
+ ],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue(mockGuard)
+ .overrideGuard(PermissionGuard)
+ .useValue(mockGuard)
+ .compile();
+
+ controller = module.get(QuestionnaireController);
+ service = module.get(QuestionnaireService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('findAll', () => {
+ it('should return list with count and auth context', async () => {
+ const mockData = [
+ { id: 'q1', filename: 'test.pdf', questions: [] },
+ { id: 'q2', filename: 'test2.xlsx', questions: [] },
+ ];
+ mockService.findAll.mockResolvedValue(mockData);
+
+ const result = await controller.findAll('org_1', mockAuthContext);
+
+ expect(result.data).toEqual(mockData);
+ expect(result.count).toBe(2);
+ expect(result.authType).toBe('session');
+ expect(result.authenticatedUser).toEqual({
+ id: 'usr_1',
+ email: 'test@example.com',
+ });
+ expect(service.findAll).toHaveBeenCalledWith('org_1');
+ });
+
+ it('should return empty list when no questionnaires', async () => {
+ mockService.findAll.mockResolvedValue([]);
+
+ const result = await controller.findAll('org_1', mockAuthContext);
+
+ expect(result.data).toEqual([]);
+ expect(result.count).toBe(0);
+ });
+
+ it('should not include authenticatedUser for api-key auth', async () => {
+ const apiKeyContext: AuthContext = {
+ ...mockAuthContext,
+ userId: undefined,
+ userEmail: undefined,
+ authType: 'api-key',
+ isApiKey: true,
+ };
+ mockService.findAll.mockResolvedValue([]);
+
+ const result = await controller.findAll('org_1', apiKeyContext);
+
+ expect(result.authenticatedUser).toBeUndefined();
+ expect(result.authType).toBe('api-key');
+ });
+ });
+
+ describe('findById', () => {
+ it('should return questionnaire with auth context', async () => {
+ const mockQuestionnaire = {
+ id: 'q1',
+ filename: 'test.pdf',
+ questions: [{ id: 'qa1', question: 'Q1?' }],
+ };
+ mockService.findById.mockResolvedValue(mockQuestionnaire);
+
+ const result = await controller.findById('q1', 'org_1', mockAuthContext);
+
+ expect(result).toMatchObject({
+ id: 'q1',
+ filename: 'test.pdf',
+ authType: 'session',
+ authenticatedUser: { id: 'usr_1', email: 'test@example.com' },
+ });
+ expect(service.findById).toHaveBeenCalledWith('q1', 'org_1');
+ });
+
+ it('should throw NotFoundException when questionnaire not found', async () => {
+ mockService.findById.mockResolvedValue(null);
+
+ await expect(
+ controller.findById('missing', 'org_1', mockAuthContext),
+ ).rejects.toThrow(NotFoundException);
+ });
+ });
+
+ describe('deleteById', () => {
+ it('should delegate to service and return result', async () => {
+ mockService.deleteById.mockResolvedValue({ success: true });
+
+ const result = await controller.deleteById('q1', 'org_1');
+
+ expect(result).toEqual({ success: true });
+ expect(service.deleteById).toHaveBeenCalledWith('q1', 'org_1');
+ });
+ });
+
+ describe('parseQuestionnaire', () => {
+ it('should delegate to service', async () => {
+ const dto = {
+ fileData: 'base64data',
+ fileType: 'application/pdf',
+ organizationId: 'org_1',
+ fileName: 'test.pdf',
+ };
+ const expected = {
+ totalQuestions: 3,
+ questionsAndAnswers: [
+ { question: 'Q1?', answer: null },
+ { question: 'Q2?', answer: null },
+ { question: 'Q3?', answer: null },
+ ],
+ };
+ mockService.parseQuestionnaire.mockResolvedValue(expected);
+
+ const result = await controller.parseQuestionnaire(dto as any);
+
+ expect(result).toEqual(expected);
+ expect(service.parseQuestionnaire).toHaveBeenCalledWith(dto);
+ });
+ });
+
+ describe('answerSingleQuestion', () => {
+ it('should return formatted answer result', async () => {
+ const dto = {
+ question: 'What is your policy?',
+ organizationId: 'org_1',
+ questionIndex: 0,
+ totalQuestions: 5,
+ };
+ mockService.answerSingleQuestion.mockResolvedValue({
+ success: true,
+ questionIndex: 0,
+ question: 'What is your policy?',
+ answer: 'Our policy covers...',
+ sources: [{ sourceType: 'policy', score: 0.9 }],
+ error: undefined,
+ });
+
+ const result = await controller.answerSingleQuestion(dto as any);
+
+ expect(result.success).toBe(true);
+ expect(result.data.answer).toBe('Our policy covers...');
+ expect(result.data.questionIndex).toBe(0);
+ expect(result.data.sources).toHaveLength(1);
+ });
+ });
+
+ describe('saveAnswer', () => {
+ it('should delegate to service', async () => {
+ const dto = {
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ questionIndex: 0,
+ answer: 'Yes',
+ status: 'manual',
+ };
+ mockService.saveAnswer.mockResolvedValue({ success: true });
+
+ const result = await controller.saveAnswer(dto as any);
+
+ expect(result).toEqual({ success: true });
+ });
+ });
+
+ describe('deleteAnswer', () => {
+ it('should delegate to service', async () => {
+ const dto = {
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ questionAnswerId: 'qa1',
+ };
+ mockService.deleteAnswer.mockResolvedValue({ success: true });
+
+ const result = await controller.deleteAnswer(dto as any);
+
+ expect(result).toEqual({ success: true });
+ });
+ });
+
+ describe('uploadAndParse', () => {
+ it('should delegate to service', async () => {
+ const dto = {
+ organizationId: 'org_1',
+ fileName: 'test.pdf',
+ fileType: 'application/pdf',
+ fileData: 'base64data',
+ source: 'internal',
+ };
+ mockService.uploadAndParse.mockResolvedValue({
+ questionnaireId: 'q1',
+ totalQuestions: 10,
+ });
+
+ const result = await controller.uploadAndParse(dto as any);
+
+ expect(result).toEqual({ questionnaireId: 'q1', totalQuestions: 10 });
+ });
+ });
+});
diff --git a/apps/api/src/questionnaire/questionnaire.controller.ts b/apps/api/src/questionnaire/questionnaire.controller.ts
index d89b8ca116..52ab0d9e1a 100644
--- a/apps/api/src/questionnaire/questionnaire.controller.ts
+++ b/apps/api/src/questionnaire/questionnaire.controller.ts
@@ -2,10 +2,15 @@ import {
BadRequestException,
Body,
Controller,
+ Delete,
+ Get,
+ NotFoundException,
+ Param,
Post,
Query,
Res,
UploadedFile,
+ UseGuards,
UseInterceptors,
Logger,
} from '@nestjs/common';
@@ -17,8 +22,19 @@ import {
ApiOkResponse,
ApiProduces,
ApiQuery,
+ ApiSecurity,
ApiTags,
} from '@nestjs/swagger';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { Public } from '../auth/public.decorator';
+import { RequirePermission } from '../auth/require-permission.decorator';
+import {
+ OrganizationId,
+ AuthContext,
+} from '../auth/auth-context.decorator';
+import { AuditRead } from '../audit/skip-audit-log.decorator';
+import type { AuthContext as AuthContextType } from '../auth/types';
import { ParseQuestionnaireDto } from './dto/parse-questionnaire.dto';
import { ExportQuestionnaireDto } from './dto/export-questionnaire.dto';
import { AnswerSingleQuestionDto } from './dto/answer-single-question.dto';
@@ -48,6 +64,8 @@ import {
path: 'questionnaire',
version: '1',
})
+@UseGuards(HybridAuthGuard, PermissionGuard)
+@ApiSecurity('apikey')
export class QuestionnaireController {
private readonly logger = new Logger(QuestionnaireController.name);
@@ -56,7 +74,68 @@ export class QuestionnaireController {
private readonly trustAccessService: TrustAccessService,
) {}
+ @Get()
+ @RequirePermission('questionnaire', 'read')
+ @ApiOkResponse({ description: 'List of questionnaires' })
+ async findAll(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.questionnaireService.findAll(organizationId);
+ return {
+ data,
+ count: data.length,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Get(':id')
+ @RequirePermission('questionnaire', 'read')
+ @ApiOkResponse({ description: 'Questionnaire details' })
+ async findById(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const questionnaire = await this.questionnaireService.findById(
+ id,
+ organizationId,
+ );
+ if (!questionnaire) {
+ throw new NotFoundException('Questionnaire not found');
+ }
+ return {
+ ...questionnaire,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Delete(':id')
+ @RequirePermission('questionnaire', 'delete')
+ @ApiOkResponse({ description: 'Questionnaire deleted' })
+ async deleteById(
+ @Param('id') id: string,
+ @OrganizationId() organizationId: string,
+ ) {
+ return this.questionnaireService.deleteById(id, organizationId);
+ }
+
@Post('parse')
+ @RequirePermission('questionnaire', 'read')
@ApiConsumes('application/json')
@ApiOkResponse({
description: 'Parsed questionnaire content',
@@ -69,6 +148,7 @@ export class QuestionnaireController {
}
@Post('answer-single')
+ @RequirePermission('questionnaire', 'update')
@ApiConsumes('application/json')
@ApiOkResponse({
description: 'Generated single answer result',
@@ -104,6 +184,7 @@ export class QuestionnaireController {
}
@Post('save-answer')
+ @RequirePermission('questionnaire', 'update')
@ApiConsumes('application/json')
@ApiOkResponse({
description: 'Save manual or generated answer',
@@ -120,6 +201,7 @@ export class QuestionnaireController {
}
@Post('delete-answer')
+ @RequirePermission('questionnaire', 'delete')
@ApiConsumes('application/json')
@ApiOkResponse({
description: 'Delete questionnaire answer',
@@ -136,6 +218,8 @@ export class QuestionnaireController {
}
@Post('export')
+ @RequirePermission('questionnaire', 'read')
+ @AuditRead()
@ApiConsumes('application/json')
@ApiProduces(
'application/pdf',
@@ -161,6 +245,7 @@ export class QuestionnaireController {
}
@Post('upload-and-parse')
+ @RequirePermission('questionnaire', 'create')
@ApiConsumes('application/json')
@ApiOkResponse({
description:
@@ -178,6 +263,7 @@ export class QuestionnaireController {
}
@Post('upload-and-parse/upload')
+ @RequirePermission('questionnaire', 'create')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({
@@ -241,6 +327,7 @@ export class QuestionnaireController {
}
@Post('parse/upload')
+ @RequirePermission('questionnaire', 'create')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({
@@ -321,6 +408,8 @@ export class QuestionnaireController {
}
@Post('parse/upload/token')
+ @Public()
+ @UseGuards() // Override class-level guards — this endpoint uses token-based auth
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiQuery({
@@ -398,6 +487,8 @@ export class QuestionnaireController {
}
@Post('answers/export')
+ @RequirePermission('questionnaire', 'read')
+ @AuditRead()
@ApiConsumes('application/json')
@ApiProduces(
'application/pdf',
@@ -424,6 +515,7 @@ export class QuestionnaireController {
}
@Post('answers/export/upload')
+ @RequirePermission('questionnaire', 'create')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({
@@ -491,6 +583,7 @@ export class QuestionnaireController {
}
@Post('auto-answer')
+ @RequirePermission('questionnaire', 'update')
@ApiConsumes('application/json')
@ApiProduces('text/event-stream')
async autoAnswer(
diff --git a/apps/api/src/questionnaire/questionnaire.module.ts b/apps/api/src/questionnaire/questionnaire.module.ts
index 9de28e7fb5..a7b8255430 100644
--- a/apps/api/src/questionnaire/questionnaire.module.ts
+++ b/apps/api/src/questionnaire/questionnaire.module.ts
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
+import { AuthModule } from '../auth/auth.module';
import { QuestionnaireController } from './questionnaire.controller';
import { QuestionnaireService } from './questionnaire.service';
import { TrustPortalModule } from '../trust-portal/trust-portal.module';
@Module({
- imports: [TrustPortalModule],
+ imports: [AuthModule, TrustPortalModule],
controllers: [QuestionnaireController],
providers: [QuestionnaireService],
})
diff --git a/apps/api/src/questionnaire/questionnaire.service.spec.ts b/apps/api/src/questionnaire/questionnaire.service.spec.ts
new file mode 100644
index 0000000000..d5d103da9b
--- /dev/null
+++ b/apps/api/src/questionnaire/questionnaire.service.spec.ts
@@ -0,0 +1,579 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { QuestionnaireService } from './questionnaire.service';
+
+// Mock external dependencies
+jest.mock('@db', () => ({
+ db: {
+ questionnaire: {
+ findMany: jest.fn(),
+ findUnique: jest.fn(),
+ delete: jest.fn(),
+ },
+ questionnaireQuestionAnswer: {
+ findUnique: jest.fn(),
+ findFirst: jest.fn(),
+ update: jest.fn(),
+ },
+ securityQuestionnaireManualAnswer: {
+ upsert: jest.fn(),
+ },
+ },
+ Prisma: {
+ JsonNull: 'DbNull',
+ },
+}));
+
+jest.mock('@/vector-store/lib', () => ({
+ syncManualAnswerToVector: jest.fn(),
+ syncOrganizationEmbeddings: jest.fn(),
+}));
+
+jest.mock('@/trigger/questionnaire/answer-question', () => ({
+ answerQuestion: jest.fn(),
+}));
+
+jest.mock('@/trigger/questionnaire/answer-question-helpers', () => ({
+ generateAnswerWithRAGBatch: jest.fn(),
+}));
+
+jest.mock('./utils/content-extractor', () => ({
+ extractContentFromFile: jest.fn(),
+ extractQuestionsWithAI: jest.fn(),
+}));
+
+jest.mock('./utils/question-parser', () => ({
+ parseQuestionsAndAnswers: jest.fn(),
+}));
+
+jest.mock('./utils/export-generator', () => ({
+ generateExportFile: jest.fn(),
+}));
+
+jest.mock('./utils/questionnaire-storage', () => ({
+ updateAnsweredCount: jest.fn(),
+ persistQuestionnaireResult: jest.fn(),
+ uploadQuestionnaireFile: jest.fn(),
+ saveGeneratedAnswer: jest.fn(),
+}));
+
+import { db } from '@db';
+import { syncManualAnswerToVector } from '@/vector-store/lib';
+import { answerQuestion } from '@/trigger/questionnaire/answer-question';
+import {
+ updateAnsweredCount,
+ persistQuestionnaireResult,
+ uploadQuestionnaireFile,
+ saveGeneratedAnswer,
+} from './utils/questionnaire-storage';
+import { extractQuestionsWithAI } from './utils/content-extractor';
+import { generateExportFile } from './utils/export-generator';
+
+const mockDb = db as jest.Mocked;
+
+describe('QuestionnaireService', () => {
+ let service: QuestionnaireService;
+
+ beforeEach(async () => {
+ const module: TestingModule = await Test.createTestingModule({
+ providers: [QuestionnaireService],
+ }).compile();
+
+ service = module.get(QuestionnaireService);
+
+ jest.clearAllMocks();
+ });
+
+ describe('findAll', () => {
+ it('should return questionnaires filtered by org and status', async () => {
+ const mockQuestionnaires = [
+ {
+ id: 'q1',
+ filename: 'test.pdf',
+ fileType: 'application/pdf',
+ status: 'completed',
+ totalQuestions: 5,
+ answeredQuestions: 3,
+ source: 'internal',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ questions: [{ id: 'qa1', question: 'Q1?', answer: 'A1' }],
+ },
+ ];
+ (mockDb.questionnaire.findMany as jest.Mock).mockResolvedValue(
+ mockQuestionnaires,
+ );
+
+ const result = await service.findAll('org_1');
+
+ expect(result).toEqual(mockQuestionnaires);
+ expect(mockDb.questionnaire.findMany).toHaveBeenCalledWith({
+ where: {
+ organizationId: 'org_1',
+ status: { in: ['completed', 'parsing'] },
+ },
+ select: {
+ id: true,
+ filename: true,
+ fileType: true,
+ status: true,
+ totalQuestions: true,
+ answeredQuestions: true,
+ source: true,
+ createdAt: true,
+ updatedAt: true,
+ questions: {
+ orderBy: { questionIndex: 'asc' },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ status: true,
+ questionIndex: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+ });
+
+ it('should return empty array when no questionnaires exist', async () => {
+ (mockDb.questionnaire.findMany as jest.Mock).mockResolvedValue([]);
+
+ const result = await service.findAll('org_1');
+
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('findById', () => {
+ it('should return questionnaire with questions', async () => {
+ const mockQuestionnaire = {
+ id: 'q1',
+ filename: 'test.pdf',
+ questions: [
+ {
+ id: 'qa1',
+ question: 'Q1?',
+ answer: 'A1',
+ status: 'manual',
+ questionIndex: 0,
+ sources: null,
+ },
+ ],
+ };
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(
+ mockQuestionnaire,
+ );
+
+ const result = await service.findById('q1', 'org_1');
+
+ expect(result).toEqual(mockQuestionnaire);
+ expect(mockDb.questionnaire.findUnique).toHaveBeenCalledWith({
+ where: { id: 'q1', organizationId: 'org_1' },
+ include: {
+ questions: {
+ orderBy: { questionIndex: 'asc' },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ status: true,
+ questionIndex: true,
+ sources: true,
+ },
+ },
+ },
+ });
+ });
+
+ it('should return null when questionnaire not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(null);
+
+ const result = await service.findById('missing', 'org_1');
+
+ expect(result).toBeNull();
+ });
+ });
+
+ describe('deleteById', () => {
+ it('should delete questionnaire and return success', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ organizationId: 'org_1',
+ });
+ (mockDb.questionnaire.delete as jest.Mock).mockResolvedValue({});
+
+ const result = await service.deleteById('q1', 'org_1');
+
+ expect(result).toEqual({ success: true });
+ expect(mockDb.questionnaire.findUnique).toHaveBeenCalledWith({
+ where: { id: 'q1', organizationId: 'org_1' },
+ });
+ expect(mockDb.questionnaire.delete).toHaveBeenCalledWith({
+ where: { id: 'q1' },
+ });
+ });
+
+ it('should throw when questionnaire not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(null);
+
+ await expect(service.deleteById('missing', 'org_1')).rejects.toThrow(
+ 'Questionnaire not found',
+ );
+ expect(mockDb.questionnaire.delete).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('saveAnswer', () => {
+ const baseSaveDto = {
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ questionIndex: 0,
+ answer: 'Yes, we do.',
+ status: 'manual' as const,
+ };
+
+ it('should return error when neither questionIndex nor questionAnswerId provided', async () => {
+ const result = await service.saveAnswer({
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ answer: 'Yes',
+ status: 'manual',
+ } as any);
+
+ expect(result).toEqual({
+ success: false,
+ error: 'questionIndex or questionAnswerId is required',
+ });
+ });
+
+ it('should return error when questionnaire not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(null);
+
+ const result = await service.saveAnswer(baseSaveDto as any);
+
+ expect(result).toEqual({
+ success: false,
+ error: 'Questionnaire not found',
+ });
+ });
+
+ it('should return error when question answer not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ questions: [],
+ });
+ (
+ mockDb.questionnaireQuestionAnswer.findFirst as jest.Mock
+ ).mockResolvedValue(null);
+
+ const result = await service.saveAnswer(baseSaveDto as any);
+
+ expect(result).toEqual({
+ success: false,
+ error: 'Question answer not found',
+ });
+ });
+
+ it('should save manual answer and sync to vector DB', async () => {
+ const existingQuestion = {
+ id: 'qa1',
+ question: 'Do you have a policy?',
+ questionIndex: 0,
+ };
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ questions: [existingQuestion],
+ });
+ (
+ mockDb.questionnaireQuestionAnswer.update as jest.Mock
+ ).mockResolvedValue({});
+ (
+ mockDb.securityQuestionnaireManualAnswer.upsert as jest.Mock
+ ).mockResolvedValue({ id: 'ma1' });
+ (syncManualAnswerToVector as jest.Mock).mockResolvedValue(undefined);
+ (updateAnsweredCount as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await service.saveAnswer(baseSaveDto as any);
+
+ expect(result).toEqual({ success: true });
+ expect(
+ mockDb.questionnaireQuestionAnswer.update,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 'qa1' },
+ data: expect.objectContaining({
+ answer: 'Yes, we do.',
+ status: 'manual',
+ }),
+ }),
+ );
+ expect(
+ mockDb.securityQuestionnaireManualAnswer.upsert,
+ ).toHaveBeenCalled();
+ expect(syncManualAnswerToVector).toHaveBeenCalledWith('ma1', 'org_1');
+ expect(updateAnsweredCount).toHaveBeenCalledWith('q1');
+ });
+
+ it('should not sync to vector DB for generated answers', async () => {
+ const existingQuestion = {
+ id: 'qa1',
+ question: 'Do you have a policy?',
+ questionIndex: 0,
+ };
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ questions: [existingQuestion],
+ });
+ (
+ mockDb.questionnaireQuestionAnswer.update as jest.Mock
+ ).mockResolvedValue({});
+ (updateAnsweredCount as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await service.saveAnswer({
+ ...baseSaveDto,
+ status: 'generated',
+ } as any);
+
+ expect(result).toEqual({ success: true });
+ expect(
+ mockDb.securityQuestionnaireManualAnswer.upsert,
+ ).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('deleteAnswer', () => {
+ it('should return error when questionnaire not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(null);
+
+ const result = await service.deleteAnswer({
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ questionAnswerId: 'qa1',
+ } as any);
+
+ expect(result).toEqual({
+ success: false,
+ error: 'Questionnaire not found',
+ });
+ });
+
+ it('should return error when question answer not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ });
+ (
+ mockDb.questionnaireQuestionAnswer.findUnique as jest.Mock
+ ).mockResolvedValue(null);
+
+ const result = await service.deleteAnswer({
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ questionAnswerId: 'qa1',
+ } as any);
+
+ expect(result).toEqual({
+ success: false,
+ error: 'Question answer not found',
+ });
+ });
+
+ it('should clear answer and update count', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ });
+ (
+ mockDb.questionnaireQuestionAnswer.findUnique as jest.Mock
+ ).mockResolvedValue({ id: 'qa1' });
+ (
+ mockDb.questionnaireQuestionAnswer.update as jest.Mock
+ ).mockResolvedValue({});
+ (updateAnsweredCount as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await service.deleteAnswer({
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ questionAnswerId: 'qa1',
+ } as any);
+
+ expect(result).toEqual({ success: true });
+ expect(
+ mockDb.questionnaireQuestionAnswer.update,
+ ).toHaveBeenCalledWith({
+ where: { id: 'qa1' },
+ data: expect.objectContaining({
+ answer: null,
+ status: 'untouched',
+ }),
+ });
+ expect(updateAnsweredCount).toHaveBeenCalledWith('q1');
+ });
+ });
+
+ describe('exportById', () => {
+ it('should export questionnaire in requested format', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue({
+ id: 'q1',
+ filename: 'test.pdf',
+ questions: [
+ { question: 'Q1?', answer: 'A1', questionIndex: 0 },
+ { question: 'Q2?', answer: 'A2', questionIndex: 1 },
+ ],
+ });
+ const mockExport = {
+ fileBuffer: Buffer.from('data'),
+ mimeType: 'text/csv',
+ filename: 'test.csv',
+ };
+ (generateExportFile as jest.Mock).mockReturnValue(mockExport);
+
+ const result = await service.exportById({
+ questionnaireId: 'q1',
+ organizationId: 'org_1',
+ format: 'csv',
+ } as any);
+
+ expect(result).toEqual(mockExport);
+ expect(generateExportFile).toHaveBeenCalledWith(
+ [
+ { question: 'Q1?', answer: 'A1' },
+ { question: 'Q2?', answer: 'A2' },
+ ],
+ 'csv',
+ 'test.pdf',
+ );
+ });
+
+ it('should throw when questionnaire not found', async () => {
+ (mockDb.questionnaire.findUnique as jest.Mock).mockResolvedValue(null);
+
+ await expect(
+ service.exportById({
+ questionnaireId: 'missing',
+ organizationId: 'org_1',
+ format: 'csv',
+ } as any),
+ ).rejects.toThrow('Questionnaire not found');
+ });
+ });
+
+ describe('uploadAndParse', () => {
+ it('should upload file, parse questions, and persist', async () => {
+ (uploadQuestionnaireFile as jest.Mock).mockResolvedValue({
+ s3Key: 'key',
+ fileSize: 1024,
+ });
+ (extractQuestionsWithAI as jest.Mock).mockResolvedValue([
+ { question: 'Q1?', answer: null },
+ { question: 'Q2?', answer: null },
+ ]);
+ (persistQuestionnaireResult as jest.Mock).mockResolvedValue('q1');
+
+ const result = await service.uploadAndParse({
+ organizationId: 'org_1',
+ fileName: 'test.pdf',
+ fileType: 'application/pdf',
+ fileData: 'base64data',
+ source: 'internal',
+ } as any);
+
+ expect(result).toEqual({ questionnaireId: 'q1', totalQuestions: 2 });
+ expect(uploadQuestionnaireFile).toHaveBeenCalled();
+ expect(extractQuestionsWithAI).toHaveBeenCalledWith(
+ 'base64data',
+ 'application/pdf',
+ expect.any(Object),
+ );
+ expect(persistQuestionnaireResult).toHaveBeenCalled();
+ });
+
+ it('should throw when persist returns null', async () => {
+ (uploadQuestionnaireFile as jest.Mock).mockResolvedValue({
+ s3Key: 'key',
+ fileSize: 1024,
+ });
+ (extractQuestionsWithAI as jest.Mock).mockResolvedValue([]);
+ (persistQuestionnaireResult as jest.Mock).mockResolvedValue(null);
+
+ await expect(
+ service.uploadAndParse({
+ organizationId: 'org_1',
+ fileName: 'test.pdf',
+ fileType: 'application/pdf',
+ fileData: 'base64data',
+ } as any),
+ ).rejects.toThrow('Failed to save questionnaire');
+ });
+ });
+
+ describe('answerSingleQuestion', () => {
+ it('should answer question and save result when questionnaireId provided', async () => {
+ (answerQuestion as jest.Mock).mockResolvedValue({
+ success: true,
+ questionIndex: 0,
+ question: 'Q1?',
+ answer: 'A1',
+ sources: [],
+ });
+ (saveGeneratedAnswer as jest.Mock).mockResolvedValue(undefined);
+
+ const result = await service.answerSingleQuestion({
+ question: 'Q1?',
+ organizationId: 'org_1',
+ questionIndex: 0,
+ totalQuestions: 5,
+ questionnaireId: 'q1',
+ } as any);
+
+ expect(result.success).toBe(true);
+ expect(result.answer).toBe('A1');
+ expect(saveGeneratedAnswer).toHaveBeenCalledWith({
+ questionnaireId: 'q1',
+ questionIndex: 0,
+ answer: 'A1',
+ sources: [],
+ });
+ });
+
+ it('should not save answer when no questionnaireId', async () => {
+ (answerQuestion as jest.Mock).mockResolvedValue({
+ success: true,
+ questionIndex: 0,
+ question: 'Q1?',
+ answer: 'A1',
+ sources: [],
+ });
+
+ const result = await service.answerSingleQuestion({
+ question: 'Q1?',
+ organizationId: 'org_1',
+ questionIndex: 0,
+ totalQuestions: 5,
+ } as any);
+
+ expect(result.success).toBe(true);
+ expect(saveGeneratedAnswer).not.toHaveBeenCalled();
+ });
+
+ it('should not save answer when answer generation failed', async () => {
+ (answerQuestion as jest.Mock).mockResolvedValue({
+ success: false,
+ questionIndex: 0,
+ question: 'Q1?',
+ answer: null,
+ sources: [],
+ });
+
+ const result = await service.answerSingleQuestion({
+ question: 'Q1?',
+ organizationId: 'org_1',
+ questionIndex: 0,
+ totalQuestions: 5,
+ questionnaireId: 'q1',
+ } as any);
+
+ expect(result.success).toBe(false);
+ expect(saveGeneratedAnswer).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/api/src/questionnaire/questionnaire.service.ts b/apps/api/src/questionnaire/questionnaire.service.ts
index 8b64920ce2..5ffaf07196 100644
--- a/apps/api/src/questionnaire/questionnaire.service.ts
+++ b/apps/api/src/questionnaire/questionnaire.service.ts
@@ -1,4 +1,4 @@
-import { Injectable, Logger } from '@nestjs/common';
+import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import type { AnswerQuestionResult } from '@/trigger/questionnaire/answer-question';
import { answerQuestion } from '@/trigger/questionnaire/answer-question';
import { generateAnswerWithRAGBatch } from '@/trigger/questionnaire/answer-question-helpers';
@@ -494,6 +494,70 @@ export class QuestionnaireService {
await saveGeneratedAnswer(params);
}
+ async findAll(organizationId: string) {
+ return db.questionnaire.findMany({
+ where: {
+ organizationId,
+ status: { in: ['completed', 'parsing'] },
+ },
+ select: {
+ id: true,
+ filename: true,
+ fileType: true,
+ status: true,
+ totalQuestions: true,
+ answeredQuestions: true,
+ source: true,
+ createdAt: true,
+ updatedAt: true,
+ questions: {
+ orderBy: { questionIndex: 'asc' },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ status: true,
+ questionIndex: true,
+ },
+ },
+ },
+ orderBy: { createdAt: 'desc' },
+ });
+ }
+
+ async findById(id: string, organizationId: string) {
+ return db.questionnaire.findUnique({
+ where: { id, organizationId },
+ include: {
+ questions: {
+ orderBy: { questionIndex: 'asc' },
+ select: {
+ id: true,
+ question: true,
+ answer: true,
+ status: true,
+ questionIndex: true,
+ sources: true,
+ },
+ },
+ },
+ });
+ }
+
+ async deleteById(id: string, organizationId: string) {
+ const questionnaire = await db.questionnaire.findUnique({
+ where: { id, organizationId },
+ });
+
+ if (!questionnaire) {
+ throw new NotFoundException(`Questionnaire with ID ${id} not found`);
+ }
+
+ await db.questionnaire.delete({ where: { id } });
+
+ return { success: true };
+ }
+
// Private helper methods
private async generateAnswersForQuestions(
diff --git a/apps/api/src/risks/dto/get-risks-query.dto.ts b/apps/api/src/risks/dto/get-risks-query.dto.ts
new file mode 100644
index 0000000000..cd79dc4d1b
--- /dev/null
+++ b/apps/api/src/risks/dto/get-risks-query.dto.ts
@@ -0,0 +1,113 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import {
+ IsEnum,
+ IsInt,
+ IsOptional,
+ IsString,
+ Max,
+ Min,
+} from 'class-validator';
+import { Type } from 'class-transformer';
+import {
+ RiskCategory,
+ Departments,
+ RiskStatus,
+} from '@trycompai/db';
+
+export enum RiskSortBy {
+ CREATED_AT = 'createdAt',
+ UPDATED_AT = 'updatedAt',
+ TITLE = 'title',
+ STATUS = 'status',
+}
+
+export enum RiskSortOrder {
+ ASC = 'asc',
+ DESC = 'desc',
+}
+
+export class GetRisksQueryDto {
+ @ApiPropertyOptional({
+ description: 'Search by title (case-insensitive contains)',
+ example: 'data breach',
+ })
+ @IsOptional()
+ @IsString()
+ title?: string;
+
+ @ApiPropertyOptional({
+ description: 'Page number (1-indexed)',
+ example: 1,
+ default: 1,
+ minimum: 1,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ page?: number = 1;
+
+ @ApiPropertyOptional({
+ description: 'Number of items per page',
+ example: 50,
+ default: 50,
+ minimum: 1,
+ maximum: 250,
+ })
+ @IsOptional()
+ @Type(() => Number)
+ @IsInt()
+ @Min(1)
+ @Max(250)
+ perPage?: number = 50;
+
+ @ApiPropertyOptional({
+ description: 'Sort by field',
+ enum: RiskSortBy,
+ default: RiskSortBy.CREATED_AT,
+ })
+ @IsOptional()
+ @IsEnum(RiskSortBy)
+ sort?: RiskSortBy = RiskSortBy.CREATED_AT;
+
+ @ApiPropertyOptional({
+ description: 'Sort direction',
+ enum: RiskSortOrder,
+ default: RiskSortOrder.DESC,
+ })
+ @IsOptional()
+ @IsEnum(RiskSortOrder)
+ sortDirection?: RiskSortOrder = RiskSortOrder.DESC;
+
+ @ApiPropertyOptional({
+ description: 'Filter by status',
+ enum: RiskStatus,
+ })
+ @IsOptional()
+ @IsEnum(RiskStatus)
+ status?: RiskStatus;
+
+ @ApiPropertyOptional({
+ description: 'Filter by category',
+ enum: RiskCategory,
+ })
+ @IsOptional()
+ @IsEnum(RiskCategory)
+ category?: RiskCategory;
+
+ @ApiPropertyOptional({
+ description: 'Filter by department',
+ enum: Departments,
+ })
+ @IsOptional()
+ @IsEnum(Departments)
+ department?: Departments;
+
+ @ApiPropertyOptional({
+ description: 'Filter by assignee member ID',
+ example: 'mem_abc123def456',
+ })
+ @IsOptional()
+ @IsString()
+ assigneeId?: string;
+}
diff --git a/apps/api/src/risks/risks.controller.spec.ts b/apps/api/src/risks/risks.controller.spec.ts
new file mode 100644
index 0000000000..b5da081e30
--- /dev/null
+++ b/apps/api/src/risks/risks.controller.spec.ts
@@ -0,0 +1,434 @@
+import { Test, TestingModule } from '@nestjs/testing';
+import { ForbiddenException } from '@nestjs/common';
+import type { AuthContext } from '../auth/types';
+import { HybridAuthGuard } from '../auth/hybrid-auth.guard';
+import { PermissionGuard } from '../auth/permission.guard';
+import { RisksController } from './risks.controller';
+import { RisksService } from './risks.service';
+
+// Mock auth.server to avoid importing better-auth ESM in Jest
+jest.mock('../auth/auth.server', () => ({
+ auth: {
+ api: {
+ getSession: jest.fn(),
+ },
+ },
+}));
+
+jest.mock('@comp/auth', () => ({
+ statement: {
+ risk: ['create', 'read', 'update', 'delete'],
+ },
+ BUILT_IN_ROLE_PERMISSIONS: {},
+}));
+
+jest.mock('../utils/assignment-filter', () => ({
+ buildRiskAssignmentFilter: jest.fn().mockReturnValue({}),
+ hasRiskAccess: jest.fn().mockReturnValue(true),
+}));
+
+import {
+ buildRiskAssignmentFilter,
+ hasRiskAccess,
+} from '../utils/assignment-filter';
+
+const mockBuildRiskAssignmentFilter =
+ buildRiskAssignmentFilter as jest.MockedFunction<
+ typeof buildRiskAssignmentFilter
+ >;
+const mockHasRiskAccess = hasRiskAccess as jest.MockedFunction<
+ typeof hasRiskAccess
+>;
+
+describe('RisksController', () => {
+ let controller: RisksController;
+ let risksService: jest.Mocked;
+
+ const orgId = 'org_test123';
+
+ const authContext: AuthContext = {
+ organizationId: orgId,
+ authType: 'session',
+ isApiKey: false,
+ isPlatformAdmin: false,
+ userRoles: ['admin'],
+ userId: 'usr_123',
+ userEmail: 'admin@example.com',
+ memberId: 'mem_123',
+ };
+
+ const authContextNoUser: AuthContext = {
+ organizationId: orgId,
+ authType: 'apikey',
+ isApiKey: true,
+ isPlatformAdmin: false,
+ userRoles: ['admin'],
+ userId: undefined,
+ userEmail: undefined,
+ memberId: undefined,
+ };
+
+ const mockRisk = {
+ id: 'risk_1',
+ title: 'Test Risk',
+ description: 'A test risk',
+ status: 'open',
+ category: 'operational',
+ department: 'engineering',
+ organizationId: orgId,
+ assigneeId: 'mem_123',
+ createdAt: new Date(),
+ updatedAt: new Date(),
+ };
+
+ beforeEach(async () => {
+ const mockService = {
+ findAllByOrganization: jest.fn(),
+ findById: jest.fn(),
+ create: jest.fn(),
+ updateById: jest.fn(),
+ deleteById: jest.fn(),
+ getStatsByAssignee: jest.fn(),
+ getStatsByDepartment: jest.fn(),
+ };
+
+ const module: TestingModule = await Test.createTestingModule({
+ controllers: [RisksController],
+ providers: [{ provide: RisksService, useValue: mockService }],
+ })
+ .overrideGuard(HybridAuthGuard)
+ .useValue({ canActivate: () => true })
+ .overrideGuard(PermissionGuard)
+ .useValue({ canActivate: () => true })
+ .compile();
+
+ controller = module.get(RisksController);
+ risksService = module.get(RisksService) as jest.Mocked;
+
+ jest.clearAllMocks();
+ mockBuildRiskAssignmentFilter.mockReturnValue({});
+ mockHasRiskAccess.mockReturnValue(true);
+ });
+
+ describe('getAllRisks', () => {
+ const paginatedResult = {
+ data: [mockRisk],
+ totalCount: 1,
+ page: 1,
+ pageCount: 1,
+ };
+
+ it('should call findAllByOrganization with correct parameters', async () => {
+ risksService.findAllByOrganization.mockResolvedValue(paginatedResult);
+ const query = { page: 1, perPage: 10 };
+
+ await controller.getAllRisks(query, orgId, authContext);
+
+ expect(mockBuildRiskAssignmentFilter).toHaveBeenCalledWith(
+ authContext.memberId,
+ authContext.userRoles,
+ { isApiKey: false },
+ );
+ expect(risksService.findAllByOrganization).toHaveBeenCalledWith(
+ orgId,
+ {},
+ query,
+ );
+ });
+
+ it('should return paginated data with auth info', async () => {
+ risksService.findAllByOrganization.mockResolvedValue(paginatedResult);
+
+ const result = await controller.getAllRisks({}, orgId, authContext);
+
+ expect(result).toEqual({
+ data: paginatedResult.data,
+ totalCount: 1,
+ page: 1,
+ pageCount: 1,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+
+ it('should omit authenticatedUser when userId is not present', async () => {
+ risksService.findAllByOrganization.mockResolvedValue(paginatedResult);
+
+ const result = await controller.getAllRisks({}, orgId, authContextNoUser);
+
+ expect(result.authType).toBe('apikey');
+ expect(result).not.toHaveProperty('authenticatedUser');
+ });
+
+ it('should pass assignment filter from buildRiskAssignmentFilter', async () => {
+ const assignmentFilter = { assigneeId: 'mem_123' };
+ mockBuildRiskAssignmentFilter.mockReturnValue(assignmentFilter);
+ risksService.findAllByOrganization.mockResolvedValue(paginatedResult);
+
+ await controller.getAllRisks({}, orgId, authContext);
+
+ expect(risksService.findAllByOrganization).toHaveBeenCalledWith(
+ orgId,
+ assignmentFilter,
+ {},
+ );
+ });
+ });
+
+ describe('getStatsByAssignee', () => {
+ const statsData = [
+ {
+ id: 'mem_1',
+ user: { name: 'User 1', image: null, email: 'u1@test.com' },
+ totalRisks: 3,
+ openRisks: 1,
+ pendingRisks: 1,
+ closedRisks: 1,
+ archivedRisks: 0,
+ },
+ ];
+
+ it('should call getStatsByAssignee with organizationId', async () => {
+ risksService.getStatsByAssignee.mockResolvedValue(statsData);
+
+ await controller.getStatsByAssignee(orgId, authContext);
+
+ expect(risksService.getStatsByAssignee).toHaveBeenCalledWith(orgId);
+ });
+
+ it('should return data with auth info', async () => {
+ risksService.getStatsByAssignee.mockResolvedValue(statsData);
+
+ const result = await controller.getStatsByAssignee(orgId, authContext);
+
+ expect(result).toEqual({
+ data: statsData,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+
+ it('should omit authenticatedUser for API key auth', async () => {
+ risksService.getStatsByAssignee.mockResolvedValue(statsData);
+
+ const result = await controller.getStatsByAssignee(
+ orgId,
+ authContextNoUser,
+ );
+
+ expect(result).not.toHaveProperty('authenticatedUser');
+ });
+ });
+
+ describe('getStatsByDepartment', () => {
+ const deptStats = [
+ { department: 'engineering', _count: 5 },
+ { department: 'finance', _count: 3 },
+ ];
+
+ it('should call getStatsByDepartment with organizationId', async () => {
+ risksService.getStatsByDepartment.mockResolvedValue(deptStats);
+
+ await controller.getStatsByDepartment(orgId, authContext);
+
+ expect(risksService.getStatsByDepartment).toHaveBeenCalledWith(orgId);
+ });
+
+ it('should return data with auth info', async () => {
+ risksService.getStatsByDepartment.mockResolvedValue(deptStats);
+
+ const result = await controller.getStatsByDepartment(orgId, authContext);
+
+ expect(result).toEqual({
+ data: deptStats,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+ });
+
+ describe('getRiskById', () => {
+ it('should call findById with correct parameters', async () => {
+ risksService.findById.mockResolvedValue(mockRisk);
+
+ await controller.getRiskById('risk_1', orgId, authContext);
+
+ expect(risksService.findById).toHaveBeenCalledWith('risk_1', orgId);
+ });
+
+ it('should return risk with auth info', async () => {
+ risksService.findById.mockResolvedValue(mockRisk);
+
+ const result = await controller.getRiskById('risk_1', orgId, authContext);
+
+ expect(result).toEqual({
+ ...mockRisk,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+
+ it('should check hasRiskAccess and throw ForbiddenException if denied', async () => {
+ risksService.findById.mockResolvedValue(mockRisk);
+ mockHasRiskAccess.mockReturnValue(false);
+
+ await expect(
+ controller.getRiskById('risk_1', orgId, authContext),
+ ).rejects.toThrow(ForbiddenException);
+
+ expect(mockHasRiskAccess).toHaveBeenCalledWith(
+ mockRisk,
+ authContext.memberId,
+ authContext.userRoles,
+ { isApiKey: false },
+ );
+ });
+
+ it('should pass isApiKey option to hasRiskAccess', async () => {
+ risksService.findById.mockResolvedValue(mockRisk);
+
+ await controller.getRiskById('risk_1', orgId, authContextNoUser);
+
+ expect(mockHasRiskAccess).toHaveBeenCalledWith(
+ mockRisk,
+ authContextNoUser.memberId,
+ authContextNoUser.userRoles,
+ { isApiKey: true },
+ );
+ });
+ });
+
+ describe('createRisk', () => {
+ const createDto = {
+ title: 'New Risk',
+ description: 'Description',
+ };
+
+ it('should call create with organizationId and dto', async () => {
+ risksService.create.mockResolvedValue(mockRisk);
+
+ await controller.createRisk(createDto, orgId, authContext);
+
+ expect(risksService.create).toHaveBeenCalledWith(orgId, createDto);
+ });
+
+ it('should return created risk with auth info', async () => {
+ risksService.create.mockResolvedValue(mockRisk);
+
+ const result = await controller.createRisk(createDto, orgId, authContext);
+
+ expect(result).toEqual({
+ ...mockRisk,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+
+ it('should omit authenticatedUser for API key auth', async () => {
+ risksService.create.mockResolvedValue(mockRisk);
+
+ const result = await controller.createRisk(
+ createDto,
+ orgId,
+ authContextNoUser,
+ );
+
+ expect(result).not.toHaveProperty('authenticatedUser');
+ expect(result.authType).toBe('apikey');
+ });
+ });
+
+ describe('updateRisk', () => {
+ const updateDto = { title: 'Updated Risk' };
+ const updatedRisk = { ...mockRisk, title: 'Updated Risk' };
+
+ it('should call updateById with correct parameters', async () => {
+ risksService.updateById.mockResolvedValue(updatedRisk);
+
+ await controller.updateRisk('risk_1', updateDto, orgId, authContext);
+
+ expect(risksService.updateById).toHaveBeenCalledWith(
+ 'risk_1',
+ orgId,
+ updateDto,
+ );
+ });
+
+ it('should return updated risk with auth info', async () => {
+ risksService.updateById.mockResolvedValue(updatedRisk);
+
+ const result = await controller.updateRisk(
+ 'risk_1',
+ updateDto,
+ orgId,
+ authContext,
+ );
+
+ expect(result).toEqual({
+ ...updatedRisk,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+ });
+
+ describe('deleteRisk', () => {
+ const deleteResult = {
+ message: 'Risk deleted successfully',
+ deletedRisk: { id: 'risk_1', title: 'Test Risk' },
+ };
+
+ it('should call deleteById with correct parameters', async () => {
+ risksService.deleteById.mockResolvedValue(deleteResult);
+
+ await controller.deleteRisk('risk_1', orgId, authContext);
+
+ expect(risksService.deleteById).toHaveBeenCalledWith('risk_1', orgId);
+ });
+
+ it('should return delete result with auth info', async () => {
+ risksService.deleteById.mockResolvedValue(deleteResult);
+
+ const result = await controller.deleteRisk('risk_1', orgId, authContext);
+
+ expect(result).toEqual({
+ ...deleteResult,
+ authType: 'session',
+ authenticatedUser: {
+ id: 'usr_123',
+ email: 'admin@example.com',
+ },
+ });
+ });
+
+ it('should omit authenticatedUser for API key auth', async () => {
+ risksService.deleteById.mockResolvedValue(deleteResult);
+
+ const result = await controller.deleteRisk(
+ 'risk_1',
+ orgId,
+ authContextNoUser,
+ );
+
+ expect(result).not.toHaveProperty('authenticatedUser');
+ });
+ });
+});
diff --git a/apps/api/src/risks/risks.controller.ts b/apps/api/src/risks/risks.controller.ts
index 28afa46c00..d0f7696d9f 100644
--- a/apps/api/src/risks/risks.controller.ts
+++ b/apps/api/src/risks/risks.controller.ts
@@ -6,11 +6,12 @@ import {
Delete,
Body,
Param,
+ Query,
UseGuards,
+ ForbiddenException,
} from '@nestjs/common';
import {
ApiBody,
- ApiHeader,
ApiOperation,
ApiParam,
ApiResponse,
@@ -19,8 +20,15 @@ import {
} from '@nestjs/swagger';
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 {
+ buildRiskAssignmentFilter,
+ hasRiskAccess,
+} from '../utils/assignment-filter';
import { CreateRiskDto } from './dto/create-risk.dto';
+import { GetRisksQueryDto } from './dto/get-risks-query.dto';
import { UpdateRiskDto } from './dto/update-risk.dto';
import { RisksService } from './risks.service';
import { RISK_OPERATIONS } from './schemas/risk-operations';
@@ -36,30 +44,86 @@ import { DELETE_RISK_RESPONSES } from './schemas/delete-risk.responses';
@Controller({ path: 'risks', version: '1' })
@UseGuards(HybridAuthGuard)
@ApiSecurity('apikey')
-@ApiHeader({
- name: 'X-Organization-Id',
- description:
- 'Organization ID (required for session auth, optional for API key auth)',
- required: false,
-})
export class RisksController {
constructor(private readonly risksService: RisksService) {}
@Get()
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'read')
@ApiOperation(RISK_OPERATIONS.getAllRisks)
@ApiResponse(GET_ALL_RISKS_RESPONSES[200])
@ApiResponse(GET_ALL_RISKS_RESPONSES[401])
@ApiResponse(GET_ALL_RISKS_RESPONSES[404])
@ApiResponse(GET_ALL_RISKS_RESPONSES[500])
async getAllRisks(
+ @Query() query: GetRisksQueryDto,
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ // Build assignment filter for restricted roles (employee/contractor)
+ const assignmentFilter = buildRiskAssignmentFilter(
+ authContext.memberId,
+ authContext.userRoles,
+ { isApiKey: authContext.isApiKey },
+ );
+
+ const result = await this.risksService.findAllByOrganization(
+ organizationId,
+ assignmentFilter,
+ query,
+ );
+
+ return {
+ data: result.data,
+ totalCount: result.totalCount,
+ page: result.page,
+ pageCount: result.pageCount,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Get('stats/by-assignee')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'read')
+ @ApiOperation({ summary: 'Get risk statistics grouped by assignee' })
+ async getStatsByAssignee(
@OrganizationId() organizationId: string,
@AuthContext() authContext: AuthContextType,
) {
- const risks = await this.risksService.findAllByOrganization(organizationId);
+ const data = await this.risksService.getStatsByAssignee(organizationId);
return {
- data: risks,
- count: risks.length,
+ data,
+ authType: authContext.authType,
+ ...(authContext.userId &&
+ authContext.userEmail && {
+ authenticatedUser: {
+ id: authContext.userId,
+ email: authContext.userEmail,
+ },
+ }),
+ };
+ }
+
+ @Get('stats/by-department')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'read')
+ @ApiOperation({ summary: 'Get risk counts grouped by department' })
+ async getStatsByDepartment(
+ @OrganizationId() organizationId: string,
+ @AuthContext() authContext: AuthContextType,
+ ) {
+ const data = await this.risksService.getStatsByDepartment(organizationId);
+
+ return {
+ data,
authType: authContext.authType,
...(authContext.userId &&
authContext.userEmail && {
@@ -72,10 +136,13 @@ export class RisksController {
}
@Get(':id')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'read')
@ApiOperation(RISK_OPERATIONS.getRiskById)
@ApiParam(RISK_PARAMS.riskId)
@ApiResponse(GET_RISK_BY_ID_RESPONSES[200])
@ApiResponse(GET_RISK_BY_ID_RESPONSES[401])
+ @ApiResponse(GET_RISK_BY_ID_RESPONSES[403])
@ApiResponse(GET_RISK_BY_ID_RESPONSES[404])
@ApiResponse(GET_RISK_BY_ID_RESPONSES[500])
async getRiskById(
@@ -85,6 +152,11 @@ export class RisksController {
) {
const risk = await this.risksService.findById(riskId, organizationId);
+ // Check assignment access for restricted roles
+ if (!hasRiskAccess(risk, authContext.memberId, authContext.userRoles, { isApiKey: authContext.isApiKey })) {
+ throw new ForbiddenException('You do not have access to this risk');
+ }
+
return {
...risk,
authType: authContext.authType,
@@ -99,6 +171,8 @@ export class RisksController {
}
@Post()
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'create')
@ApiOperation(RISK_OPERATIONS.createRisk)
@ApiBody(RISK_BODIES.createRisk)
@ApiResponse(CREATE_RISK_RESPONSES[201])
@@ -127,6 +201,8 @@ export class RisksController {
}
@Patch(':id')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'update')
@ApiOperation(RISK_OPERATIONS.updateRisk)
@ApiParam(RISK_PARAMS.riskId)
@ApiBody(RISK_BODIES.updateRisk)
@@ -161,6 +237,8 @@ export class RisksController {
}
@Delete(':id')
+ @UseGuards(PermissionGuard)
+ @RequirePermission('risk', 'delete')
@ApiOperation(RISK_OPERATIONS.deleteRisk)
@ApiParam(RISK_PARAMS.riskId)
@ApiResponse(DELETE_RISK_RESPONSES[200])
diff --git a/apps/api/src/risks/risks.service.ts b/apps/api/src/risks/risks.service.ts
index 74cdd8beac..0fb6faf37d 100644
--- a/apps/api/src/risks/risks.service.ts
+++ b/apps/api/src/risks/risks.service.ts
@@ -1,37 +1,101 @@
-import { Injectable, NotFoundException, Logger } from '@nestjs/common';
-import { db } from '@trycompai/db';
+import { BadRequestException, Injectable, NotFoundException, Logger } from '@nestjs/common';
+import { db, Prisma } from '@trycompai/db';
import { CreateRiskDto } from './dto/create-risk.dto';
+import { GetRisksQueryDto } from './dto/get-risks-query.dto';
import { UpdateRiskDto } from './dto/update-risk.dto';
+export interface PaginatedRisksResult {
+ data: Prisma.RiskGetPayload<{
+ include: {
+ assignee: {
+ include: {
+ user: {
+ select: { id: true; name: true; email: true; image: true };
+ };
+ };
+ };
+ };
+ }>[];
+ totalCount: number;
+ page: number;
+ pageCount: number;
+}
+
@Injectable()
export class RisksService {
private readonly logger = new Logger(RisksService.name);
- async findAllByOrganization(organizationId: string) {
+ private async validateAssigneeNotPlatformAdmin(assigneeId: string, organizationId: string) {
+ const member = await db.member.findFirst({
+ where: { id: assigneeId, organizationId },
+ include: { user: { select: { isPlatformAdmin: true } } },
+ });
+ if (member?.user.isPlatformAdmin) {
+ throw new BadRequestException('Cannot assign a platform admin as assignee');
+ }
+ }
+
+ async findAllByOrganization(
+ organizationId: string,
+ assignmentFilter: Prisma.RiskWhereInput = {},
+ query: GetRisksQueryDto = {},
+ ): Promise {
+ const {
+ title,
+ page = 1,
+ perPage = 50,
+ sort = 'createdAt',
+ sortDirection = 'desc',
+ status,
+ category,
+ department,
+ assigneeId,
+ } = query;
+
try {
- const risks = await db.risk.findMany({
- where: { organizationId },
- orderBy: { createdAt: 'desc' },
- include: {
- assignee: {
- include: {
- user: {
- select: {
- id: true,
- name: true,
- email: true,
- image: true,
+ const where: Prisma.RiskWhereInput = {
+ organizationId,
+ ...assignmentFilter,
+ ...(title && {
+ title: { contains: title, mode: Prisma.QueryMode.insensitive },
+ }),
+ ...(status && { status }),
+ ...(category && { category }),
+ ...(department && { department }),
+ ...(assigneeId && { assigneeId }),
+ };
+
+ const [risks, totalCount] = await Promise.all([
+ db.risk.findMany({
+ where,
+ skip: (page - 1) * perPage,
+ take: perPage,
+ orderBy: { [sort]: sortDirection },
+ include: {
+ assignee: {
+ include: {
+ user: {
+ select: {
+ id: true,
+ name: true,
+ email: true,
+ image: true,
+ },
},
},
},
},
- },
- });
+ }),
+ db.risk.count({ where }),
+ ]);
+
+ const pageCount = Math.ceil(totalCount / perPage);
this.logger.log(
- `Retrieved ${risks.length} risks for organization ${organizationId}`,
+ `Retrieved ${risks.length} risks (page ${page}/${pageCount}) for organization ${organizationId}`,
);
- return risks;
+
+ return { data: risks, totalCount, page, pageCount };
} catch (error) {
this.logger.error(
`Failed to retrieve risks for organization ${organizationId}:`,
@@ -76,6 +140,9 @@ export class RisksService {
async create(organizationId: string, createRiskDto: CreateRiskDto) {
try {
+ if (createRiskDto.assigneeId) {
+ await this.validateAssigneeNotPlatformAdmin(createRiskDto.assigneeId, organizationId);
+ }
const risk = await db.risk.create({
data: {
...createRiskDto,
@@ -105,6 +172,10 @@ export class RisksService {
// First check if the risk exists in the organization
await this.findById(id, organizationId);
+ if (updateRiskDto.assigneeId) {
+ await this.validateAssigneeNotPlatformAdmin(updateRiskDto.assigneeId, organizationId);
+ }
+
const updatedRisk = await db.risk.update({
where: { id },
data: updateRiskDto,
@@ -146,4 +217,40 @@ export class RisksService {
throw error;
}
}
+
+ async getStatsByAssignee(organizationId: string) {
+ const members = await db.member.findMany({
+ where: { organizationId },
+ select: {
+ id: true,
+ risks: {
+ where: { organizationId },
+ select: { status: true },
+ },
+ user: {
+ select: { name: true, image: true, email: true },
+ },
+ },
+ });
+
+ return members
+ .filter((m) => m.risks.length > 0)
+ .map((m) => ({
+ id: m.id,
+ user: m.user,
+ totalRisks: m.risks.length,
+ openRisks: m.risks.filter((r) => r.status === 'open').length,
+ pendingRisks: m.risks.filter((r) => r.status === 'pending').length,
+ closedRisks: m.risks.filter((r) => r.status === 'closed').length,
+ archivedRisks: m.risks.filter((r) => r.status === 'archived').length,
+ }));
+ }
+
+ async getStatsByDepartment(organizationId: string) {
+ return db.risk.groupBy({
+ by: ['department'],
+ where: { organizationId },
+ _count: true,
+ });
+ }
}
diff --git a/apps/api/src/risks/schemas/get-risk-by-id.responses.ts b/apps/api/src/risks/schemas/get-risk-by-id.responses.ts
index 6870eb655d..b4b8ae5cf7 100644
--- a/apps/api/src/risks/schemas/get-risk-by-id.responses.ts
+++ b/apps/api/src/risks/schemas/get-risk-by-id.responses.ts
@@ -152,6 +152,23 @@ export const GET_RISK_BY_ID_RESPONSES: Record