diff --git a/.cursor/mcp.json b/.cursor/mcp.json index 43e26b028..8b88be494 100644 --- a/.cursor/mcp.json +++ b/.cursor/mcp.json @@ -2,11 +2,11 @@ "mcpServers": { "trigger": { "command": "npx", - "args": [ - "trigger.dev@latest", - "mcp", - "--dev-only" - ] + "args": ["trigger.dev@latest", "mcp", "--dev-only"] + }, + "trycompai": { + "command": "npx", + "args": ["-y", "@trycompai/design-system-mcp@latest"] } } -} \ No newline at end of file +} diff --git a/ENTERPRISE_API_AUTOMATION_VERSIONING.md b/ENTERPRISE_API_AUTOMATION_VERSIONING.md deleted file mode 100644 index 4b0a56c01..000000000 --- a/ENTERPRISE_API_AUTOMATION_VERSIONING.md +++ /dev/null @@ -1,184 +0,0 @@ -# Enterprise API - Automation Versioning Endpoints - -## Overview - -Implement versioning for automation scripts. The Next.js app handles database operations (storing version metadata), while the Enterprise API handles S3 operations (copying/managing script files) and Redis operations (chat history). - -## Context - -### Current S3 Structure - -- **Draft script**: `{orgId}/{taskId}/{automationId}.automation.js` -- Scripts are stored in S3 via the enterprise API - -### New S3 Structure for Versions - -- **Draft script**: `{orgId}/{taskId}/{automationId}.draft.js` -- **Published versions**: `{orgId}/{taskId}/{automationId}.v{version}.js` - -**Migration Note**: Existing scripts at `{automationId}.automation.js` should be moved to `{automationId}.draft.js` - -### Database (handled by Next.js app) - -- `EvidenceAutomationVersion` table stores version metadata -- Next.js app creates version records after enterprise API copies files - -## Endpoints to Implement - -### 1. Publish Draft Script - -**Endpoint**: `POST /api/tasks-automations/publish` - -**Purpose**: Create a new version by copying current draft script to a versioned S3 key. - -**Request Body**: - -```typescript -{ - orgId: string; - taskId: string; - automationId: string; -} -``` - -**Process**: - -1. Construct draft S3 key: `{orgId}/{taskId}/{automationId}.draft.js` -2. Check if draft script exists in S3 -3. If not found, return error: `{ success: false, error: 'No draft script found to publish' }` -4. Query database to get the next version number: - - Find highest existing version for this `automationId` - - Increment by 1 (or start at 1 if no versions exist) -5. Construct version S3 key: `{orgId}/{taskId}/{automationId}.v{nextVersion}.js` -6. Copy draft script to version key in S3 -7. Return success with the version number and scriptKey - -**Response**: - -```typescript -{ - success: boolean; - version?: number; // e.g., 1, 2, 3 - scriptKey?: string; // e.g., "org_xxx/tsk_xxx/aut_xxx.v1.js" - error?: string; -} -``` - -**Note**: Enterprise API determines the version number server-side by querying the database, not from client input. This prevents version conflicts. - -**Error Cases**: - -- Draft script not found in S3 -- S3 copy operation fails -- Invalid orgId/taskId/automationId - ---- - -### 2. Restore Version to Draft - -**Endpoint**: `POST /api/tasks-automations/restore-version` - -**Purpose**: Replace current draft script with a published version's script. Chat history is preserved. - -**Request Body**: - -```typescript -{ - orgId: string; - taskId: string; - automationId: string; - version: number; // Which version to restore (e.g., 1, 2, 3) -} -``` - -**Process**: - -1. Construct version S3 key: `{orgId}/{taskId}/{automationId}.v{version}.js` -2. Check if version script exists in S3 -3. If not found, return error: `{ success: false, error: 'Version not found' }` -4. Construct draft S3 key: `{orgId}/{taskId}/{automationId}.draft.js` -5. Copy version script to draft key in S3 (overwrites current draft) -6. Do NOT touch Redis chat history - it should persist -7. Return success - -**Response**: - -```typescript -{ - success: boolean; - error?: string; -} -``` - -**Error Cases**: - -- Version script not found in S3 -- S3 copy operation fails -- Invalid version number - ---- - -## Implementation Notes - -### S3 Operations - -- Use AWS S3 SDK's `copyObject` method to copy between keys -- Bucket name should come from environment variables -- Ensure proper error handling for S3 operations - -### Authentication - -- These endpoints should require authentication (API key or session) -- Validate that the user has access to the organization/task/automation - -### Redis Chat History - -- **Important**: Do NOT clear or modify chat history when restoring versions -- Chat history key format: `automation:{automationId}:chat` -- Chat history persists regardless of which version is in the draft - -### Example S3 Keys - -For automation `aut_68e6a70803cf925eac17896a` in task `tsk_68e6a5c1e0b762e741c2e020`: - -- **Draft**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.draft.js` -- **Version 1**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.v1.js` -- **Version 2**: `org_68e6a5c1d30338b3981c2104/tsk_68e6a5c1e0b762e741c2e020/aut_68e6a70803cf925eac17896a.v2.js` - -### Integration Flow - -#### Publishing a Version - -1. User clicks "Publish" in Next.js UI with optional changelog -2. Next.js calls `POST /api/tasks-automations/publish` (no version number in request) -3. Enterprise API: - - Queries database to get next version number - - Copies draft → versioned S3 key - - Returns version number and scriptKey -4. Next.js saves version record to database with returned version number, scriptKey, and changelog - -#### Restoring a Version - -1. User clicks "Restore Version X" in Next.js UI -2. Shows confirmation dialog warning current draft will be lost -3. Next.js calls `POST /api/tasks-automations/restore-version` -4. Enterprise API copies version script → draft S3 key -5. Enterprise API returns success -6. Next.js shows success message -7. User can continue editing in builder with restored script - -### Error Handling - -- Return proper HTTP status codes (404 for not found, 400 for bad request, 500 for S3 errors) -- Include descriptive error messages in response body -- Log errors for debugging - -### Testing Checklist - -- [ ] Can publish a draft script as version 1 -- [ ] Can publish multiple versions (1, 2, 3...) -- [ ] Cannot publish if no draft exists -- [ ] Can restore version 1 to draft -- [ ] Restoring doesn't affect chat history -- [ ] S3 keys follow correct naming convention -- [ ] Proper error messages when scripts don't exist diff --git a/apps/api/src/assistant-chat/assistant-chat.controller.ts b/apps/api/src/assistant-chat/assistant-chat.controller.ts index f5a861ef7..dbd58220a 100644 --- a/apps/api/src/assistant-chat/assistant-chat.controller.ts +++ b/apps/api/src/assistant-chat/assistant-chat.controller.ts @@ -34,7 +34,10 @@ import type { AssistantChatMessage } from './assistant-chat.types'; export class AssistantChatController { constructor(private readonly assistantChatService: AssistantChatService) {} - private getUserScopedContext(auth: AuthContextType): { organizationId: string; userId: string } { + 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'); @@ -69,7 +72,9 @@ export class AssistantChatController { }, }, }) - async getHistory(@AuthContext() auth: AuthContextType): Promise<{ messages: AssistantChatMessage[] }> { + async getHistory( + @AuthContext() auth: AuthContextType, + ): Promise<{ messages: AssistantChatMessage[] }> { const { organizationId, userId } = this.getUserScopedContext(auth); const messages = await this.assistantChatService.getHistory({ @@ -105,7 +110,9 @@ export class AssistantChatController { summary: 'Clear assistant chat history', description: 'Deletes the current user-scoped assistant chat history.', }) - async clearHistory(@AuthContext() auth: AuthContextType): Promise<{ success: true }> { + async clearHistory( + @AuthContext() auth: AuthContextType, + ): Promise<{ success: true }> { const { organizationId, userId } = this.getUserScopedContext(auth); await this.assistantChatService.clearHistory({ @@ -116,5 +123,3 @@ export class AssistantChatController { return { success: true }; } } - - diff --git a/apps/api/src/assistant-chat/assistant-chat.dto.ts b/apps/api/src/assistant-chat/assistant-chat.dto.ts index 9568fbec7..8364161e5 100644 --- a/apps/api/src/assistant-chat/assistant-chat.dto.ts +++ b/apps/api/src/assistant-chat/assistant-chat.dto.ts @@ -1,5 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsArray, IsIn, IsNumber, IsString, ValidateNested } from 'class-validator'; +import { + IsArray, + IsIn, + IsNumber, + IsString, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; export class AssistantChatMessageDto { @@ -27,5 +33,3 @@ export class SaveAssistantChatHistoryDto { @Type(() => AssistantChatMessageDto) messages!: AssistantChatMessageDto[]; } - - diff --git a/apps/api/src/assistant-chat/assistant-chat.module.ts b/apps/api/src/assistant-chat/assistant-chat.module.ts index ba781368c..a06068c0c 100644 --- a/apps/api/src/assistant-chat/assistant-chat.module.ts +++ b/apps/api/src/assistant-chat/assistant-chat.module.ts @@ -9,5 +9,3 @@ import { AssistantChatService } from './assistant-chat.service'; providers: [AssistantChatService], }) export class AssistantChatModule {} - - diff --git a/apps/api/src/assistant-chat/assistant-chat.service.ts b/apps/api/src/assistant-chat/assistant-chat.service.ts index 52672cef3..dfd2bd423 100644 --- a/apps/api/src/assistant-chat/assistant-chat.service.ts +++ b/apps/api/src/assistant-chat/assistant-chat.service.ts @@ -17,7 +17,10 @@ type GetAssistantChatKeyParams = { userId: string; }; -const getAssistantChatKey = ({ organizationId, userId }: GetAssistantChatKeyParams): string => { +const getAssistantChatKey = ({ + organizationId, + userId, +}: GetAssistantChatKeyParams): string => { return `assistant-chat:v1:${organizationId}:${userId}`; }; @@ -27,9 +30,13 @@ export class AssistantChatService { * Default TTL is 7 days. This is intended to behave like "session context" * rather than a long-term, searchable archive. */ - private readonly ttlSeconds = Number(process.env.ASSISTANT_CHAT_TTL_SECONDS ?? 60 * 60 * 24 * 7); + private readonly ttlSeconds = Number( + process.env.ASSISTANT_CHAT_TTL_SECONDS ?? 60 * 60 * 24 * 7, + ); - async getHistory(params: GetAssistantChatKeyParams): Promise { + async getHistory( + params: GetAssistantChatKeyParams, + ): Promise { const key = getAssistantChatKey(params); const raw = await assistantChatRedisClient.get(key); const parsed = StoredMessagesSchema.safeParse(raw); @@ -37,7 +44,10 @@ export class AssistantChatService { return parsed.data; } - async saveHistory(params: GetAssistantChatKeyParams, messages: AssistantChatMessage[]): Promise { + async saveHistory( + params: GetAssistantChatKeyParams, + messages: AssistantChatMessage[], + ): Promise { const key = getAssistantChatKey(params); // Always validate before writing to keep the cache shape stable. const validated = StoredMessagesSchema.parse(messages); @@ -49,5 +59,3 @@ export class AssistantChatService { await assistantChatRedisClient.del(key); } } - - diff --git a/apps/api/src/assistant-chat/assistant-chat.types.ts b/apps/api/src/assistant-chat/assistant-chat.types.ts index 223a5316e..e6cdba235 100644 --- a/apps/api/src/assistant-chat/assistant-chat.types.ts +++ b/apps/api/src/assistant-chat/assistant-chat.types.ts @@ -4,5 +4,3 @@ export type AssistantChatMessage = { text: string; createdAt: number; }; - - diff --git a/apps/api/src/assistant-chat/upstash-redis.client.ts b/apps/api/src/assistant-chat/upstash-redis.client.ts index da7d93439..d4f74ef8d 100644 --- a/apps/api/src/assistant-chat/upstash-redis.client.ts +++ b/apps/api/src/assistant-chat/upstash-redis.client.ts @@ -19,7 +19,11 @@ class InMemoryRedis { return record.value as T; } - async set(key: string, value: unknown, options?: { ex?: number }): Promise<'OK'> { + async set( + key: string, + value: unknown, + options?: { ex?: number }, + ): Promise<'OK'> { const expiresAt = options?.ex ? Date.now() + options.ex * 1000 : undefined; this.storage.set(key, { value, expiresAt }); return 'OK'; @@ -32,7 +36,8 @@ class InMemoryRedis { } const hasUpstashConfig = - !!process.env.UPSTASH_REDIS_REST_URL && !!process.env.UPSTASH_REDIS_REST_TOKEN; + !!process.env.UPSTASH_REDIS_REST_URL && + !!process.env.UPSTASH_REDIS_REST_TOKEN; export const assistantChatRedisClient: Pick = hasUpstashConfig @@ -41,5 +46,3 @@ export const assistantChatRedisClient: Pick = token: process.env.UPSTASH_REDIS_REST_TOKEN!, }) : (new InMemoryRedis() as unknown as Pick); - - diff --git a/apps/api/src/auth/internal-token.guard.ts b/apps/api/src/auth/internal-token.guard.ts index d0e6ec5e7..a753b0015 100644 --- a/apps/api/src/auth/internal-token.guard.ts +++ b/apps/api/src/auth/internal-token.guard.ts @@ -42,5 +42,3 @@ export class InternalTokenGuard implements CanActivate { return true; } } - - diff --git a/apps/api/src/comments/comment-mention-notifier.service.ts b/apps/api/src/comments/comment-mention-notifier.service.ts index bfcdecbbb..816a86664 100644 --- a/apps/api/src/comments/comment-mention-notifier.service.ts +++ b/apps/api/src/comments/comment-mention-notifier.service.ts @@ -102,7 +102,8 @@ async function buildFallbackCommentContext(params: { }); if (taskItem) { - const parentRoutePath = taskItem.entityType === 'vendor' ? 'vendors' : 'risk'; + const parentRoutePath = + taskItem.entityType === 'vendor' ? 'vendors' : 'risk'; const url = new URL( `${appUrl}/${organizationId}/${parentRoutePath}/${taskItem.entityId}`, ); @@ -291,7 +292,11 @@ export class CommentMentionNotifierService { // Check if user is unsubscribed from comment mention notifications // Note: We'll use 'taskMentions' preference for now, or create a new 'commentMentions' preference - const isUnsubscribed = await isUserUnsubscribed(db, user.email, 'taskMentions'); + const isUnsubscribed = await isUserUnsubscribed( + db, + user.email, + 'taskMentions', + ); if (isUnsubscribed) { this.logger.log( `Skipping mention notification: user ${user.email} is unsubscribed from mentions`, @@ -375,4 +380,3 @@ export class CommentMentionNotifierService { } } } - diff --git a/apps/api/src/comments/comments.service.ts b/apps/api/src/comments/comments.service.ts index d78b66cd5..9aea2b8e7 100644 --- a/apps/api/src/comments/comments.service.ts +++ b/apps/api/src/comments/comments.service.ts @@ -270,7 +270,9 @@ export class CommentsService { // Notify mentioned users if (createCommentDto.content && userId) { - const mentionedUserIds = extractMentionedUserIds(createCommentDto.content); + const mentionedUserIds = extractMentionedUserIds( + createCommentDto.content, + ); if (mentionedUserIds.length > 0) { // Fire-and-forget: notification failures should not block comment creation void this.mentionNotifier.notifyMentionedUsers({ diff --git a/apps/api/src/policies/policies.service.ts b/apps/api/src/policies/policies.service.ts index 60875cdd8..5c1a8f501 100644 --- a/apps/api/src/policies/policies.service.ts +++ b/apps/api/src/policies/policies.service.ts @@ -32,6 +32,16 @@ export class PoliciesService { assigneeId: true, approverId: true, policyTemplateId: true, + assignee: { + select: { + id: true, + user: { + select: { + name: true, + }, + }, + }, + }, }, orderBy: { createdAt: 'desc' }, }); diff --git a/apps/api/src/task-management/task-item-assignment-notifier.service.ts b/apps/api/src/task-management/task-item-assignment-notifier.service.ts index 5abf22dab..a9a44492a 100644 --- a/apps/api/src/task-management/task-item-assignment-notifier.service.ts +++ b/apps/api/src/task-management/task-item-assignment-notifier.service.ts @@ -183,9 +183,9 @@ export class TaskItemAssignmentNotifierService { }, }); - this.logger.log( - `[NOVU] Assignment in-app notification sent to ${assigneeUser.id} for task "${taskTitle}"`, - ); + this.logger.log( + `[NOVU] Assignment in-app notification sent to ${assigneeUser.id} for task "${taskTitle}"`, + ); } catch (error) { this.logger.error( `[NOVU] Failed to send assignment in-app notification to ${assigneeUser.id}:`, diff --git a/apps/api/src/task-management/task-item-mention-notifier.service.ts b/apps/api/src/task-management/task-item-mention-notifier.service.ts index a9c1eeffa..754fde974 100644 --- a/apps/api/src/task-management/task-item-mention-notifier.service.ts +++ b/apps/api/src/task-management/task-item-mention-notifier.service.ts @@ -110,7 +110,11 @@ export class TaskItemMentionNotifierService { } // Check if user is unsubscribed from task mention notifications - const isUnsubscribed = await isUserUnsubscribed(db, user.email, 'taskMentions'); + const isUnsubscribed = await isUserUnsubscribed( + db, + user.email, + 'taskMentions', + ); if (isUnsubscribed) { this.logger.log( `Skipping mention notification: user ${user.email} is unsubscribed from task mentions`, diff --git a/apps/api/src/task-management/task-management.service.ts b/apps/api/src/task-management/task-management.service.ts index 2ef9ac7b0..cb7b5d67a 100644 --- a/apps/api/src/task-management/task-management.service.ts +++ b/apps/api/src/task-management/task-management.service.ts @@ -87,7 +87,9 @@ export class TaskManagementService { if (error instanceof BadRequestException) { throw error; } - throw new InternalServerErrorException('Failed to fetch task items stats'); + throw new InternalServerErrorException( + 'Failed to fetch task items stats', + ); } } diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts index 6ccce608c..d2c84a817 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-monthly-schedule.ts @@ -89,4 +89,3 @@ export const vendorRiskAssessmentMonthlySchedule = schedules.task({ } }, }); - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts index cfa7aee7e..8607efee6 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts @@ -87,7 +87,10 @@ function incrementVersion(currentVersion: string | null | undefined): string { * Otherwise, check if data exists - if not, do research. */ function shouldDoResearch( - globalVendor: { riskAssessmentData: unknown; riskAssessmentVersion: string | null } | null, + globalVendor: { + riskAssessmentData: unknown; + riskAssessmentVersion: string | null; + } | null, withResearch: boolean, ): boolean { // If withResearch is true, task was triggered because research is needed (we filter before triggering) @@ -120,7 +123,9 @@ function isJsonInputValue(value: unknown): value is Prisma.InputJsonValue { } if (typeof value === 'object') { - return Object.values(value as Record).every(isJsonInputValue); + return Object.values(value as Record).every( + isJsonInputValue, + ); } return false; @@ -155,7 +160,9 @@ function extractDomain(website: string | null | undefined): string | null { try { // Add protocol if missing to make URL parsing work - const urlString = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const urlString = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; const url = new URL(urlString); // Remove www. prefix and return just the domain return url.hostname.toLowerCase().replace(/^www\./, ''); @@ -197,7 +204,6 @@ export const vendorRiskAssessmentTask = schemaTask({ }, maxDuration: 1000 * 60 * 10, run: async (payload) => { - const vendor = await db.vendor.findFirst({ where: { id: payload.vendorId, @@ -236,7 +242,10 @@ export const vendorRiskAssessmentTask = schemaTask({ const normalizedWebsite = normalizeWebsite(vendor.website); if (!normalizedWebsite) { - logger.info('⏭️ SKIP (invalid website)', { vendor: payload.vendorName, website: vendor.website }); + logger.info('⏭️ SKIP (invalid website)', { + vendor: payload.vendorName, + website: vendor.website, + }); await db.vendor.update({ where: { id: vendor.id }, data: { status: VendorStatus.assessed }, @@ -268,19 +277,19 @@ export const vendorRiskAssessmentTask = schemaTask({ riskAssessmentUpdatedAt: true, riskAssessmentData: true, }, - orderBy: [ - { riskAssessmentUpdatedAt: 'desc' }, - { createdAt: 'desc' }, - ], + orderBy: [{ riskAssessmentUpdatedAt: 'desc' }, { createdAt: 'desc' }], }) : []; - + // Use the most recent one for reading/checking, but we'll update all duplicates const globalVendor = globalVendors[0] ?? null; // Determine if research is needed // If withResearch is true, task was triggered because research is needed (we filter before triggering) - const needsResearch = shouldDoResearch(globalVendor, payload.withResearch ?? false); + const needsResearch = shouldDoResearch( + globalVendor, + payload.withResearch ?? false, + ); if (needsResearch) { logger.info('🔍 DOING RESEARCH', { @@ -297,10 +306,11 @@ export const vendorRiskAssessmentTask = schemaTask({ // Still ensure a "Verify risk assessment" task exists so humans can confirm accuracy, // even when we are reusing cached GlobalVendors data (no research performed). - const { creatorMemberId, assigneeMemberId } = await resolveTaskCreatorAndAssignee({ - organizationId: payload.organizationId, - createdByUserId: payload.createdByUserId ?? null, - }); + const { creatorMemberId, assigneeMemberId } = + await resolveTaskCreatorAndAssignee({ + organizationId: payload.organizationId, + createdByUserId: payload.createdByUserId ?? null, + }); const creatorMember = await db.member.findUnique({ where: { id: creatorMemberId }, @@ -325,7 +335,8 @@ export const vendorRiskAssessmentTask = schemaTask({ await db.taskItem.create({ data: { title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, - description: 'Review the latest Risk Assessment and confirm it is accurate.', + description: + 'Review the latest Risk Assessment and confirm it is accurate.', status: TaskItemStatus.todo, priority: TaskItemPriority.high, entityId: payload.vendorId, @@ -346,7 +357,8 @@ export const vendorRiskAssessmentTask = schemaTask({ }, data: { status: TaskItemStatus.todo, - description: 'Review the latest Risk Assessment and confirm it is accurate.', + description: + 'Review the latest Risk Assessment and confirm it is accurate.', assigneeId: assigneeMemberId, updatedById: creatorMemberId, }, @@ -399,10 +411,11 @@ export const vendorRiskAssessmentTask = schemaTask({ }, }); - const { creatorMemberId, assigneeMemberId } = await resolveTaskCreatorAndAssignee({ - organizationId: payload.organizationId, - createdByUserId: payload.createdByUserId ?? null, - }); + const { creatorMemberId, assigneeMemberId } = + await resolveTaskCreatorAndAssignee({ + organizationId: payload.organizationId, + createdByUserId: payload.createdByUserId ?? null, + }); // Get creator member with userId for activity log const creatorMember = await db.member.findUnique({ @@ -411,10 +424,13 @@ export const vendorRiskAssessmentTask = schemaTask({ }); if (!creatorMember?.userId) { - logger.warn('Creator member has no userId, skipping activity log creation', { - creatorMemberId, - organizationId: payload.organizationId, - }); + logger.warn( + 'Creator member has no userId, skipping activity log creation', + { + creatorMemberId, + organizationId: payload.organizationId, + }, + ); } // Ensure a "Verify risk assessment" task exists immediately, but keep it blocked while generation runs. @@ -455,9 +471,9 @@ export const vendorRiskAssessmentTask = schemaTask({ try { await db.auditLog.create({ data: { - organizationId: payload.organizationId, + organizationId: payload.organizationId, userId: creatorMember.userId, - memberId: creatorMemberId, + memberId: creatorMemberId, entityType: 'task', entityId: verifyTaskItemId, description: 'created this task', @@ -469,7 +485,7 @@ export const vendorRiskAssessmentTask = schemaTask({ parentEntityId: payload.vendorId, }, }, - }); + }); } catch (error) { logger.error('Failed to log task item creation:', error); // Don't throw - audit log failures should not block operations @@ -481,7 +497,8 @@ export const vendorRiskAssessmentTask = schemaTask({ const frameworkChecklist = buildFrameworkChecklist(organizationFrameworks); // Do research if needed (vendor doesn't exist, no data, or explicitly requested) - const research = needsResearch && payload.vendorWebsite + const research = + needsResearch && payload.vendorWebsite ? await firecrawlAgentVendorRiskAssessment({ vendorName: payload.vendorName, vendorWebsite: payload.vendorWebsite, @@ -513,7 +530,10 @@ export const vendorRiskAssessmentTask = schemaTask({ riskAssessmentVersion: true, riskAssessmentUpdatedAt: true, }, - orderBy: [{ riskAssessmentUpdatedAt: 'desc' }, { createdAt: 'desc' }], + orderBy: [ + { riskAssessmentUpdatedAt: 'desc' }, + { createdAt: 'desc' }, + ], }) : []; @@ -556,7 +576,10 @@ export const vendorRiskAssessmentTask = schemaTask({ }, }); - return { nextVersion: computedNext, updatedWebsites: [normalizedWebsite] }; + return { + nextVersion: computedNext, + updatedWebsites: [normalizedWebsite], + }; }, }); @@ -584,7 +607,8 @@ export const vendorRiskAssessmentTask = schemaTask({ }, data: { status: TaskItemStatus.todo, - description: 'Review the latest Risk Assessment and confirm it is accurate.', + description: + 'Review the latest Risk Assessment and confirm it is accurate.', // Keep stable assignee/creator assigneeId: assigneeMemberId, updatedById: creatorMemberId, @@ -607,5 +631,3 @@ export const vendorRiskAssessmentTask = schemaTask({ }; }, }); - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts index 0532376c3..f9f1f9541 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-schema.ts @@ -1,8 +1,14 @@ import { z } from 'zod'; -const urlOrEmptySchema = z.union([z.string().url(), z.literal('')]).optional().nullable(); +const urlOrEmptySchema = z + .union([z.string().url(), z.literal('')]) + .optional() + .nullable(); // Firecrawl may return various date formats (ISO, "YYYY-MM-DD", etc). We normalize later. -const dateStringOrEmptySchema = z.union([z.string(), z.literal('')]).optional().nullable(); +const dateStringOrEmptySchema = z + .union([z.string(), z.literal('')]) + .optional() + .nullable(); export const vendorRiskAssessmentAgentSchema = z.object({ risk_level: z.string().optional().nullable(), @@ -12,7 +18,10 @@ export const vendorRiskAssessmentAgentSchema = z.object({ .array( z.object({ type: z.string(), - status: z.enum(['verified', 'expired', 'not_certified', 'unknown']).optional().nullable(), + status: z + .enum(['verified', 'expired', 'not_certified', 'unknown']) + .optional() + .nullable(), issued_at: dateStringOrEmptySchema, expires_at: dateStringOrEmptySchema, url: urlOrEmptySchema, @@ -38,7 +47,10 @@ export const vendorRiskAssessmentAgentSchema = z.object({ summary: z.string().optional().nullable(), source: z.string().optional().nullable(), url: urlOrEmptySchema, - sentiment: z.enum(['positive', 'negative', 'neutral']).optional().nullable(), + sentiment: z + .enum(['positive', 'negative', 'neutral']) + .optional() + .nullable(), }), ) .optional() @@ -48,5 +60,3 @@ export const vendorRiskAssessmentAgentSchema = z.object({ export type VendorRiskAssessmentAgentResult = z.infer< typeof vendorRiskAssessmentAgentSchema >; - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts index 2fdbb2dba..b934550b9 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/agent-types.ts @@ -17,7 +17,10 @@ export type VendorRiskAssessmentLink = { url: string; }; -export type VendorRiskAssessmentNewsSentiment = 'positive' | 'negative' | 'neutral'; +export type VendorRiskAssessmentNewsSentiment = + | 'positive' + | 'negative' + | 'neutral'; export type VendorRiskAssessmentNewsItem = { date: string; @@ -39,5 +42,3 @@ export type VendorRiskAssessmentDataV1 = { links?: VendorRiskAssessmentLink[] | null; news?: VendorRiskAssessmentNewsItem[] | null; }; - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/assignee.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/assignee.ts index c14db58b1..fee1bced2 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/assignee.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/assignee.ts @@ -35,7 +35,9 @@ export async function resolveTaskCreatorAndAssignee(params: { const creatorMemberId = creatorMember?.id ?? adminMember?.id ?? anyMember?.id; if (!creatorMemberId) { - throw new Error(`No active members found for organization ${organizationId}`); + throw new Error( + `No active members found for organization ${organizationId}`, + ); } return { @@ -43,5 +45,3 @@ export async function resolveTaskCreatorAndAssignee(params: { assigneeMemberId: creatorMember?.id ?? adminMember?.id ?? null, }; } - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/constants.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/constants.ts index 64e35e9ff..098b714c9 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/constants.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/constants.ts @@ -1,5 +1,4 @@ -export const VENDOR_RISK_ASSESSMENT_TASK_ID = 'vendor-risk-assessment-task' as const; +export const VENDOR_RISK_ASSESSMENT_TASK_ID = + 'vendor-risk-assessment-task' as const; export const VENDOR_RISK_ASSESSMENT_TASK_TITLE = 'Risk Assessment' as const; - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts index 32c135c0e..017e4d10f 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/description.ts @@ -32,8 +32,7 @@ export function buildRiskAssessmentDescription(params: { ...base, vendorName: base.vendorName ?? vendorName, vendorWebsite: base.vendorWebsite ?? vendorWebsite, - securityAssessment: (base.securityAssessment ?? '') + checklistSuffix || null, + securityAssessment: + (base.securityAssessment ?? '') + checklistSuffix || null, } satisfies VendorRiskAssessmentDataV1); } - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts index 3c5ad6108..7b1cc6e2d 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl-agent.ts @@ -10,7 +10,8 @@ function normalizeUrl(url: string | null | undefined): string | null { if (trimmed === '') return null; const looksLikeDomain = - !/^https?:\/\//i.test(trimmed) && /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed); + !/^https?:\/\//i.test(trimmed) && + /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed); const candidate = looksLikeDomain ? `https://${trimmed}` : trimmed; try { @@ -37,7 +38,9 @@ export async function firecrawlAgentVendorRiskAssessment(params: { }): Promise { const apiKey = process.env.FIRECRAWL_API_KEY; if (!apiKey) { - logger.warn('FIRECRAWL_API_KEY is not configured; skipping vendor research'); + logger.warn( + 'FIRECRAWL_API_KEY is not configured; skipping vendor research', + ); return null; } @@ -47,7 +50,9 @@ export async function firecrawlAgentVendorRiskAssessment(params: { try { origin = new URL(vendorWebsite).origin; } catch { - logger.warn('Invalid website URL provided to Firecrawl Agent', { vendorWebsite }); + logger.warn('Invalid website URL provided to Firecrawl Agent', { + vendorWebsite, + }); return null; } @@ -126,7 +131,10 @@ Focus on their official website (especially trust/security/compliance pages), pr summary: { type: 'string' }, source: { type: 'string' }, url: { type: 'string' }, - sentiment: { type: 'string', enum: ['positive', 'negative', 'neutral'] }, + sentiment: { + type: 'string', + enum: ['positive', 'negative', 'neutral'], + }, }, required: ['date', 'title'], }, @@ -147,11 +155,22 @@ Focus on their official website (especially trust/security/compliance pages), pr const links = parsed.data.links ?? null; const linkPairs: Array<{ label: string; url: string }> = []; - if (links?.trust_center_url) linkPairs.push({ label: 'Trust & Security', url: links.trust_center_url }); - if (links?.security_page_url) linkPairs.push({ label: 'Security Overview', url: links.security_page_url }); - if (links?.soc2_report_url) linkPairs.push({ label: 'SOC 2 Report', url: links.soc2_report_url }); - if (links?.privacy_policy_url) linkPairs.push({ label: 'Privacy Policy', url: links.privacy_policy_url }); - if (links?.terms_of_service_url) linkPairs.push({ label: 'Terms of Service', url: links.terms_of_service_url }); + if (links?.trust_center_url) + linkPairs.push({ label: 'Trust & Security', url: links.trust_center_url }); + if (links?.security_page_url) + linkPairs.push({ + label: 'Security Overview', + url: links.security_page_url, + }); + if (links?.soc2_report_url) + linkPairs.push({ label: 'SOC 2 Report', url: links.soc2_report_url }); + if (links?.privacy_policy_url) + linkPairs.push({ label: 'Privacy Policy', url: links.privacy_policy_url }); + if (links?.terms_of_service_url) + linkPairs.push({ + label: 'Terms of Service', + url: links.terms_of_service_url, + }); const normalizedLinks = linkPairs .map((l) => ({ ...l, url: normalizeUrl(l.url) })) @@ -199,7 +218,9 @@ Focus on their official website (especially trust/security/compliance pages), pr kind: 'vendorRiskAssessmentV1', vendorName, vendorWebsite, - lastResearchedAt: normalizeIso(parsed.data.last_researched_at ?? null) ?? new Date().toISOString(), + lastResearchedAt: + normalizeIso(parsed.data.last_researched_at ?? null) ?? + new Date().toISOString(), riskLevel: parsed.data.risk_level ?? null, securityAssessment: parsed.data.security_assessment ?? null, certifications: certifications.length > 0 ? certifications : null, @@ -218,5 +239,3 @@ Focus on their official website (especially trust/security/compliance pages), pr return result; } - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl.ts index 6af98c902..4ce68c7c5 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/firecrawl.ts @@ -21,7 +21,10 @@ function normalizeUrl(url: string | null | undefined): string | null { if (!trimmed || trimmed === '') return null; // If it looks like a domain but missing scheme, assume https - if (!/^https?:\/\//i.test(trimmed) && /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed)) { + if ( + !/^https?:\/\//i.test(trimmed) && + /^[a-z0-9.-]+\.[a-z]{2,}([/].*)?$/i.test(trimmed) + ) { trimmed = `https://${trimmed}`; } @@ -47,7 +50,9 @@ export async function firecrawlExtractVendorData( ): Promise { const apiKey = process.env.FIRECRAWL_API_KEY; if (!apiKey) { - logger.warn('FIRECRAWL_API_KEY is not configured; skipping vendor research'); + logger.warn( + 'FIRECRAWL_API_KEY is not configured; skipping vendor research', + ); return null; } @@ -66,9 +71,9 @@ export async function firecrawlExtractVendorData( 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}`, }, - body: JSON.stringify({ - urls: [`${origin}/*`], - prompt: `You are a security analyst collecting SOC 2 + ISO 27001 evidence links for a third-party risk assessment. + body: JSON.stringify({ + urls: [`${origin}/*`], + prompt: `You are a security analyst collecting SOC 2 + ISO 27001 evidence links for a third-party risk assessment. Goal: return the MOST SPECIFIC, DIRECT URL for each document type below. Do not return general category pages. @@ -102,7 +107,8 @@ When multiple candidates exist, choose the most direct URL that best matches the properties: { company_description: { type: 'string', - description: 'Brief 1-2 sentence description of what the company does and their main services/products', + description: + 'Brief 1-2 sentence description of what the company does and their main services/products', }, privacy_policy_url: { type: 'string', @@ -137,19 +143,22 @@ When multiple candidates exist, choose the most direct URL that best matches the }, }, }, - enableWebSearch: true, - includeSubdomains: true, - showSources: true, - scrapeOptions: { - onlyMainContent: false, - removeBase64Images: true, - }, - }), - }); + enableWebSearch: true, + includeSubdomains: true, + showSources: true, + scrapeOptions: { + onlyMainContent: false, + removeBase64Images: true, + }, + }), + }); const initialData = (await initialResponse.json()) as FirecrawlStartResponse; if (!initialData.success || !initialData.id) { - logger.warn('Firecrawl failed to start extraction', { website, initialData }); + logger.warn('Firecrawl failed to start extraction', { + website, + initialData, + }); return null; } @@ -209,7 +218,11 @@ When multiple candidates exist, choose the most direct URL that best matches the } if (statusData.status === 'failed' || statusData.status === 'cancelled') { - logger.warn('Firecrawl extraction did not complete', { website, jobId, statusData }); + logger.warn('Firecrawl extraction did not complete', { + website, + jobId, + statusData, + }); return null; } } @@ -217,5 +230,3 @@ When multiple candidates exist, choose the most direct URL that best matches the logger.warn('Firecrawl extraction timed out', { website, jobId }); return null; } - - diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/frameworks.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/frameworks.ts index 8aa620a1b..2fb1371bc 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/frameworks.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/frameworks.ts @@ -17,23 +17,33 @@ type FrameworkRule = { const FRAMEWORK_RULES: FrameworkRule[] = [ { match: /\bsoc\s*2\b/i, - checks: ['Review their SOC 2 report (Type I / Type II) and note any exceptions.'], + checks: [ + 'Review their SOC 2 report (Type I / Type II) and note any exceptions.', + ], }, { match: /\biso\s*27001\b/i, - checks: ['Review their ISO 27001 certificate and scope/SoA (if available).'], + checks: [ + 'Review their ISO 27001 certificate and scope/SoA (if available).', + ], }, { match: /\bgdpr\b/i, - checks: ['Check for a DPA (Data Processing Agreement) and confirm GDPR commitments.'], + checks: [ + 'Check for a DPA (Data Processing Agreement) and confirm GDPR commitments.', + ], }, { match: /\bhipaa\b/i, - checks: ['If PHI is involved, confirm whether they offer a BAA and required safeguards.'], + checks: [ + 'If PHI is involved, confirm whether they offer a BAA and required safeguards.', + ], }, { match: /\bpci\b|\bpci\s*dss\b/i, - checks: ['If payment data is involved, confirm PCI DSS compliance / attestation.'], + checks: [ + 'If payment data is involved, confirm PCI DSS compliance / attestation.', + ], }, ]; @@ -75,7 +85,9 @@ export function buildFrameworkChecklist(frameworks: OrgFramework[]): string[] { .filter(Boolean) .join(', '); if (frameworkList) { - return [`Review vendor documentation relevant to your frameworks: ${frameworkList}.`]; + return [ + `Review vendor documentation relevant to your frameworks: ${frameworkList}.`, + ]; } } @@ -84,4 +96,4 @@ export function buildFrameworkChecklist(frameworks: OrgFramework[]): string[] { export function getDefaultFrameworks(): OrgFramework[] { return DEFAULT_FRAMEWORKS; -} \ No newline at end of file +} diff --git a/apps/api/src/trigger/vendor/vendor-risk-assessment/schema.ts b/apps/api/src/trigger/vendor/vendor-risk-assessment/schema.ts index 35628ffb4..0f2363c5c 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment/schema.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment/schema.ts @@ -2,11 +2,26 @@ import { z } from 'zod'; export const firecrawlVendorDataSchema = z.object({ company_description: z.string().optional().nullable(), - privacy_policy_url: z.union([z.string().url(), z.literal('')]).optional().nullable(), - terms_of_service_url: z.union([z.string().url(), z.literal('')]).optional().nullable(), - security_overview_url: z.union([z.string().url(), z.literal('')]).optional().nullable(), - trust_portal_url: z.union([z.string().url(), z.literal('')]).optional().nullable(), - soc2_report_url: z.union([z.string().url(), z.literal('')]).optional().nullable(), + privacy_policy_url: z + .union([z.string().url(), z.literal('')]) + .optional() + .nullable(), + terms_of_service_url: z + .union([z.string().url(), z.literal('')]) + .optional() + .nullable(), + security_overview_url: z + .union([z.string().url(), z.literal('')]) + .optional() + .nullable(), + trust_portal_url: z + .union([z.string().url(), z.literal('')]) + .optional() + .nullable(), + soc2_report_url: z + .union([z.string().url(), z.literal('')]) + .optional() + .nullable(), certified_security_frameworks: z.array(z.string()).optional().nullable(), }); @@ -28,5 +43,3 @@ export const vendorRiskAssessmentPayloadSchema = z.object({ export type VendorRiskAssessmentPayload = z.infer< typeof vendorRiskAssessmentPayloadSchema >; - - diff --git a/apps/api/src/trust-portal/dto/trust-document.dto.ts b/apps/api/src/trust-portal/dto/trust-document.dto.ts index a040f11ee..6f7825594 100644 --- a/apps/api/src/trust-portal/dto/trust-document.dto.ts +++ b/apps/api/src/trust-portal/dto/trust-document.dto.ts @@ -89,5 +89,3 @@ export class DeleteTrustDocumentDto { @IsString() organizationId!: string; } - - diff --git a/apps/api/src/trust-portal/trust-access.controller.ts b/apps/api/src/trust-portal/trust-access.controller.ts index 8784ae7c8..4cfcf5926 100644 --- a/apps/api/src/trust-portal/trust-access.controller.ts +++ b/apps/api/src/trust-portal/trust-access.controller.ts @@ -509,7 +509,9 @@ export class TrustAccessController { description: 'Signed URL for ZIP archive returned', }) async downloadAllTrustDocuments(@Param('token') token: string) { - return this.trustAccessService.downloadAllTrustDocumentsByAccessToken(token); + return this.trustAccessService.downloadAllTrustDocumentsByAccessToken( + token, + ); } @Get('access/:token/documents/:documentId') diff --git a/apps/api/src/trust-portal/trust-access.service.ts b/apps/api/src/trust-portal/trust-access.service.ts index d4209fd33..d247e9b0a 100644 --- a/apps/api/src/trust-portal/trust-access.service.ts +++ b/apps/api/src/trust-portal/trust-access.service.ts @@ -735,15 +735,22 @@ export class TrustAccessService { // Check if grant has expired if (grant.expiresAt < now) { - throw new BadRequestException('Cannot resend access email for expired grant'); + throw new BadRequestException( + 'Cannot resend access email for expired grant', + ); } // Generate a new access token if expired or missing let accessToken = grant.accessToken; - if (!accessToken || (grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now)) { + if ( + !accessToken || + (grant.accessTokenExpiresAt && grant.accessTokenExpiresAt < now) + ) { accessToken = this.generateToken(32); - const accessTokenExpiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); + const accessTokenExpiresAt = new Date( + now.getTime() + 24 * 60 * 60 * 1000, + ); await db.trustAccessGrant.update({ where: { id: grantId }, @@ -1331,7 +1338,7 @@ export class TrustAccessService { lastPublishedAt: true, updatedAt: true, }, - orderBy: [{ lastPublishedAt: 'desc' }, { updatedAt: 'desc' }], + orderBy: { name: 'desc' }, }); return policies; @@ -1492,9 +1499,7 @@ export class TrustAccessService { const archive = archiver('zip', { zlib: { level: 9 } }); const zipStream = new PassThrough(); - let putPromise: - | Promise - | undefined; + let putPromise: Promise | undefined; try { putPromise = s3Client.send( @@ -1798,4 +1803,4 @@ export class TrustAccessService { return { name: 'All Policies', downloadUrl }; } -} \ No newline at end of file +} diff --git a/apps/api/src/vendors/dto/trigger-vendor-risk-assessment.dto.ts b/apps/api/src/vendors/dto/trigger-vendor-risk-assessment.dto.ts index 75bb3837e..4b705bf1e 100644 --- a/apps/api/src/vendors/dto/trigger-vendor-risk-assessment.dto.ts +++ b/apps/api/src/vendors/dto/trigger-vendor-risk-assessment.dto.ts @@ -34,7 +34,8 @@ export class TriggerVendorRiskAssessmentBatchDto { organizationId: string; @ApiProperty({ - description: 'If false, skips Firecrawl research (cheaper). Defaults to true.', + description: + 'If false, skips Firecrawl research (cheaper). Defaults to true.', required: false, default: true, }) @@ -51,5 +52,3 @@ export class TriggerVendorRiskAssessmentBatchDto { @Type(() => TriggerVendorRiskAssessmentVendorDto) vendors: TriggerVendorRiskAssessmentVendorDto[]; } - - diff --git a/apps/api/src/vendors/internal-vendor-automation.controller.ts b/apps/api/src/vendors/internal-vendor-automation.controller.ts index dcfb8b787..7c08817f3 100644 --- a/apps/api/src/vendors/internal-vendor-automation.controller.ts +++ b/apps/api/src/vendors/internal-vendor-automation.controller.ts @@ -18,18 +18,22 @@ export class InternalVendorAutomationController { @Post('risk-assessment/trigger-batch') @HttpCode(200) @ApiOperation({ - summary: 'Trigger vendor risk assessment tasks for a batch of vendors (internal)', + summary: + 'Trigger vendor risk assessment tasks for a batch of vendors (internal)', }) @ApiResponse({ status: 200, description: 'Tasks triggered' }) async triggerVendorRiskAssessmentBatch( @Body() body: TriggerVendorRiskAssessmentBatchDto, ) { // Log incoming request for debugging - console.log('[InternalVendorAutomationController] Received batch trigger request', { - organizationId: body.organizationId, - vendorCount: body.vendors.length, - withResearch: body.withResearch, - }); + console.log( + '[InternalVendorAutomationController] Received batch trigger request', + { + organizationId: body.organizationId, + vendorCount: body.vendors.length, + withResearch: body.withResearch, + }, + ); const result = await this.vendorsService.triggerVendorRiskAssessments({ organizationId: body.organizationId, @@ -38,7 +42,10 @@ export class InternalVendorAutomationController { vendors: body.vendors, }); - console.log('[InternalVendorAutomationController] Batch trigger completed', result); + console.log( + '[InternalVendorAutomationController] Batch trigger completed', + result, + ); return { success: true, @@ -46,5 +53,3 @@ export class InternalVendorAutomationController { }; } } - - diff --git a/apps/api/src/vendors/vendors.service.ts b/apps/api/src/vendors/vendors.service.ts index 10e277eb2..ea2d8c71e 100644 --- a/apps/api/src/vendors/vendors.service.ts +++ b/apps/api/src/vendors/vendors.service.ts @@ -7,7 +7,9 @@ import { Prisma } from '@prisma/client'; import type { TriggerVendorRiskAssessmentVendorDto } from './dto/trigger-vendor-risk-assessment.dto'; import { resolveTaskCreatorAndAssignee } from '../trigger/vendor/vendor-risk-assessment/assignee'; -const normalizeWebsite = (website: string | null | undefined): string | null => { +const normalizeWebsite = ( + website: string | null | undefined, +): string | null => { if (!website) return null; const trimmed = website.trim(); if (!trimmed) return null; @@ -40,7 +42,9 @@ const extractDomain = (website: string | null | undefined): string | null => { try { // Add protocol if missing to make URL parsing work - const urlString = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + const urlString = /^https?:\/\//i.test(trimmed) + ? trimmed + : `https://${trimmed}`; const url = new URL(urlString); // Remove www. prefix and return just the domain return url.hostname.toLowerCase().replace(/^www\./, ''); @@ -126,10 +130,7 @@ export class VendorsService { riskAssessmentVersion: true, riskAssessmentUpdatedAt: true, }, - orderBy: [ - { riskAssessmentUpdatedAt: 'desc' }, - { createdAt: 'desc' }, - ], + orderBy: [{ riskAssessmentUpdatedAt: 'desc' }, { createdAt: 'desc' }], }); // Prefer record WITH risk assessment data (most recent) @@ -144,7 +145,8 @@ export class VendorsService { ...vendor, riskAssessmentData: globalVendorData?.riskAssessmentData ?? null, riskAssessmentVersion: globalVendorData?.riskAssessmentVersion ?? null, - riskAssessmentUpdatedAt: globalVendorData?.riskAssessmentUpdatedAt ?? null, + riskAssessmentUpdatedAt: + globalVendorData?.riskAssessmentUpdatedAt ?? null, }; this.logger.log(`Retrieved vendor: ${vendor.name} (${id})`); @@ -233,12 +235,21 @@ export class VendorsService { vendor: v, domain: extractDomain(v.vendorWebsite ?? null), })) - .filter((vd): vd is { vendor: TriggerVendorRiskAssessmentVendorDto; domain: string } => vd.domain !== null); + .filter( + ( + vd, + ): vd is { + vendor: TriggerVendorRiskAssessmentVendorDto; + domain: string; + } => vd.domain !== null, + ); // Check which domains already have risk assessment data using contains filter const existingDomains = new Set(); if (vendorDomains.length > 0) { - const uniqueDomains = Array.from(new Set(vendorDomains.map((vd) => vd.domain))); + const uniqueDomains = Array.from( + new Set(vendorDomains.map((vd) => vd.domain)), + ); const existing = await db.globalVendors.findMany({ where: { OR: uniqueDomains.map((domain) => ({ @@ -278,10 +289,11 @@ export class VendorsService { if (skippedVendors.length > 0) { const settled = await Promise.allSettled( skippedVendors.map(async (v) => { - const { creatorMemberId, assigneeMemberId } = await resolveTaskCreatorAndAssignee({ - organizationId, - createdByUserId: null, - }); + const { creatorMemberId, assigneeMemberId } = + await resolveTaskCreatorAndAssignee({ + organizationId, + createdByUserId: null, + }); const creatorMember = await db.member.findUnique({ where: { id: creatorMemberId }, @@ -303,7 +315,8 @@ export class VendorsService { const created = await db.taskItem.create({ data: { title: VERIFY_RISK_ASSESSMENT_TASK_TITLE, - description: 'Review the latest Risk Assessment and confirm it is accurate.', + description: + 'Review the latest Risk Assessment and confirm it is accurate.', status: TaskItemStatus.todo, priority: TaskItemPriority.high, entityId: v.vendorId, @@ -351,7 +364,8 @@ export class VendorsService { where: { id: existingVerifyTask.id }, data: { status: TaskItemStatus.todo, - description: 'Review the latest Risk Assessment and confirm it is accurate.', + description: + 'Review the latest Risk Assessment and confirm it is accurate.', assigneeId: assigneeMemberId, updatedById: creatorMemberId, }, @@ -363,33 +377,46 @@ export class VendorsService { const failures = settled.filter((r) => r.status === 'rejected'); if (failures.length > 0) { - this.logger.warn('Some verify tasks could not be ensured for skipped vendors', { - organizationId, - failures: failures.length, - skippedCount: skippedVendors.length, - }); + this.logger.warn( + 'Some verify tasks could not be ensured for skipped vendors', + { + organizationId, + failures: failures.length, + skippedCount: skippedVendors.length, + }, + ); } } } // Simplified logging: clear lists of what needs research vs what doesn't if (!withResearch && skippedVendors.length > 0) { - this.logger.log('✅ Vendors that DO NOT need research (already have data)', { - count: skippedVendors.length, - vendors: skippedVendors.map((v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`), - }); + this.logger.log( + '✅ Vendors that DO NOT need research (already have data)', + { + count: skippedVendors.length, + vendors: skippedVendors.map( + (v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`, + ), + }, + ); } if (vendorsToTrigger.length > 0) { this.logger.log('🔍 Vendors that NEED research (missing data)', { count: vendorsToTrigger.length, withResearch, - vendors: vendorsToTrigger.map((v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`), + vendors: vendorsToTrigger.map( + (v) => `${v.vendorName} (${v.vendorWebsite ?? 'no website'})`, + ), }); } else { - this.logger.log('✅ All vendors already have risk assessment data - no research needed', { - totalVendors: vendors.length, - }); + this.logger.log( + '✅ All vendors already have risk assessment data - no research needed', + { + totalVendors: vendors.length, + }, + ); } // Use batchTrigger for efficiency (less overhead than N individual triggers) @@ -412,7 +439,10 @@ export class VendorsService { return { triggered: 0, batchId: null }; } - const batchHandle = await tasks.batchTrigger('vendor-risk-assessment-task', batch); + const batchHandle = await tasks.batchTrigger( + 'vendor-risk-assessment-task', + batch, + ); this.logger.log('✅ Triggered risk assessment tasks', { count: vendorsToTrigger.length, @@ -424,12 +454,15 @@ export class VendorsService { batchId: batchHandle.batchId, }; } catch (error) { - this.logger.error('Failed to batch trigger vendor risk assessment tasks', { - organizationId, - vendorCount: vendorsToTrigger.length, - error: error instanceof Error ? error.message : String(error), - errorStack: error instanceof Error ? error.stack : undefined, - }); + this.logger.error( + 'Failed to batch trigger vendor risk assessment tasks', + { + organizationId, + vendorCount: vendorsToTrigger.length, + error: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined, + }, + ); throw error; } } diff --git a/apps/app/agents.md b/apps/app/agents.md new file mode 100644 index 000000000..ab953de9c --- /dev/null +++ b/apps/app/agents.md @@ -0,0 +1,247 @@ +# UI Component Usage Rules (Design System) + +## Core Principle + +**ONLY use components from `@trycompai/design-system`.** Do not use shadcn/ui, Radix primitives, or custom components when a design system component exists. + +**Components do NOT accept `className`. Use variants and props only.** + +This design system enforces strict styling through `class-variance-authority` (cva). +The `className` prop has been removed from all components to prevent style overrides. + +## Component Priority + +1. **First choice:** `@trycompai/design-system` +2. **Never:** Custom implementations when DS has the component + +```tsx +// ✅ ALWAYS - Use design system +import { Button, Table, Badge, Tabs } from '@trycompai/design-system'; + +// ❌ NEVER - Don't use @comp/ui when DS has the component +import { Button } from '@comp/ui/button'; +import { Table } from '@comp/ui/table'; +``` + +## Server vs Client Components + +**Layouts should be server-side rendered.** Any client-side logic (hooks, state, event handlers) must be wrapped in its own `'use client'` component. + +```tsx +// ✅ Server layout with client component for interactivity +// layout.tsx (server) +import { ClientTabs } from './components/ClientTabs'; + +export default function Layout({ children }) { + return ( + + + {/* Client component for interactive tabs */} + {children} + + ); +} + +// components/ClientTabs.tsx (client) +'use client'; +export function ClientTabs() { + const router = useRouter(); + // ... client logic +} + +// ❌ NEVER - Don't make entire layout a client component +'use client'; +export default function Layout({ children }) { ... } +``` + +## Avoid nuqs + +Don't use `nuqs` for query state. Use standard Next.js patterns: + +- `useRouter().push()` for navigation +- `useSearchParams()` for reading query params +- Server-side `searchParams` prop for initial state + +## ❌ These Will NOT Compile + +```tsx +// className is not a valid prop - TypeScript will error + +Content +Status +Content +``` + +## ✅ ALWAYS Do This + +```tsx +// Use component variants + + +Status + +// Use component props +Content +Title +Section Title +Content +``` + +## Layout & Positioning + +For layout concerns (width, grid positioning, margins), use wrapper elements: + +```tsx +// ✅ Wrapper div for layout +
+ +
+ +// ✅ Grid/flex positioning with wrapper +
+ Spanning Card +
+ +// ✅ Use Stack/Grid for spacing + + + + +``` + +## Available Components & Their APIs + +### Layout Primitives + +```tsx +// Stack - flex layout + + {children} + + +// Grid - responsive grid + + {children} + + +// Container - max-width wrapper + + {children} + + +// PageLayout - full page structure + + {children} + +``` + +### Typography + +```tsx +// Heading - h1-h6 with consistent styles +Page Title +Subtitle + +// Text - body text +Description +Important text +``` + +### Interactive + +```tsx +// Button variants: default, outline, secondary, ghost, destructive, link +// Button sizes: default, xs, sm, lg, icon, icon-xs, icon-sm, icon-lg + + + + +// Badge variants: default, secondary, destructive, outline +Active +``` + +### Layout Components + +```tsx +// Card with maxWidth control +Content + +// Section with title/description +
+ + Settings + Manage your preferences + + {children} +
+``` + +## If a Variant Doesn't Exist + +1. **Check the component file** - it might exist and you missed it +2. **Add a new variant** to the component's `cva` definition +3. **Create a new component** if it's a genuinely new pattern + +```tsx +// Example: Adding a variant to button.tsx +const buttonVariants = cva('...base classes...', { + variants: { + variant: { + // existing variants... + newVariant: 'bg-teal-500 text-white hover:bg-teal-600', // ADD HERE + }, + }, +}); +``` + +**NEVER use wrapper divs to apply styles that should be component variants.** + +## Import Pattern + +```tsx +import { + Button, + Card, + CardHeader, + CardContent, + Stack, + Heading, + Text, + Badge, + // ... etc +} from '@trycompai/design-system'; +``` + +## Icons + +Import icons from `@trycompai/design-system/icons` (re-exports `@carbon/icons-react`): + +```tsx +// ✅ ALWAYS - Use design system icons +import { Add, Download, Settings, ChevronDown } from '@trycompai/design-system/icons'; + +// Carbon icons use size prop, not className + + + +// ❌ NEVER - Don't use lucide-react +import { Plus, Download } from 'lucide-react'; + +``` + +Common icon mappings from lucide-react to Carbon: + +| lucide-react | @carbon/icons-react | +|--------------|---------------------| +| Plus | Add | +| X | Close | +| Check | Checkmark | +| ChevronDown | ChevronDown | +| ChevronRight | ChevronRight | +| Settings | Settings | +| Trash | TrashCan | +| Edit | Edit | +| Search | Search | +| Loader2 | (use Button loading prop) | diff --git a/apps/app/eslint.config.mjs b/apps/app/eslint.config.mjs new file mode 100644 index 000000000..377010027 --- /dev/null +++ b/apps/app/eslint.config.mjs @@ -0,0 +1,34 @@ +import { FlatCompat } from '@eslint/eslintrc'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, +}); + +export default [ + { + ignores: [ + '**/.next/**', + '**/dist/**', + '**/node_modules/**', + '**/coverage/**', + '**/playwright-report/**', + '**/.turbo/**', + '**/out/**', + ], + }, + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + rules: { + // This repo has existing violations; keep lint actionable while we migrate. + '@typescript-eslint/no-explicit-any': 'off', + 'react/no-unescaped-entities': 'off', + 'prefer-const': 'off', + }, + }, +]; + diff --git a/apps/app/next.config.ts b/apps/app/next.config.ts index 4cb4b03ef..4369872e2 100644 --- a/apps/app/next.config.ts +++ b/apps/app/next.config.ts @@ -18,6 +18,7 @@ const config: NextConfig = { }, }, }, + webpack: (config, { isServer }) => { if (isServer) { // Very important, DO NOT REMOVE, it's needed for Prisma to work in the server bundle @@ -40,7 +41,12 @@ const config: NextConfig = { ? `${process.env.STATIC_ASSETS_URL}/app` : '', reactStrictMode: false, - transpilePackages: ['@trycompai/db', '@prisma/client'], + transpilePackages: [ + '@trycompai/db', + '@prisma/client', + '@trycompai/design-system', + '@carbon/icons-react', + ], images: { remotePatterns: [ { diff --git a/apps/app/package.json b/apps/app/package.json index 6e64cba08..521bf7a2a 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -57,6 +57,7 @@ "@trigger.dev/react-hooks": "4.0.6", "@trigger.dev/sdk": "4.0.6", "@trycompai/db": "^1.3.20", + "@trycompai/design-system": "^1.0.28", "@trycompai/email": "workspace:*", "@types/canvas-confetti": "^1.9.0", "@types/react-syntax-highlighter": "^15.5.13", @@ -170,7 +171,7 @@ "db:migrate": "cd ../../packages/db && bunx prisma migrate dev && cd ../../apps/app", "deploy:trigger-prod": "npx trigger.dev@4.0.6 deploy", "dev": "bun i && bunx concurrently --kill-others --names \"next,trigger\" --prefix-colors \"yellow,blue\" \"next dev --turbo -p 3000\" \"bunx trigger.dev@4.0.6 dev\"", - "lint": "next lint && prettier --check .", + "lint": "eslint . && prettier --check .", "prebuild": "bun run db:generate", "postinstall": "prisma generate --schema=./prisma/schema.prisma || exit 0", "start": "next start", diff --git a/apps/app/src/actions/policies/archive-policy.ts b/apps/app/src/actions/policies/archive-policy.ts index cc2b0ab01..850bc3efc 100644 --- a/apps/app/src/actions/policies/archive-policy.ts +++ b/apps/app/src/actions/policies/archive-policy.ts @@ -58,7 +58,7 @@ export const archivePolicyAction = authActionClient }); revalidatePath(`/${activeOrganizationId}/policies/${id}`); - revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); diff --git a/apps/app/src/actions/policies/create-new-policy.ts b/apps/app/src/actions/policies/create-new-policy.ts index 419543af4..64cca38ea 100644 --- a/apps/app/src/actions/policies/create-new-policy.ts +++ b/apps/app/src/actions/policies/create-new-policy.ts @@ -104,7 +104,7 @@ export const createPolicyAction = authActionClient // ); // } - revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); diff --git a/apps/app/src/actions/policies/delete-policy.ts b/apps/app/src/actions/policies/delete-policy.ts index ed4d9a179..405e2ba7c 100644 --- a/apps/app/src/actions/policies/delete-policy.ts +++ b/apps/app/src/actions/policies/delete-policy.ts @@ -52,7 +52,7 @@ export const deletePolicyAction = authActionClient }); // Revalidate paths to update UI - revalidatePath(`/${activeOrganizationId}/policies/all`); + revalidatePath(`/${activeOrganizationId}/policies`); revalidateTag('policies', 'max'); } catch (error) { console.error(error); diff --git a/apps/app/src/actions/policies/update-policy-overview-action.ts b/apps/app/src/actions/policies/update-policy-overview-action.ts index 9445d40c1..515e65694 100644 --- a/apps/app/src/actions/policies/update-policy-overview-action.ts +++ b/apps/app/src/actions/policies/update-policy-overview-action.ts @@ -56,7 +56,7 @@ export const updatePolicyOverviewAction = authActionClient }); revalidatePath(`/${session.activeOrganizationId}/policies/${id}`); - revalidatePath(`/${session.activeOrganizationId}/policies/all`); + revalidatePath(`/${session.activeOrganizationId}/policies`); revalidatePath(`/${session.activeOrganizationId}/policies`); return { diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/layout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/layout.tsx deleted file mode 100644 index 4e40cc87b..000000000 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function TestsDashboardLayout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx index 4f38f9a92..12fead15d 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx @@ -1,9 +1,5 @@ -import Loader from '@/components/ui/loader'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( -
- -
- ); + return } />; } diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellRailNav.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellRailNav.tsx new file mode 100644 index 000000000..306a90847 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellRailNav.tsx @@ -0,0 +1,96 @@ +'use client'; + +import { AppShellRailItem } from '@trycompai/design-system'; +import { + FlaskConical, + Gauge, + ListCheck, + NotebookText, + Settings, + Store, + Users, + Zap, +} from 'lucide-react'; +import { usePathname, useRouter } from 'next/navigation'; + +interface AppShellRailNavProps { + organizationId: string; +} + +export function AppShellRailNav({ organizationId }: AppShellRailNavProps) { + const router = useRouter(); + const pathname = usePathname() ?? ''; + + const orgBase = `/${organizationId}`; + + const isActivePrefix = (prefix: string): boolean => { + return pathname === prefix || pathname.startsWith(`${prefix}/`); + }; + + const items = [ + { + href: `${orgBase}/frameworks`, + label: 'Overview', + icon: , + isActive: isActivePrefix(`${orgBase}/frameworks`), + }, + { + href: `${orgBase}/policies`, + label: 'Policies', + icon: , + isActive: isActivePrefix(`${orgBase}/policies`), + }, + { + href: `${orgBase}/tasks`, + label: 'Evidence', + icon: , + isActive: isActivePrefix(`${orgBase}/tasks`), + }, + { + href: `${orgBase}/people/all`, + label: 'People', + icon: , + isActive: isActivePrefix(`${orgBase}/people`), + }, + { + href: `${orgBase}/vendors`, + label: 'Vendors', + icon: , + isActive: isActivePrefix(`${orgBase}/vendors`), + }, + { + href: `${orgBase}/integrations`, + label: 'Integrations', + icon: , + isActive: isActivePrefix(`${orgBase}/integrations`), + }, + { + href: `${orgBase}/cloud-tests`, + label: 'Cloud Tests', + icon: , + isActive: isActivePrefix(`${orgBase}/cloud-tests`), + }, + { + href: `${orgBase}/settings`, + label: 'Settings', + icon: , + isActive: isActivePrefix(`${orgBase}/settings`), + }, + ] as const; + + return ( + <> + {items.map((item) => ( + router.push(item.href)} + /> + ))} + + ); +} + diff --git a/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx new file mode 100644 index 000000000..dc98a5167 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/AppShellWrapper.tsx @@ -0,0 +1,341 @@ +'use client'; + +import Chat from '@/components/ai/chat'; +import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; +import { NotificationBell } from '@/components/notifications/notification-bell'; +import { OrganizationSwitcher } from '@/components/organization-switcher'; +import { SidebarProvider } from '@/context/sidebar-context'; +import { signOut } from '@/utils/auth-client'; +import { + CertificateCheck, + Chemistry, + Dashboard, + Document, + Group, + Integration, + ListChecked, + Logout, + Policy, + Security, + Settings, + ShoppingBag, + Task, + TaskComplete, + Warning, +} from '@carbon/icons-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@comp/ui/dropdown-menu'; +import type { Onboarding, Organization } from '@db'; +import type { CommandSearchGroup } from '@trycompai/design-system'; +import { + AppShell, + AppShellAIChatTrigger, + AppShellBody, + AppShellContent, + AppShellMain, + AppShellNavbar, + AppShellRail, + AppShellRailItem, + AppShellSidebar, + AppShellSidebarHeader, + AppShellUserMenu, + Avatar, + AvatarFallback, + AvatarImage, + CommandSearch, + HStack, + Logo, + Text, + ThemeToggle, +} from '@trycompai/design-system'; +import { useTheme } from 'next-themes'; +import Link from 'next/link'; +import { usePathname, useRouter } from 'next/navigation'; +import { Suspense } from 'react'; +import { AppSidebar } from './AppSidebar'; +import { ConditionalOnboardingTracker } from './ConditionalOnboardingTracker'; + +interface AppShellWrapperProps { + children: React.ReactNode; + organization: Organization; + organizations: Organization[]; + logoUrls: Record; + onboarding: Onboarding | null; + isCollapsed: boolean; + isQuestionnaireEnabled: boolean; + isTrustNdaEnabled: boolean; + hasAuditorRole: boolean; + isOnlyAuditor: boolean; + user: { + name: string | null; + email: string; + image: string | null; + }; +} + +export function AppShellWrapper({ + children, + organization, + organizations, + logoUrls, + onboarding, + isCollapsed, + isQuestionnaireEnabled, + isTrustNdaEnabled, + hasAuditorRole, + isOnlyAuditor, + user, +}: AppShellWrapperProps) { + const { theme, setTheme } = useTheme(); + const pathname = usePathname(); + const router = useRouter(); + const isSettingsActive = pathname?.startsWith(`/${organization.id}/settings`); + + const searchGroups: CommandSearchGroup[] = [ + { + id: 'navigation', + label: 'Navigation', + items: [ + { + id: 'overview', + label: 'Overview', + icon: , + onSelect: () => router.push(`/${organization.id}/frameworks`), + keywords: ['dashboard', 'home', 'frameworks'], + }, + ...(hasAuditorRole + ? [ + { + id: 'auditor', + label: 'Auditor View', + icon: , + onSelect: () => router.push(`/${organization.id}/auditor`), + keywords: ['audit', 'review'], + }, + ] + : []), + ...(organization.advancedModeEnabled + ? [ + { + id: 'controls', + label: 'Controls', + icon: , + onSelect: () => router.push(`/${organization.id}/controls`), + keywords: ['security', 'compliance'], + }, + ] + : []), + { + id: 'policies', + label: 'Policies', + icon: , + onSelect: () => router.push(`/${organization.id}/policies`), + keywords: ['policy', 'documents'], + }, + { + id: 'evidence', + label: 'Evidence', + icon: , + onSelect: () => router.push(`/${organization.id}/tasks`), + keywords: ['tasks', 'evidence', 'artifacts'], + }, + ...(isTrustNdaEnabled + ? [ + { + id: 'trust', + label: 'Trust', + icon: , + onSelect: () => router.push(`/${organization.id}/trust`), + keywords: ['trust center', 'portal'], + }, + ] + : []), + { + id: 'people', + label: 'People', + icon: , + onSelect: () => router.push(`/${organization.id}/people/all`), + keywords: ['users', 'team', 'members', 'employees'], + }, + { + id: 'risks', + label: 'Risks', + icon: , + onSelect: () => router.push(`/${organization.id}/risk`), + keywords: ['risk management', 'assessment'], + }, + { + id: 'vendors', + label: 'Vendors', + icon: , + onSelect: () => router.push(`/${organization.id}/vendors`), + keywords: ['suppliers', 'third party'], + }, + ...(isQuestionnaireEnabled + ? [ + { + id: 'questionnaire', + label: 'Questionnaire', + icon: , + onSelect: () => router.push(`/${organization.id}/questionnaire`), + keywords: ['survey', 'questions'], + }, + ] + : []), + ...(!isOnlyAuditor + ? [ + { + id: 'integrations', + label: 'Integrations', + icon: , + onSelect: () => router.push(`/${organization.id}/integrations`), + keywords: ['connect', 'apps', 'services'], + }, + ] + : []), + { + id: 'cloud-tests', + label: 'Cloud Tests', + icon: , + onSelect: () => router.push(`/${organization.id}/cloud-tests`), + keywords: ['testing', 'cloud', 'infrastructure'], + }, + ], + }, + ...(!isOnlyAuditor + ? [ + { + id: 'settings', + label: 'Settings', + items: [ + { + id: 'settings-general', + label: 'General Settings', + icon: , + onSelect: () => router.push(`/${organization.id}/settings`), + keywords: ['preferences', 'configuration'], + }, + ], + }, + ] + : []), + ]; + + return ( + + } defaultSidebarOpen={!isCollapsed}> + + + + + / + + + } + centerContent={} + endContent={ + + + + + + + {user.image && } + + {user.name?.charAt(0)?.toUpperCase() || user.email?.charAt(0)?.toUpperCase()} + + + + +
+ + {user.name} + + + {user.email} + +
+ + + + + + Settings + + + + +
+ Theme + setTheme(isDark ? 'dark' : 'light')} + /> +
+ + signOut()}> + + Log out + +
+
+
+ } + /> + + + + } + label="Compliance" + /> + + {!isOnlyAuditor && ( + + } + label="Settings" + /> + + )} + + + + + + + + + {onboarding?.triggerJobId && } + {children} + + + + + + + +
+
+ ); +} diff --git a/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx new file mode 100644 index 000000000..e5013b3a6 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/components/AppSidebar.tsx @@ -0,0 +1,157 @@ +'use client'; + +import { + Chemistry, + Dashboard, + Document, + Group, + Integration, + ListChecked, + Policy, + Security, + ShoppingBag, + Task, + TaskComplete, + Warning, +} from '@carbon/icons-react'; +import type { Organization } from '@db'; +import { AppShellNav, AppShellNavItem } from '@trycompai/design-system'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +interface NavItem { + id: string; + path: string; + name: string; + icon: React.ReactNode; + hidden?: boolean; +} + +interface AppSidebarProps { + organization: Organization; + isQuestionnaireEnabled: boolean; + isTrustNdaEnabled: boolean; + hasAuditorRole: boolean; + isOnlyAuditor: boolean; +} + +export function AppSidebar({ + organization, + isQuestionnaireEnabled, + isTrustNdaEnabled, + hasAuditorRole, + isOnlyAuditor, +}: AppSidebarProps) { + const pathname = usePathname(); + + const navItems: NavItem[] = [ + { + id: 'frameworks', + path: `/${organization.id}/frameworks`, + name: 'Overview', + icon: , + }, + { + id: 'auditor', + path: `/${organization.id}/auditor`, + name: 'Auditor View', + icon: , + hidden: !hasAuditorRole, + }, + { + id: 'controls', + path: `/${organization.id}/controls`, + name: 'Controls', + icon: , + hidden: !organization.advancedModeEnabled, + }, + { + id: 'policies', + path: `/${organization.id}/policies`, + name: 'Policies', + icon: , + }, + { + id: 'tasks', + path: `/${organization.id}/tasks`, + name: 'Evidence', + icon: , + }, + { + id: 'trust', + path: `/${organization.id}/trust`, + name: 'Trust', + icon: , + hidden: !isTrustNdaEnabled, + }, + { + id: 'people', + path: `/${organization.id}/people/all`, + name: 'People', + icon: , + }, + { + id: 'risk', + path: `/${organization.id}/risk`, + name: 'Risks', + icon: , + }, + { + id: 'vendors', + path: `/${organization.id}/vendors`, + name: 'Vendors', + icon: , + }, + { + id: 'questionnaire', + path: `/${organization.id}/questionnaire`, + name: 'Questionnaire', + icon: , + hidden: !isQuestionnaireEnabled, + }, + { + id: 'integrations', + path: `/${organization.id}/integrations`, + name: 'Integrations', + icon: , + hidden: isOnlyAuditor, + }, + { + id: 'tests', + path: `/${organization.id}/cloud-tests`, + name: 'Cloud Tests', + icon: , + }, + ]; + + const isPathActive = (itemPath: string) => { + const itemPathParts = itemPath.split('/').filter(Boolean); + const itemBaseSegment = itemPathParts.length > 1 ? itemPathParts[1] : ''; + + const currentPathParts = pathname.split('/').filter(Boolean); + const currentBaseSegment = currentPathParts.length > 1 ? currentPathParts[1] : ''; + + if (itemPath === `/${organization.id}` || itemPath === `/${organization.id}/implementation`) { + return ( + pathname === `/${organization.id}` || + pathname?.startsWith(`/${organization.id}/implementation`) + ); + } + + return itemBaseSegment === currentBaseSegment; + }; + + const visibleItems = navItems.filter((item) => !item.hidden); + + return ( + + {visibleItems.map((item) => ( + + + {item.name} + + + ))} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx index 64e52dbea..e9e73b20b 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/[controlId]/components/table/ControlRequirementsTable.tsx @@ -32,7 +32,7 @@ export function ControlRequirementsTable({ data }: DataTableProps) { switch (requirement.policy ? 'policy' : 'task') { case 'policy': if (requirement.policy?.id) { - router.push(`/${orgId}/policies/all/${requirement.policy.id}`); + router.push(`/${orgId}/policies/${requirement.policy.id}`); } break; case 'task': diff --git a/apps/app/src/app/(app)/[orgId]/controls/page.tsx b/apps/app/src/app/(app)/[orgId]/controls/page.tsx index 0d9360a04..4216c89a2 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/page.tsx @@ -1,7 +1,7 @@ -import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { getValidFilters } from '@/lib/data-table'; import { auth } from '@/utils/auth'; import { db } from '@db'; +import { PageHeader, PageLayout, Stack } from '@trycompai/design-system'; import { Metadata } from 'next'; import { headers } from 'next/headers'; import { SearchParams } from 'nuqs'; @@ -36,14 +36,17 @@ export default async function ControlsPage({ ...props }: ControlTableProps) { const requirements = await getRequirements(); return ( - - - + + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx index 03acf2135..59fbe5ba9 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/ComplianceProgressChart.tsx @@ -22,8 +22,8 @@ interface ComplianceProgressChartProps { } const CHART_COLORS = { - score: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + score: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; export function ComplianceProgressChart({ data }: ComplianceProgressChartProps) { @@ -128,7 +128,7 @@ export function ComplianceProgressChart({ data }: ComplianceProgressChartProps) cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx index d34afa091..e995ef5cc 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/PeopleChart.tsx @@ -22,8 +22,8 @@ interface PeopleChartProps { } const CHART_COLORS = { - completed: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + completed: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; export function PeopleChart({ data }: PeopleChartProps) { @@ -126,7 +126,7 @@ export function PeopleChart({ data }: PeopleChartProps) { cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx index 2272645ac..b375b9ef4 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/PoliciesChart.tsx @@ -22,8 +22,8 @@ interface PoliciesChartProps { } const CHART_COLORS = { - score: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + score: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; // Custom tooltip component for the pie chart @@ -142,7 +142,7 @@ export function PoliciesChart({ data }: PoliciesChartProps) { cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx index f582c33ee..d739e0e84 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/TasksChart.tsx @@ -22,8 +22,8 @@ interface TasksChartProps { } const CHART_COLORS = { - done: 'hsl(var(--chart-primary))', - remaining: 'hsl(var(--muted))', + done: 'var(--color-primary)', + remaining: 'var(--color-muted)', }; export function TasksChart({ data }: TasksChartProps) { @@ -126,7 +126,7 @@ export function TasksChart({ data }: TasksChartProps) { cy={viewBox.cy} r={32} fill="none" - stroke="hsl(var(--border))" + stroke="var(--color-border)" strokeWidth={1} strokeDasharray="2,2" /> diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx index 9f7cd2347..d453d36df 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx @@ -1,7 +1,9 @@ +import { PageHeader, PageLayout } from '@trycompai/design-system'; + export default async function Layout({ children }: { children: React.ReactNode }) { return ( -
-
{children}
-
+ } padding="default"> + {children} + ); } diff --git a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx b/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx deleted file mode 100644 index e11597e51..000000000 --- a/apps/app/src/app/(app)/[orgId]/integrations/layout.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function IntegrationsLayout({ children }: { children: React.ReactNode }) { - return children; -} diff --git a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx index fd70dd3b2..50de6920d 100644 --- a/apps/app/src/app/(app)/[orgId]/integrations/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/integrations/page.tsx @@ -1,4 +1,5 @@ import { db } from '@db'; +import { PageHeader, PageLayout, Stack } from '@trycompai/design-system'; import { PlatformIntegrations } from './components/PlatformIntegrations'; export default async function IntegrationsPage() { @@ -15,20 +16,11 @@ export default async function IntegrationsPage() { }); return ( -
- {/* Header */} -
-
-

Integrations

- -
-

- Connect your tools to automate compliance checks and evidence collection. -

-
- - {/* Unified Integrations List */} - -
+ + + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/layout.tsx index d14f95951..237edeb67 100644 --- a/apps/app/src/app/(app)/[orgId]/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/layout.tsx @@ -1,19 +1,15 @@ -import { AnimatedLayout } from '@/components/animated-layout'; -import { CheckoutCompleteDialog } from '@/components/dialogs/checkout-complete-dialog'; -import { Header } from '@/components/header'; -import { AssistantSheet } from '@/components/sheets/assistant-sheet'; -import { Sidebar } from '@/components/sidebar'; +import { getFeatureFlags } from '@/app/posthog'; +import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; import { TriggerTokenProvider } from '@/components/trigger-token-provider'; -import { SidebarProvider } from '@/context/sidebar-context'; +import { getOrganizations } from '@/data/getOrganizations'; import { auth } from '@/utils/auth'; +import { GetObjectCommand } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { db, Role } from '@db'; import dynamic from 'next/dynamic'; import { cookies, headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { Suspense } from 'react'; -import { ConditionalOnboardingTracker } from './components/ConditionalOnboardingTracker'; -import { ConditionalPaddingWrapper } from './components/ConditionalPaddingWrapper'; -import { DynamicMinHeight } from './components/DynamicMinHeight'; +import { AppShellWrapper } from './components/AppShellWrapper'; // Helper to safely parse comma-separated roles string function parseRolesString(rolesStr: string | null | undefined): Role[] { @@ -39,7 +35,7 @@ export default async function Layout({ const cookieStore = await cookies(); const isCollapsed = cookieStore.get('sidebar-collapsed')?.value === 'true'; - let publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; + const publicAccessToken = cookieStore.get('publicAccessToken')?.value || undefined; // Get headers once to avoid multiple async calls const requestHeaders = await headers(); @@ -118,25 +114,70 @@ export default async function Layout({ }, }); + // Fetch organizations and feature flags for sidebar + const { organizations } = await getOrganizations(); + + // Generate logo URLs for all organizations + const logoUrls: Record = {}; + if (s3Client && APP_AWS_ORG_ASSETS_BUCKET) { + await Promise.all( + organizations.map(async (org) => { + if (org.logo) { + try { + const command = new GetObjectCommand({ + Bucket: APP_AWS_ORG_ASSETS_BUCKET, + Key: org.logo, + }); + logoUrls[org.id] = await getSignedUrl(s3Client, command, { expiresIn: 3600 }); + } catch { + // Logo not available + } + } + }), + ); + } + + // Check feature flags for menu items + let isQuestionnaireEnabled = false; + let isTrustNdaEnabled = false; + if (session?.user?.id) { + const flags = await getFeatureFlags(session.user.id); + isQuestionnaireEnabled = flags['ai-vendor-questionnaire'] === true; + isTrustNdaEnabled = + flags['is-trust-nda-enabled'] === true || flags['is-trust-nda-enabled'] === 'true'; + } + + // Check auditor role + const hasAuditorRole = roles.includes(Role.auditor); + const isOnlyAuditor = hasAuditorRole && roles.length === 1; + + // User data for navbar + const user = { + name: session.user.name, + email: session.user.email, + image: session.user.image, + }; + return ( - - } isCollapsed={isCollapsed}> - {onboarding?.triggerJobId && } -
- - {children} - - - - - - - - + + {children} + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/loading.tsx index e8354955a..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/loading.tsx @@ -1,9 +1,5 @@ -import Loader from '@/components/ui/loader'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( -
- -
- ); + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx index 25bb950b2..0f7eea54f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Employee.tsx @@ -2,6 +2,7 @@ import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db'; +import { Stack } from '@trycompai/design-system'; import type { FleetPolicy, Host } from '../../devices/types'; import { EmployeeDetails } from './EmployeeDetails'; import { EmployeeTasks } from './EmployeeTasks'; @@ -28,7 +29,7 @@ export function Employee({ canEdit, }: EmployeeDetailsProps) { return ( -
+ -
+ ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx index e12d1f074..d9a16b72e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeDetails.tsx @@ -1,10 +1,10 @@ 'use client'; import { Button } from '@comp/ui/button'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { Form } from '@comp/ui/form'; import type { Departments, Member, User } from '@db'; import { zodResolver } from '@hookform/resolvers/zod'; +import { Section, Stack } from '@trycompai/design-system'; import { Save } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; @@ -102,16 +102,10 @@ export const EmployeeDetails = ({ }; return ( - - - Employee Details -

- Manage employee information and department assignment -

-
+
- +
@@ -119,24 +113,24 @@ export const EmployeeDetails = ({
-
- - - +
+ +
+
- +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx index fc96b5309..84db2e6b2 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx @@ -1,9 +1,19 @@ +'use client'; + import type { TrainingVideo } from '@/lib/data/training-videos'; import type { EmployeeTrainingVideoCompletion, Member, Policy, User } from '@db'; import { cn } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; +import { + Section, + Stack, + Tabs, + TabsContent, + TabsList, + TabsTrigger, + Text, +} from '@trycompai/design-system'; import { AlertCircle, CheckCircle2, XCircle } from 'lucide-react'; import type { FleetPolicy, Host } from '../../devices/types'; @@ -25,30 +35,20 @@ export const EmployeeTasks = ({ fleetPolicies: FleetPolicy[]; }) => { return ( - - - -
-

Employee Tasks

-

- View and manage employee tasks and their status -

-
-
-
- - - +
+ + + Policies Training Videos Device -
+ {policies.length === 0 ? ( -
-

No policies required to sign.

+
+ No policies required to sign.
) : ( policies.map((policy) => { @@ -57,28 +57,28 @@ export const EmployeeTasks = ({ return (
-

+
{isCompleted ? ( ) : ( - + )} - {policy.name} -

+ {policy.name} +
); }) )} -
+
-
+ {trainingVideos.length === 0 ? ( -
-

No training videos required to watch.

+
+ No training videos required to watch.
) : ( trainingVideos.map((video) => { @@ -87,38 +87,36 @@ export const EmployeeTasks = ({ return (
-

+
{isCompleted ? ( -
- -
+ ) : ( - + )} - {video.metadata.title} + {video.metadata.title}
{isCompleted && ( - + Completed -{' '} {video.completedAt && new Date(video.completedAt).toLocaleDateString()} - + )} -

+
); }) )} -
+
{host ? ( - {host.computer_name}'s Policies + {host.computer_name}'s Policies {fleetPolicies.map((policy) => ( @@ -126,19 +124,19 @@ export const EmployeeTasks = ({ key={policy.id} className={cn( 'hover:bg-muted/50 flex items-center justify-between rounded-md border border-l-4 p-3 shadow-sm transition-colors', - policy.response === 'pass' ? 'border-l-primary' : 'border-l-red-500', + policy.response === 'pass' ? 'border-l-primary' : 'border-l-destructive', )} > -

{policy.name}

+ {policy.name} {policy.response === 'pass' ? (
- Pass + Pass
) : ( -
+
- Fail + Fail
)}
@@ -146,13 +144,13 @@ export const EmployeeTasks = ({
) : ( -
-

No device found.

+
+ No device found.
)} - - - + + +
); }; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx index fe8c387cb..7368291df 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/Status.tsx @@ -12,8 +12,8 @@ const STATUS_OPTIONS: { value: EmployeeStatusType; label: string }[] = [ // Status color hex values for charts export const EMPLOYEE_STATUS_HEX_COLORS: Record = { - inactive: '#ef4444', - active: 'hsl(var(--chart-primary))', + inactive: 'var(--color-destructive)', + active: 'var(--color-primary)', }; export const Status = ({ diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx deleted file mode 100644 index 4855864ec..000000000 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -interface LayoutProps { - children: React.ReactNode; -} - -export default async function Layout({ children }: LayoutProps) { - return ( -
-
{children}
-
- ); -} diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx index 4f38f9a92..008d2c03c 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx @@ -1,9 +1,10 @@ import Loader from '@/components/ui/loader'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; export default function Loading() { return ( -
+ } padding="default"> -
+
); } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index d7e9dc9c5..486da9a02 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -1,6 +1,5 @@ import { auth } from '@/utils/auth'; -import PageWithBreadcrumb from '@/components/pages/PageWithBreadcrumb'; import { type TrainingVideo, trainingVideos as trainingVideosData, @@ -8,6 +7,7 @@ import { import { getFleetInstance } from '@/lib/fleet'; import type { EmployeeTrainingVideoCompletion, Member, User } from '@db'; import { db } from '@db'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; import type { Metadata } from 'next'; import { headers } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; @@ -50,11 +50,16 @@ export default async function EmployeeDetailsPage({ const { fleetPolicies, device } = await getFleetPolicies(employee); return ( - + } > - + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx index 900da78c9..b529666ff 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -2,7 +2,7 @@ import { Edit, Laptop, MoreHorizontal, Trash2 } from 'lucide-react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { useRef, useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; @@ -56,8 +56,16 @@ function getInitials(name?: string | null, email?: string | null): string { return '??'; } -export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canEdit, isCurrentUserOwner }: MemberRowProps) { +export function MemberRow({ + member, + onRemove, + onRemoveDevice, + onUpdateRole, + canEdit, + isCurrentUserOwner, +}: MemberRowProps) { const params = useParams<{ orgId: string }>(); + const router = useRouter(); const { orgId } = params; const [isRemoveAlertOpen, setIsRemoveAlertOpen] = useState(false); @@ -90,6 +98,9 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE const isEmployee = currentRoles.includes('employee'); const isContractor = currentRoles.includes('contractor'); + const isDeactivated = member.deactivated; + const canViewProfile = !isDeactivated; + const profileHref = canViewProfile ? `/${orgId}/people/${memberId}` : null; const handleDialogItemSelect = () => { focusRef.current = dropdownTriggerRef.current; @@ -140,11 +151,11 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE } }; - const isDeactivated = member.deactivated; - return ( <> -
+
@@ -152,17 +163,35 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE
- - {memberName} - + {profileHref ? ( + + {memberName} + + ) : ( + + {memberName} + + )} {isDeactivated && ( - + Deactivated )} - {!isDeactivated && (isEmployee || isContractor) && ( + {profileHref && ( ({'View Profile'}) @@ -175,7 +204,11 @@ export function MemberRow({ member, onRemove, onRemoveDevice, onUpdateRole, canE
{currentRoles.map((role) => ( - + {(() => { switch (role) { case 'owner': diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index 6c5c7e7ea..0ecf12b94 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -10,10 +10,10 @@ import { toast } from 'sonner'; import { authClient } from '@/utils/auth-client'; import { Button } from '@comp/ui/button'; import { Card, CardContent } from '@comp/ui/card'; -import { Input } from '@comp/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; import { Separator } from '@comp/ui/separator'; import type { Invitation, Role } from '@db'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@trycompai/design-system'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; @@ -250,12 +250,16 @@ export function TeamMembersClient({
- setSearchQuery(e.target.value || null)} - leftIcon={} - /> + + + + + setSearchQuery(e.target.value || null)} + /> + {searchQuery && (
; +export default function Layout({ children }: { children: React.ReactNode }) { + return children; } diff --git a/apps/app/src/app/(app)/[orgId]/people/all/page.tsx b/apps/app/src/app/(app)/[orgId]/people/all/page.tsx index a1687716c..9399417fb 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/page.tsx @@ -1,17 +1,6 @@ -import PageCore from '@/components/pages/PageCore.tsx'; -import type { Metadata } from 'next'; -import { TeamMembers } from './components/TeamMembers'; +import { redirect } from 'next/navigation'; -export default async function Members() { - return ( - - - - ); -} - -export async function generateMetadata(): Promise { - return { - title: 'People', - }; +export default async function AllPeoplePage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + redirect(`/${orgId}/people`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx new file mode 100644 index 000000000..46b6eb0f0 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { + PageHeader, + PageLayout, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@trycompai/design-system'; +import type { ReactNode } from 'react'; + +interface PeoplePageTabsProps { + peopleContent: ReactNode; + employeeTasksContent: ReactNode | null; + devicesContent: ReactNode; + showEmployeeTasks: boolean; +} + +export function PeoplePageTabs({ + peopleContent, + employeeTasksContent, + devicesContent, + showEmployeeTasks, +}: PeoplePageTabsProps) { + return ( + + + People + {showEmployeeTasks && ( + Employee Tasks + )} + Employee Devices + + } + /> + } + > + {peopleContent} + {showEmployeeTasks && ( + {employeeTasksContent} + )} + {devicesContent} + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx index 166937bb0..6c42f12ea 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/components/EmployeeCompletionChart.tsx @@ -1,7 +1,7 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; -import { Input } from '@comp/ui/input'; +import { InputGroup, InputGroupAddon, InputGroupInput } from '@trycompai/design-system'; import { ExternalLink, Search } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -23,10 +23,10 @@ interface EmployeeCompletionChartProps { showAll?: boolean; } -// Define colors for the chart +// Define colors for the chart using DS semantic colors const taskColors = { - completed: 'bg-primary', // Green/Blue - incomplete: 'bg-[var(--chart-open)]', // Yellow + completed: 'bg-success', // Green - completed/good state + incomplete: 'bg-warning', // Yellow - needs action }; interface EmployeeTaskStats { @@ -191,12 +191,16 @@ export function EmployeeCompletionChart({ {'Employee Task Completion'} {showAll && (
- setSearchTerm(e.target.value)} - leftIcon={} - /> + + + + + setSearchTerm(e.target.value)} + /> +
)} @@ -243,11 +247,11 @@ export function EmployeeCompletionChart({
-
+
{'Completed'}
-
+
{'Not Completed'}
diff --git a/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx b/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx index 421ad19d7..dc9c10f10 100644 --- a/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/dashboard/page.tsx @@ -1,12 +1,6 @@ -import type { Metadata } from 'next'; -import { EmployeesOverview } from './components/EmployeesOverview'; +import { redirect } from 'next/navigation'; -export default async function PeopleOverviewPage() { - return ; -} - -export async function generateMetadata(): Promise { - return { - title: 'People', - }; +export default async function DashboardPage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + redirect(`/${orgId}/people`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx b/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx index 39d608f43..2f5d939a1 100644 --- a/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/devices/page.tsx @@ -1,24 +1,6 @@ -import { DeviceComplianceChart } from './components/DeviceComplianceChart'; -import { EmployeeDevicesList } from './components/EmployeeDevicesList'; -import { getEmployeeDevices } from './data'; -import type { Host } from './types'; +import { redirect } from 'next/navigation'; -export default async function EmployeeDevicesPage() { - let devices: Host[] = []; - - try { - const fetchedDevices = await getEmployeeDevices(); - devices = fetchedDevices || []; - } catch (error) { - console.error('Error fetching employee devices:', error); - // Return empty array on error to render empty state - devices = []; - } - - return ( -
- - -
- ); +export default async function DevicesPage({ params }: { params: Promise<{ orgId: string }> }) { + const { orgId } = await params; + redirect(`/${orgId}/people`); } diff --git a/apps/app/src/app/(app)/[orgId]/people/layout.tsx b/apps/app/src/app/(app)/[orgId]/people/layout.tsx index 44640914e..c1492b79b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/layout.tsx @@ -1,57 +1,3 @@ -import { auth } from '@/utils/auth'; -import { SecondaryMenu } from '@comp/ui/secondary-menu'; -import { db } from '@db'; -import { headers } from 'next/headers'; -import { redirect } from 'next/navigation'; - -export default async function Layout({ children }: { children: React.ReactNode }) { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const orgId = session?.session.activeOrganizationId; - - if (!orgId) { - return redirect('/'); - } - - // Fetch all members first - const allMembers = await db.member.findMany({ - where: { - organizationId: orgId, - deactivated: false, - }, - }); - - const employees = allMembers.filter((member) => { - const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; - return roles.includes('employee') || roles.includes('contractor'); - }); - - return ( -
- 0 - ? [ - { - path: `/${orgId}/people/dashboard`, - label: 'Employee Tasks', - }, - ] - : []), - { - path: `/${orgId}/people/devices`, - label: 'Employee Devices', - }, - ]} - /> - -
{children}
-
- ); +export default function Layout({ children }: { children: React.ReactNode }) { + return children; } diff --git a/apps/app/src/app/(app)/[orgId]/people/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/loading.tsx index 4f38f9a92..7a4aded9f 100644 --- a/apps/app/src/app/(app)/[orgId]/people/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/loading.tsx @@ -1,9 +1,5 @@ -import Loader from '@/components/ui/loader'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( -
- -
- ); + return } loading={true} />; } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index ffd9cb2fd..b15f9784e 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -1,10 +1,69 @@ +import { auth } from '@/utils/auth'; +import { db } from '@db'; +import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; +import { TeamMembers } from './all/components/TeamMembers'; +import { PeoplePageTabs } from './components/PeoplePageTabs'; +import { EmployeesOverview } from './dashboard/components/EmployeesOverview'; +import { DeviceComplianceChart } from './devices/components/DeviceComplianceChart'; +import { EmployeeDevicesList } from './devices/components/EmployeeDevicesList'; +import { getEmployeeDevices } from './devices/data'; +import type { Host } from './devices/types'; -export default async function Page({ - params, -}: { - params: Promise<{ locale: string; orgId: string }>; -}) { +export default async function PeoplePage({ params }: { params: Promise<{ orgId: string }> }) { const { orgId } = await params; - return redirect(`/${orgId}/people/all`); + + const session = await auth.api.getSession({ + headers: await headers(), + }); + + if (!session?.session.activeOrganizationId) { + return redirect('/'); + } + + // Check if there are employees to show the Employee Tasks tab + const allMembers = await db.member.findMany({ + where: { + organizationId: orgId, + deactivated: false, + }, + }); + + const employees = allMembers.filter((member) => { + const roles = member.role.includes(',') ? member.role.split(',') : [member.role]; + return roles.includes('employee') || roles.includes('contractor'); + }); + + const showEmployeeTasks = employees.length > 0; + + // Fetch devices data + let devices: Host[] = []; + try { + const fetchedDevices = await getEmployeeDevices(); + devices = fetchedDevices || []; + } catch (error) { + console.error('Error fetching employee devices:', error); + devices = []; + } + + return ( + } + employeeTasksContent={showEmployeeTasks ? : null} + devicesContent={ + <> + + + + } + showEmployeeTasks={showEmployeeTasks} + /> + ); +} + +export async function generateMetadata(): Promise { + return { + title: 'People', + }; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/PolicyChartsClient.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/PolicyChartsClient.tsx new file mode 100644 index 000000000..bbc67613e --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/PolicyChartsClient.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Grid } from '@trycompai/design-system'; +import { + type PoliciesOverview, + usePoliciesOverview, +} from '../hooks/usePoliciesOverview'; +import { PolicyAssigneeChart } from './policy-assignee-chart'; +import { PolicyStatusChart } from './policy-status-chart'; + +interface PolicyChartsClientProps { + organizationId: string; + initialData: PoliciesOverview | null; +} + +export function PolicyChartsClient({ + organizationId, + initialData, +}: PolicyChartsClientProps) { + const { overview } = usePoliciesOverview({ + organizationId, + initialData, + }); + + return ( + + + + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx index ed01f8f87..8c219dd43 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-assignee-chart.tsx @@ -2,16 +2,15 @@ import * as React from 'react'; -import { Badge } from '@comp/ui/badge'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from '@comp/ui/chart'; -import { Users } from 'lucide-react'; -import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from 'recharts'; +import { Card, HStack, Text } from '@trycompai/design-system'; +import { UserMultiple } from '@trycompai/design-system/icons'; +import { Bar, BarChart, Cell, LabelList, ResponsiveContainer, XAxis, YAxis } from 'recharts'; interface AssigneeData { id: string; @@ -27,186 +26,129 @@ interface PolicyAssigneeChartProps { data?: AssigneeData[] | null; } -const CHART_COLORS = { - published: 'hsl(var(--chart-positive))', // green - draft: 'hsl(var(--chart-neutral))', // yellow - archived: 'hsl(var(--chart-warning))', // gray - needs_review: 'hsl(var(--chart-destructive))', // red +// Primary color for bars +const BAR_COLOR = 'oklch(0.6 0.16 145)'; // success/green - matches "published" theme + +// Status colors for legend +const STATUS_COLORS = { + published: 'oklch(0.6 0.16 145)', + draft: 'oklch(0.75 0.15 85)', + needs_review: 'oklch(0.58 0.22 27)', + archived: 'oklch(0.556 0 0)', }; export function PolicyAssigneeChart({ data }: PolicyAssigneeChartProps) { - // Sort assignees by total policies (descending) + // Sort assignees by total policies (descending) and take top 5 const sortedData = React.useMemo(() => { if (!data || data.length === 0) return []; - return [...data] - .sort((a, b) => b.total - a.total) - .slice(0, 4) - .reverse(); + return [...data].sort((a, b) => b.total - a.total).slice(0, 5); }, [data]); - // Calculate total policies and top assignee - const totalPolicies = React.useMemo(() => { - if (!data || data.length === 0) return 0; - return data.reduce((sum, item) => sum + item.total, 0); - }, [data]); - - const topAssignee = React.useMemo(() => { - if (!data || data.length === 0) return null; - return data.reduce((prev, current) => (prev.total > current.total ? prev : current)); - }, [data]); + // Calculate totals for footer + const totalAssignees = data?.length ?? 0; + const totalAssignedPolicies = data?.reduce((sum, a) => sum + a.total, 0) ?? 0; if (!data || data.length === 0) { return ( - - -
- {'Policies by Assignee'} - - - Distribution - -
-
- -
-
- -
-

- No policies assigned to users -

-
-
- -
- + +
+ + + No policies assigned to users + +
); } const chartData = sortedData.map((item) => ({ - name: item.name, - published: item.published, - draft: item.draft, - archived: item.archived, - needs_review: item.needs_review, + name: item.name.split(' ')[0], // First name only for cleaner display + fullName: item.name, + total: item.total, })); const chartConfig = { - published: { - label: 'Published', - color: CHART_COLORS.published, - }, - draft: { - label: 'Draft', - color: CHART_COLORS.draft, - }, - archived: { - label: 'Archived', - color: CHART_COLORS.archived, - }, - needs_review: { - label: 'Needs Review', - color: CHART_COLORS.needs_review, + total: { + label: 'Policies', + color: BAR_COLOR, }, } satisfies ChartConfig; - return ( - - -
- {'Policies by Assignee'} -
+ // Dynamic height based on number of assignees + const barHeight = 28; + const chartHeight = Math.max(sortedData.length * barHeight, 80); -
-
0 ? 100 : 0}%`, - }} - /> -
- - -
-
- Assignee - Policy Count -
- - - - - value.split(' ')[0]} - fontSize={12} - stroke="hsl(var(--muted-foreground))" - /> - } /> - - - - 5; + + return ( + +
+ + + + + + ( + + {props.payload.fullName}: {value} policies + + )} + /> + } + /> + + {chartData.map((entry, index) => ( + + ))} + - - - -
- - -
- {Object.entries(chartConfig).map(([key, config]) => ( -
-
- {config.label} -
- ))} -
- + + + + +
+
+ + + {Object.entries(STATUS_COLORS).map(([status, color]) => ( + +
+ + {status === 'needs_review' + ? 'Review' + : status.charAt(0).toUpperCase() + status.slice(1)} + + + ))} + + + {showingLimited ? `Top 5 of ${totalAssignees}` : `${totalAssignees} assignees`} + + +
); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx index e2bfbd187..dac65345e 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/components/policy-status-chart.tsx @@ -3,15 +3,14 @@ import * as React from 'react'; import { Label, Pie, PieChart } from 'recharts'; -import { Badge } from '@comp/ui/badge'; -import { Card, CardContent, CardFooter, CardHeader, CardTitle } from '@comp/ui/card'; import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, } from '@comp/ui/chart'; -import { Info } from 'lucide-react'; +import { Card, HStack, Stack, Text } from '@trycompai/design-system'; +import { Information } from '@trycompai/design-system/icons'; interface PolicyOverviewData { totalPolicies: number; @@ -25,85 +24,67 @@ interface PolicyStatusChartProps { data?: PolicyOverviewData | null; } +// Using oklch values from DS globals.css const CHART_COLORS = { - published: 'hsl(var(--chart-positive))', // green - draft: 'hsl(var(--chart-neutral))', // yellow - archived: 'hsl(var(--chart-warning))', // gray - needs_review: 'hsl(var(--chart-destructive))', // red + published: 'oklch(0.6 0.16 145)', // --success (green) + draft: 'oklch(0.75 0.15 85)', // --warning (yellow) + needs_review: 'oklch(0.58 0.22 27)', // --destructive (red) + archived: 'oklch(0.556 0 0)', // --muted-foreground (gray) }; -// Custom tooltip component for the pie chart -const StatusTooltip = ({ active, payload }: any) => { - if (active && payload && payload.length) { - const data = payload[0].payload; - return ( -
-

{data.name}

-

- Count: {data.value} -

-
- ); - } - return null; +const STATUS_LABELS: Record = { + published: 'Published', + draft: 'Draft', + needs_review: 'Review', + archived: 'Archived', }; export function PolicyStatusChart({ data }: PolicyStatusChartProps) { - const chartData = React.useMemo(() => { + // All statuses for the legend (always show all) + const allStatuses = React.useMemo(() => { if (!data) return []; - const items = [ + return [ { + key: 'published', name: 'Published', value: data.publishedPolicies, fill: CHART_COLORS.published, }, { + key: 'draft', name: 'Draft', value: data.draftPolicies, fill: CHART_COLORS.draft, }, { + key: 'needs_review', name: 'Needs Review', value: data.needsReviewPolicies, fill: CHART_COLORS.needs_review, }, { + key: 'archived', name: 'Archived', value: data.archivedPolicies, fill: CHART_COLORS.archived, }, ]; - return items.filter((item) => item.value > 0); }, [data]); - // Calculate most common status - const mostCommonStatus = React.useMemo(() => { - if (!chartData.length) return null; - return chartData.reduce((prev, current) => (prev.value > current.value ? prev : current)); - }, [chartData]); + // Only non-zero values for the pie chart + const chartData = React.useMemo(() => { + return allStatuses.filter((item) => item.value > 0); + }, [allStatuses]); if (!data) { return ( - - -
- {'Policy by Status'} - - Overview - -
-
- -
-
- -
-

No policy data available

-
-
- -
- + +
+ + + No policy data available + +
); } @@ -115,52 +96,32 @@ export function PolicyStatusChart({ data }: PolicyStatusChartProps) { } satisfies ChartConfig; return ( - - -
- {'Policy by Status'} -
- -
-
-
- - - - - } /> - -