diff --git a/apps/api/src/email/templates/evidence-bulk-review-requested.tsx b/apps/api/src/email/templates/evidence-bulk-review-requested.tsx new file mode 100644 index 000000000..ce37f025f --- /dev/null +++ b/apps/api/src/email/templates/evidence-bulk-review-requested.tsx @@ -0,0 +1,137 @@ +import * as React from 'react'; +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; +import { getUnsubscribeUrl } from '@trycompai/email'; + +interface TaskItem { + title: string; + url: string; +} + +interface Props { + toName: string; + toEmail: string; + taskCount: number; + submittedByName: string; + organizationName: string; + tasksUrl: string; + tasks: TaskItem[]; +} + +export const EvidenceBulkReviewRequestedEmail = ({ + toName, + toEmail, + taskCount, + submittedByName, + organizationName, + tasksUrl, + tasks, +}: Props) => { + const unsubscribeUrl = getUnsubscribeUrl(toEmail); + const taskText = taskCount === 1 ? 'task' : 'tasks'; + + return ( + + + + + + + + {`${taskCount} ${taskText} submitted for your review`} + + + + + + + Evidence Review Requested + + + + Hello {toName}, + + + + {submittedByName} has submitted {taskCount}{' '} + {taskText} for your review in {organizationName}. + + + + Please review the evidence and approve or reject each task: + + + + {tasks.map((task, index) => ( + + {'• '} + + {task.title} + + + ))} + + + + + Review Tasks + + + + + or copy and paste this URL into your browser:{' '} + + {tasksUrl} + + + + + + Don't want to receive task assignment notifications?{' '} + + Manage your email preferences + + . + + + + + + + + + + + ); +}; + +export default EvidenceBulkReviewRequestedEmail; diff --git a/apps/api/src/email/templates/evidence-review-requested.tsx b/apps/api/src/email/templates/evidence-review-requested.tsx new file mode 100644 index 000000000..f32f19fb6 --- /dev/null +++ b/apps/api/src/email/templates/evidence-review-requested.tsx @@ -0,0 +1,119 @@ +import * as React from 'react'; +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; +import { getUnsubscribeUrl } from '@trycompai/email'; + +interface Props { + toName: string; + toEmail: string; + taskTitle: string; + submittedByName: string; + organizationName: string; + taskUrl: string; +} + +export const EvidenceReviewRequestedEmail = ({ + toName, + toEmail, + taskTitle, + submittedByName, + organizationName, + taskUrl, +}: Props) => { + const unsubscribeUrl = getUnsubscribeUrl(toEmail); + + return ( + + + + + + + + {`You've been requested to review evidence for "${taskTitle}"`} + + + + + + + Evidence Review Requested + + + + Hello {toName}, + + + + {submittedByName} has submitted evidence for{' '} + "{taskTitle}" and requested your approval in{' '} + {organizationName}. + + + + Please review the evidence and approve or reject it. + + + + + Review Evidence + + + + + or copy and paste this URL into your browser:{' '} + + {taskUrl} + + + + + + Don't want to receive task assignment notifications?{' '} + + Manage your email preferences + + . + + + + + + + + + + + ); +}; + +export default EvidenceReviewRequestedEmail; diff --git a/apps/api/src/tasks/task-notifier.service.ts b/apps/api/src/tasks/task-notifier.service.ts index a2526a877..180b6733c 100644 --- a/apps/api/src/tasks/task-notifier.service.ts +++ b/apps/api/src/tasks/task-notifier.service.ts @@ -7,6 +7,8 @@ import { TaskBulkStatusChangedEmail } from '../email/templates/task-bulk-status- import { TaskBulkAssigneeChangedEmail } from '../email/templates/task-bulk-assignee-changed'; import { TaskStatusChangedEmail } from '../email/templates/task-status-changed'; import { TaskAssigneeChangedEmail } from '../email/templates/task-assignee-changed'; +import { EvidenceReviewRequestedEmail } from '../email/templates/evidence-review-requested'; +import { EvidenceBulkReviewRequestedEmail } from '../email/templates/evidence-bulk-review-requested'; import { NovuService } from '../notifications/novu.service'; const BULK_TASK_WORKFLOW_ID = 'evidence-bulk-updated'; @@ -782,4 +784,287 @@ export class TaskNotifierService { ); } } + + async notifyEvidenceReviewRequested(params: { + organizationId: string; + taskId: string; + taskTitle: string; + submittedByUserId: string; + approverMemberId: string; + }): Promise { + const { organizationId, taskId, taskTitle, submittedByUserId, approverMemberId } = params; + + try { + const [organization, submittedByUser, approverMember] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.user.findUnique({ + where: { id: submittedByUserId }, + select: { name: true, email: true }, + }), + db.member.findUnique({ + where: { id: approverMemberId }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }), + ]); + + const organizationName = organization?.name ?? 'your organization'; + const submittedByName = + submittedByUser?.name?.trim() || + submittedByUser?.email?.trim() || + 'Someone'; + + if (!approverMember?.user?.id || !approverMember.user.email) { + this.logger.warn('Approver not found, skipping review request notification'); + return; + } + + if (approverMember.user.id === submittedByUserId) { + this.logger.log('Approver is the submitter, skipping notification'); + return; + } + + const recipient = { + id: approverMember.user.id, + name: approverMember.user.name?.trim() || approverMember.user.email?.trim() || 'User', + email: approverMember.user.email, + }; + + const isUnsubscribed = await isUserUnsubscribed( + db, + recipient.email, + 'taskAssignments', + ); + + if (isUnsubscribed) { + this.logger.log( + `Skipping notification: user ${recipient.email} is unsubscribed from task assignments`, + ); + return; + } + + const appUrl = + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL ?? + 'https://app.trycomp.ai'; + const taskUrl = `${appUrl}/${organizationId}/tasks/${taskId}`; + + // Send email notification + try { + const { id } = await sendEmail({ + to: recipient.email, + subject: `Evidence review requested: "${taskTitle}"`, + react: EvidenceReviewRequestedEmail({ + toName: recipient.name, + toEmail: recipient.email, + taskTitle, + submittedByName, + organizationName, + taskUrl, + }), + system: true, + }); + + this.logger.log( + `Evidence review request email sent to ${recipient.email} (ID: ${id})`, + ); + } catch (error) { + this.logger.error( + `Failed to send evidence review request email to ${recipient.email}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + } + + // Send in-app notification + try { + const title = 'Evidence review requested'; + const message = `${submittedByName} submitted evidence for "${taskTitle}" and requested your approval in ${organizationName}`; + + await this.novuService.trigger({ + workflowId: TASK_WORKFLOW_ID, + subscriberId: `${recipient.id}-${organizationId}`, + email: recipient.email, + payload: { + title, + message, + url: taskUrl, + }, + }); + + this.logger.log( + `[NOVU] Evidence review request in-app notification sent to ${recipient.id}`, + ); + } catch (error) { + this.logger.error( + `[NOVU] Failed to send evidence review request in-app notification to ${recipient.id}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } catch (error) { + this.logger.error( + 'Failed to send evidence review request notifications', + error as Error, + ); + } + } + + async notifyBulkEvidenceReviewRequested(params: { + organizationId: string; + taskIds: string[]; + taskCount: number; + submittedByUserId: string; + approverMemberId: string; + }): Promise { + const { organizationId, taskIds, taskCount, submittedByUserId, approverMemberId } = params; + + try { + const [organization, submittedByUser, approverMember, tasks] = await Promise.all([ + db.organization.findUnique({ + where: { id: organizationId }, + select: { name: true }, + }), + db.user.findUnique({ + where: { id: submittedByUserId }, + select: { name: true, email: true }, + }), + db.member.findUnique({ + where: { id: approverMemberId }, + select: { + user: { + select: { + id: true, + name: true, + email: true, + }, + }, + }, + }), + db.task.findMany({ + where: { + id: { in: taskIds }, + organizationId, + }, + select: { + id: true, + title: true, + }, + }), + ]); + + const organizationName = organization?.name ?? 'your organization'; + const submittedByName = + submittedByUser?.name?.trim() || + submittedByUser?.email?.trim() || + 'Someone'; + + if (!approverMember?.user?.id || !approverMember.user.email) { + this.logger.warn('Approver not found, skipping bulk review notification'); + return; + } + + if (approverMember.user.id === submittedByUserId) { + this.logger.log('Approver is the submitter, skipping notification'); + return; + } + + const recipient = { + id: approverMember.user.id, + name: approverMember.user.name?.trim() || approverMember.user.email?.trim() || 'User', + email: approverMember.user.email, + }; + + const isUnsubscribed = await isUserUnsubscribed( + db, + recipient.email, + 'taskAssignments', + ); + + if (isUnsubscribed) { + this.logger.log( + `Skipping notification: user ${recipient.email} is unsubscribed from task assignments`, + ); + return; + } + + const appUrl = + process.env.NEXT_PUBLIC_APP_URL ?? + process.env.BETTER_AUTH_URL ?? + 'https://app.trycomp.ai'; + const tasksUrl = `${appUrl}/${organizationId}/tasks`; + const taskText = taskCount === 1 ? 'task' : 'tasks'; + + const taskItems = tasks.map((task) => ({ + title: task.title ?? 'Untitled task', + url: `${appUrl}/${organizationId}/tasks/${task.id}`, + })); + + // Send email notification + try { + const { id } = await sendEmail({ + to: recipient.email, + subject: `${taskCount} ${taskText} submitted for your review`, + react: EvidenceBulkReviewRequestedEmail({ + toName: recipient.name, + toEmail: recipient.email, + taskCount, + submittedByName, + organizationName, + tasksUrl, + tasks: taskItems, + }), + system: true, + }); + + this.logger.log( + `Bulk evidence review request email sent to ${recipient.email} (ID: ${id})`, + ); + } catch (error) { + this.logger.error( + `Failed to send bulk evidence review request email to ${recipient.email}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + } + + // Send in-app notification + try { + const title = `${taskCount} ${taskText} submitted for review`; + const message = `${submittedByName} submitted ${taskCount} ${taskText} for your review in ${organizationName}`; + + await this.novuService.trigger({ + workflowId: BULK_TASK_WORKFLOW_ID, + subscriberId: `${recipient.id}-${organizationId}`, + email: recipient.email, + payload: { + title, + message, + url: tasksUrl, + }, + }); + + this.logger.log( + `[NOVU] Bulk evidence review request in-app notification sent to ${recipient.id}`, + ); + } catch (error) { + this.logger.error( + `[NOVU] Failed to send bulk evidence review request in-app notification to ${recipient.id}:`, + error instanceof Error ? error.message : 'Unknown error', + ); + } + } catch (error) { + this.logger.error( + 'Failed to send bulk evidence review request notifications', + error as Error, + ); + } + } } diff --git a/apps/api/src/tasks/tasks.controller.ts b/apps/api/src/tasks/tasks.controller.ts index 320313bfa..92bfa929a 100644 --- a/apps/api/src/tasks/tasks.controller.ts +++ b/apps/api/src/tasks/tasks.controller.ts @@ -8,6 +8,7 @@ import { Param, Patch, Post, + Query, UseGuards, } from '@nestjs/common'; import { @@ -256,6 +257,55 @@ export class TasksController { ); } + @Post('bulk/submit-for-review') + @ApiOperation({ + summary: 'Bulk submit tasks for review', + description: 'Submit multiple tasks for review with a single approver', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + taskIds: { + type: 'array', + items: { type: 'string' }, + example: ['tsk_abc123', 'tsk_def456'], + }, + approverId: { + type: 'string', + example: 'mem_abc123', + description: 'Member ID of the approver', + }, + }, + required: ['taskIds', 'approverId'], + }, + }) + @ApiResponse({ status: 200, description: 'Tasks submitted for review' }) + @ApiResponse({ status: 400, description: 'Invalid request' }) + async bulkSubmitForReview( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Body() body: { taskIds: string[]; approverId: string }, + ): Promise<{ submittedCount: number }> { + if (!authContext.userId) { + throw new BadRequestException( + 'User ID is required. Bulk operations require authenticated user session.', + ); + } + if (!Array.isArray(body.taskIds) || body.taskIds.length === 0) { + throw new BadRequestException('taskIds must be a non-empty array'); + } + if (!body.approverId) { + throw new BadRequestException('approverId is required'); + } + return await this.tasksService.bulkSubmitForReview( + organizationId, + body.taskIds, + authContext.userId, + body.approverId, + ); + } + @Delete('bulk') @ApiOperation({ summary: 'Delete multiple tasks', @@ -355,11 +405,34 @@ export class TasksController { return await this.tasksService.getTask(organizationId, taskId); } + @Get(':taskId/activity') + @ApiOperation({ + summary: 'Get task activity', + description: 'Retrieve audit log activity for a specific task with pagination', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiResponse({ status: 200, description: 'Activity retrieved successfully' }) + @ApiResponse({ status: 400, description: 'Task not found' }) + async getTaskActivity( + @OrganizationId() organizationId: string, + @Param('taskId') taskId: string, + @Query('skip') skip?: string, + @Query('take') take?: string, + ) { + const parsedSkip = skip ? Math.max(0, parseInt(skip, 10) || 0) : 0; + const parsedTake = take ? Math.min(50, Math.max(1, parseInt(take, 10) || 10)) : 10; + return await this.tasksService.getTaskActivity(organizationId, taskId, parsedSkip, parsedTake); + } + @Patch(':taskId') @ApiOperation({ summary: 'Update a task', description: - 'Update an existing task (status, assignee, frequency, department, reviewDate)', + 'Update an existing task (status, assignee, approver, frequency, department, reviewDate)', }) @ApiParam({ name: 'taskId', @@ -381,6 +454,12 @@ export class TasksController { example: 'mem_abc123', description: 'Assignee member ID, or null to unassign', }, + approverId: { + type: 'string', + nullable: true, + example: 'mem_abc123', + description: 'Approver member ID, or null to unassign', + }, frequency: { type: 'string', enum: ['daily', 'weekly', 'monthly', 'quarterly', 'yearly'], @@ -424,6 +503,7 @@ export class TasksController { body: { status?: TaskStatus; assigneeId?: string | null; + approverId?: string | null; frequency?: string; department?: string; reviewDate?: string; @@ -458,6 +538,7 @@ export class TasksController { { status: body.status, assigneeId: body.assigneeId, + approverId: body.approverId, frequency: body.frequency, department: body.department, reviewDate: parsedReviewDate, @@ -466,6 +547,118 @@ export class TasksController { ); } + // ==================== TASK APPROVAL ==================== + + @Post(':taskId/submit-for-review') + @ApiOperation({ + summary: 'Submit task for review', + description: + 'Move task status to in_review and assign an approver.', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + approverId: { + type: 'string', + example: 'mem_abc123', + description: 'Member ID of the approver', + }, + }, + required: ['approverId'], + }, + }) + @ApiResponse({ status: 200, description: 'Task submitted for review' }) + @ApiResponse({ status: 400, description: 'Invalid request' }) + async submitForReview( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('taskId') taskId: string, + @Body() body: { approverId: string }, + ): Promise { + if (!authContext.userId) { + throw new BadRequestException( + 'User ID is required. This operation requires an authenticated user session.', + ); + } + if (!body.approverId) { + throw new BadRequestException('approverId is required'); + } + return await this.tasksService.submitForReview( + organizationId, + taskId, + authContext.userId, + body.approverId, + ); + } + + @Post(':taskId/approve') + @ApiOperation({ + summary: 'Approve a task', + description: + 'Approve a task that is in review. Only the assigned approver can approve. Moves status to done and creates an audit comment.', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiResponse({ status: 200, description: 'Task approved successfully' }) + @ApiResponse({ status: 400, description: 'Task is not in review' }) + @ApiResponse({ status: 403, description: 'Not the assigned approver' }) + async approveTask( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('taskId') taskId: string, + ): Promise { + if (!authContext.userId) { + throw new BadRequestException( + 'User ID is required. This operation requires an authenticated user session.', + ); + } + return await this.tasksService.approveTask( + organizationId, + taskId, + authContext.userId, + ); + } + + @Post(':taskId/reject') + @ApiOperation({ + summary: 'Reject a task review', + description: + 'Reject a task that is in review. Only the assigned approver can reject. Reverts status to the previous status and creates an audit comment.', + }) + @ApiParam({ + name: 'taskId', + description: 'Unique task identifier', + example: 'tsk_abc123def456', + }) + @ApiResponse({ status: 200, description: 'Task rejected successfully' }) + @ApiResponse({ status: 400, description: 'Task is not in review' }) + @ApiResponse({ status: 403, description: 'Not the assigned approver' }) + async rejectTask( + @OrganizationId() organizationId: string, + @AuthContext() authContext: AuthContextType, + @Param('taskId') taskId: string, + ): Promise { + if (!authContext.userId) { + throw new BadRequestException( + 'User ID is required. This operation requires an authenticated user session.', + ); + } + return await this.tasksService.rejectTask( + organizationId, + taskId, + authContext.userId, + ); + } + // ==================== TASK ATTACHMENTS ==================== @Get(':taskId/attachments') diff --git a/apps/api/src/tasks/tasks.service.ts b/apps/api/src/tasks/tasks.service.ts index edf3e7d53..ee5403c07 100644 --- a/apps/api/src/tasks/tasks.service.ts +++ b/apps/api/src/tasks/tasks.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, + ForbiddenException, Injectable, InternalServerErrorException, } from '@nestjs/common'; @@ -53,6 +54,7 @@ export class TasksService { }, include: { assignee: true, + approver: { include: { user: true } }, }, }); @@ -89,6 +91,41 @@ export class TasksService { } } + /** + * Get audit activity for a task + */ + async getTaskActivity( + organizationId: string, + taskId: string, + skip = 0, + take = 10, + ) { + await this.verifyTaskAccess(organizationId, taskId); + + const where = { + organizationId, + entityType: 'task' as const, + entityId: taskId, + }; + + const [logs, total] = await Promise.all([ + db.auditLog.findMany({ + where, + include: { + user: { + select: { id: true, name: true, email: true, image: true }, + }, + }, + orderBy: { timestamp: 'desc' }, + skip, + take, + }), + db.auditLog.count({ where }), + ]); + + return { logs, total }; + } + /** * Get all automation runs for a task */ @@ -267,6 +304,7 @@ export class TasksService { updateData: { status?: TaskStatus; assigneeId?: string | null; + approverId?: string | null; frequency?: string; department?: string; reviewDate?: Date | null; @@ -296,6 +334,7 @@ export class TasksService { const dataToUpdate: { status?: TaskStatus; assigneeId?: string | null; + approverId?: string | null; frequency?: string; department?: string; reviewDate?: Date | null; @@ -305,10 +344,13 @@ export class TasksService { dataToUpdate.status = updateData.status; } if (updateData.assigneeId !== undefined) { - // Convert null to undefined for Prisma, or keep string value dataToUpdate.assigneeId = updateData.assigneeId === null ? null : updateData.assigneeId; } + if (updateData.approverId !== undefined) { + dataToUpdate.approverId = + updateData.approverId === null ? null : updateData.approverId; + } if (updateData.frequency !== undefined) { dataToUpdate.frequency = updateData.frequency; } @@ -319,6 +361,11 @@ export class TasksService { dataToUpdate.reviewDate = updateData.reviewDate; } + // Get the current member for audit logging + const currentMember = await db.member.findFirst({ + where: { userId: changedByUserId, organizationId, deactivated: false }, + }); + // Update the task const updatedTask = await db.task.update({ where: { @@ -331,11 +378,32 @@ export class TasksService { }, }); - // Send notifications for status changes + // Write audit logs and send notifications for status changes if ( updateData.status !== undefined && existingTask.status !== updateData.status ) { + const oldStatusLabel = existingTask.status.replace('_', ' '); + const newStatusLabel = updateData.status.replace('_', ' '); + + await db.auditLog.create({ + data: { + organizationId, + userId: changedByUserId, + memberId: currentMember?.id ?? null, + entityType: 'task', + entityId: taskId, + description: `changed status from ${oldStatusLabel} to ${newStatusLabel}`, + data: { + action: 'update', + taskTitle: existingTask.title, + field: 'status', + oldValue: existingTask.status, + newValue: updateData.status, + }, + }, + }); + this.taskNotifierService .notifyStatusChange({ organizationId, @@ -350,11 +418,52 @@ export class TasksService { }); } - // Send notifications for assignee changes + // Write audit logs and send notifications for assignee changes if ( updateData.assigneeId !== undefined && (existingTask.assigneeId ?? null) !== (updateData.assigneeId ?? null) ) { + // Resolve assignee names for the audit log + const [oldAssignee, newAssignee] = await Promise.all([ + existingTask.assigneeId + ? db.member.findUnique({ + where: { id: existingTask.assigneeId }, + include: { user: { select: { name: true, email: true } } }, + }) + : null, + updateData.assigneeId + ? db.member.findUnique({ + where: { id: updateData.assigneeId }, + include: { user: { select: { name: true, email: true } } }, + }) + : null, + ]); + + const oldName = oldAssignee + ? oldAssignee.user.name || oldAssignee.user.email + : 'unassigned'; + const newName = newAssignee + ? newAssignee.user.name || newAssignee.user.email + : 'unassigned'; + + await db.auditLog.create({ + data: { + organizationId, + userId: changedByUserId, + memberId: currentMember?.id ?? null, + entityType: 'task', + entityId: taskId, + description: `changed assignee from ${oldName} to ${newName}`, + data: { + action: 'update', + taskTitle: existingTask.title, + field: 'assignee', + oldValue: existingTask.assigneeId, + newValue: updateData.assigneeId, + }, + }, + }); + this.taskNotifierService .notifyAssigneeChange({ organizationId, @@ -381,4 +490,341 @@ export class TasksService { throw new InternalServerErrorException('Failed to update task'); } } + + /** + * Submit a task for review (moves status to in_review) + */ + async submitForReview( + organizationId: string, + taskId: string, + userId: string, + approverId: string, + ): Promise { + const task = await db.task.findFirst({ + where: { id: taskId, organizationId }, + }); + + if (!task) { + throw new BadRequestException('Task not found or access denied'); + } + + if (task.status === 'in_review') { + throw new BadRequestException('Task is already in review'); + } + + if (task.status === 'done') { + throw new BadRequestException('Task is already done'); + } + + // Verify the approver exists and is active + const approver = await db.member.findFirst({ + where: { id: approverId, organizationId, deactivated: false }, + include: { user: true }, + }); + + if (!approver) { + throw new BadRequestException('Approver not found or is deactivated'); + } + + const currentMember = await db.member.findFirst({ + where: { userId, organizationId, deactivated: false }, + }); + + const updatedTask = await db.$transaction(async (tx) => { + const updated = await tx.task.update({ + where: { id: taskId, organizationId }, + data: { + status: TaskStatus.in_review, + previousStatus: task.status, + approverId, + }, + include: { assignee: true, approver: true }, + }); + + await tx.auditLog.create({ + data: { + organizationId, + userId, + memberId: currentMember?.id ?? null, + entityType: 'task', + entityId: taskId, + description: `submitted evidence for review by ${approver.user.name || approver.user.email}`, + data: { + action: 'review', + taskTitle: task.title, + approverId, + previousStatus: task.status, + }, + }, + }); + + return updated; + }); + + // Notify approver (fire-and-forget) + this.taskNotifierService + .notifyEvidenceReviewRequested({ + organizationId, + taskId, + taskTitle: task.title, + submittedByUserId: userId, + approverMemberId: approverId, + }) + .catch((error) => { + console.error('Failed to send evidence review request notifications:', error); + }); + + return updatedTask; + } + + /** + * Bulk submit tasks for review + */ + async bulkSubmitForReview( + organizationId: string, + taskIds: string[], + userId: string, + approverId: string, + ): Promise<{ submittedCount: number }> { + // Verify the approver exists and is active + const approver = await db.member.findFirst({ + where: { id: approverId, organizationId, deactivated: false }, + include: { user: true }, + }); + + if (!approver) { + throw new BadRequestException('Approver not found or is deactivated'); + } + + const tasks = await db.task.findMany({ + where: { + id: { in: taskIds }, + organizationId, + status: { notIn: ['in_review', 'done'] }, + }, + }); + + if (tasks.length === 0) { + throw new BadRequestException('No eligible tasks found for review'); + } + + const currentMember = await db.member.findFirst({ + where: { userId, organizationId, deactivated: false }, + }); + + await db.$transaction(async (tx) => { + for (const task of tasks) { + await tx.task.update({ + where: { id: task.id, organizationId }, + data: { + status: TaskStatus.in_review, + previousStatus: task.status, + approverId, + }, + }); + + await tx.auditLog.create({ + data: { + organizationId, + userId, + memberId: currentMember?.id ?? null, + entityType: 'task', + entityId: task.id, + description: `submitted evidence for review by ${approver.user.name || approver.user.email}`, + data: { + action: 'review', + taskTitle: task.title, + approverId, + previousStatus: task.status, + }, + }, + }); + } + }); + + // Send a single notification for all tasks (fire-and-forget) + this.taskNotifierService + .notifyBulkEvidenceReviewRequested({ + organizationId, + taskIds: tasks.map((t) => t.id), + taskCount: tasks.length, + submittedByUserId: userId, + approverMemberId: approverId, + }) + .catch((error) => { + console.error('Failed to send bulk evidence review request notifications:', error); + }); + + return { submittedCount: tasks.length }; + } + + /** + * Approve a task (moves status from in_review to done) + */ + async approveTask( + organizationId: string, + taskId: string, + userId: string, + ): Promise { + const task = await db.task.findFirst({ + where: { id: taskId, organizationId }, + include: { + approver: { include: { user: true } }, + assignee: { include: { user: true } }, + }, + }); + + if (!task) { + throw new BadRequestException('Task not found or access denied'); + } + + if (task.status !== 'in_review') { + throw new BadRequestException('Task must be in review to approve'); + } + + // Verify the current user is the assigned approver + const currentMember = await db.member.findFirst({ + where: { userId, organizationId, deactivated: false }, + include: { user: true }, + }); + + if (!currentMember) { + throw new ForbiddenException('User is not a member of this organization'); + } + + if (task.approverId !== currentMember.id) { + throw new ForbiddenException( + 'Only the assigned approver can approve this task', + ); + } + + const now = new Date(); + + const updatedTask = await db.$transaction(async (tx) => { + const updated = await tx.task.update({ + where: { id: taskId, organizationId }, + data: { + status: TaskStatus.done, + approvedAt: now, + reviewDate: now, + previousStatus: null, + }, + include: { assignee: true, approver: true }, + }); + + const assigneeName = task.assignee + ? task.assignee.user.name || task.assignee.user.email + : 'Unknown'; + + await tx.auditLog.create({ + data: { + organizationId, + userId, + memberId: currentMember.id, + entityType: 'task', + entityId: taskId, + description: `approved evidence by ${assigneeName}`, + data: { + action: 'approve', + taskTitle: task.title, + assigneeName, + }, + }, + }); + + return updated; + }); + + return updatedTask; + } + + /** + * Reject a task (reverts status from in_review to previousStatus) + */ + async rejectTask( + organizationId: string, + taskId: string, + userId: string, + ): Promise { + const task = await db.task.findFirst({ + where: { id: taskId, organizationId }, + include: { + approver: { include: { user: true } }, + assignee: { include: { user: true } }, + }, + }); + + if (!task) { + throw new BadRequestException('Task not found or access denied'); + } + + if (task.status !== 'in_review') { + throw new BadRequestException('Task must be in review to reject'); + } + + // Verify the current user is the assigned approver or an admin/owner + const currentMember = await db.member.findFirst({ + where: { userId, organizationId, deactivated: false }, + include: { user: true }, + }); + + if (!currentMember) { + throw new ForbiddenException('User is not a member of this organization'); + } + + const memberRoles = currentMember.role + ?.split(',') + .map((r: string) => r.trim()) ?? []; + const isAdminOrOwner = + memberRoles.includes('admin') || memberRoles.includes('owner'); + const isApprover = task.approverId === currentMember.id; + + if (!isApprover && !isAdminOrOwner) { + throw new ForbiddenException( + 'Only the assigned approver or an admin/owner can reject this task', + ); + } + + const isCancellation = !isApprover && isAdminOrOwner; + const revertStatus = task.previousStatus ?? TaskStatus.todo; + + const updatedTask = await db.$transaction(async (tx) => { + const updated = await tx.task.update({ + where: { id: taskId, organizationId }, + data: { + status: revertStatus, + previousStatus: null, + approverId: null, + approvedAt: null, + }, + include: { assignee: true, approver: true }, + }); + + const assigneeName = task.assignee + ? (task.assignee.user.name || task.assignee.user.email) + : 'Unknown'; + + await tx.auditLog.create({ + data: { + organizationId, + userId, + memberId: currentMember.id, + entityType: 'task', + entityId: taskId, + description: isCancellation + ? `cancelled evidence review for ${assigneeName}` + : `rejected evidence by ${assigneeName}`, + data: { + action: isCancellation ? 'reject' : 'reject', + taskTitle: task.title, + revertedToStatus: revertStatus, + }, + }, + }); + + return updated; + }); + + return updatedTask; + } } 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 4318b5f6c..7fffbc9d9 100644 --- a/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts +++ b/apps/api/src/trigger/vendor/vendor-risk-assessment-task.ts @@ -214,7 +214,7 @@ async function normalizeRiskLevel( try { const result = await generateObject({ - model: openai('gpt-4o-mini'), + model: openai('gpt-5.2'), schema: normalizedRiskLevelSchema, prompt: `Classify this vendor security risk level into exactly one of these 5 categories. diff --git a/apps/app/src/actions/organization/update-organization-evidence-approval-action.ts b/apps/app/src/actions/organization/update-organization-evidence-approval-action.ts new file mode 100644 index 000000000..06b43c01f --- /dev/null +++ b/apps/app/src/actions/organization/update-organization-evidence-approval-action.ts @@ -0,0 +1,46 @@ +'use server'; + +import { db } from '@db'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { headers } from 'next/headers'; +import { authActionClient } from '../safe-action'; +import { organizationEvidenceApprovalSchema } from '../schema'; + +export const updateOrganizationEvidenceApprovalAction = authActionClient + .inputSchema(organizationEvidenceApprovalSchema) + .metadata({ + name: 'update-organization-evidence-approval', + track: { + event: 'update-organization-evidence-approval', + channel: 'server', + }, + }) + .action(async ({ parsedInput, ctx }) => { + const { evidenceApprovalEnabled } = parsedInput; + const { activeOrganizationId } = ctx.session; + + if (!activeOrganizationId) { + throw new Error('No active organization'); + } + + try { + await db.organization.update({ + where: { id: activeOrganizationId }, + data: { evidenceApprovalEnabled }, + }); + + const headersList = await headers(); + let path = headersList.get('x-pathname') || headersList.get('referer') || ''; + path = path.replace(/\/[a-z]{2}\//, '/'); + + revalidatePath(path); + revalidateTag(`organization_${activeOrganizationId}`, 'max'); + + return { + success: true, + }; + } catch (error) { + console.error(error); + throw new Error('Failed to update evidence approval setting'); + } + }); diff --git a/apps/app/src/actions/schema.ts b/apps/app/src/actions/schema.ts index 11a961448..a5516ec4f 100644 --- a/apps/app/src/actions/schema.ts +++ b/apps/app/src/actions/schema.ts @@ -65,6 +65,10 @@ export const organizationAdvancedModeSchema = z.object({ advancedModeEnabled: z.boolean(), }); +export const organizationEvidenceApprovalSchema = z.object({ + evidenceApprovalEnabled: z.boolean(), +}); + // Risks export const createRiskSchema = z.object({ title: z diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx index 3b877dd1c..05eb7d449 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/EmptyState.tsx @@ -1,13 +1,13 @@ 'use client'; import { ConnectIntegrationDialog } from '@/components/integrations/ConnectIntegrationDialog'; -import { Button } from '@comp/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@comp/ui/card'; import { Input } from '@comp/ui/input'; import { Label } from '@comp/ui/label'; import MultipleSelector from '@comp/ui/multiple-selector'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { ArrowLeft, CheckCircle2, Cloud, ExternalLink, Loader2 } from 'lucide-react'; +import { Button, PageHeader, PageLayout, Spinner } from '@trycompai/design-system'; +import { ArrowLeft, CheckmarkFilled, Launch } from '@trycompai/design-system/icons'; import { useEffect, useState } from 'react'; import { toast } from 'sonner'; import { connectCloudAction } from '../actions/connect-cloud'; @@ -305,13 +305,15 @@ export function EmptyState({ // AWS Step 2.5: Region Selection (after credential validation) if (step === 'validate-aws' && provider && selectedProvider === 'aws') { return ( - - - setStep('connect')}> - - Back - - + + setStep('connect')} + iconLeft={} + > + Back + @@ -375,36 +377,39 @@ export function EmptyState({ - - {isConnecting ? ( - <> - - Connecting... - > - ) : ( - <>Complete Setup> - )} - + + + {isConnecting ? 'Connecting...' : 'Complete Setup'} + + - + ); } // Step 1: Choose Provider if (step === 'choose') { return ( - + + {onBack && ( + }> + Back to Results + + )} + + > + } + > {showConnectDialog && ( )} - {onBack && ( - - - - Back to Results - - - )} - - - - - - - - - - {onBack ? 'Add Another Cloud' : 'Continuous Cloud Scanning'} - - - - Automatically monitor your cloud infrastructure for security vulnerabilities and - compliance issues. - - - - Always-on monitoring - - - - - + {CLOUD_PROVIDERS.filter( (cp) => cp.id === 'aws' || !connectedProviders.includes(cp.id), ).map((cloudProvider) => ( @@ -482,7 +456,7 @@ export function EmptyState({ ))} - + ); } @@ -491,13 +465,10 @@ export function EmptyState({ const fields = PROVIDER_FIELDS[provider.id]; return ( - - - - - Back - - + + }> + Back + @@ -529,7 +500,7 @@ export function EmptyState({ rel="noopener noreferrer" className="text-primary hover:text-primary/80 flex w-fit items-center gap-1.5 text-sm font-medium transition-colors" > - + Setup guide @@ -604,35 +575,36 @@ export function EmptyState({ ); })} - - {isConnecting ? ( - <> - - {selectedProvider === 'aws' ? 'Validating credentials...' : 'Connecting...'} - > - ) : ( - <>{selectedProvider === 'aws' ? 'Continue' : `Connect ${provider.shortName}`}> - )} - + + + {isConnecting + ? selectedProvider === 'aws' + ? 'Validating credentials...' + : 'Connecting...' + : selectedProvider === 'aws' + ? 'Continue' + : `Connect ${provider.shortName}`} + + - + ); } // Step 3: Success if (step === 'success' && provider) { return ( - + - + Successfully Connected! @@ -642,14 +614,14 @@ export function EmptyState({ - + This usually takes 1-2 minutes. We'll show results as soon as they're ready. - + ); } diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx index 3b88fcac6..4b3d01ecf 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/ProviderTabs.tsx @@ -165,7 +165,7 @@ export function ProviderTabs({ return ( - + onConnectionTabChange(providerType, value)} diff --git a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx index f50a5e738..9e8f71d49 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/components/TestsLayout.tsx @@ -146,9 +146,7 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL if (!result.success) { console.error('Legacy scan error:', result.errors); - toast.error( - `Scan failed: ${result.errors?.join(', ') || 'Unknown error'}`, - ); + toast.error(`Scan failed: ${result.errors?.join(', ') || 'Unknown error'}`); return null; } } else { @@ -236,7 +234,6 @@ export function TestsLayout({ initialFindings, initialProviders, orgId }: TestsL <> { setAddConnectionProvider(null); setViewingResults(false); 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 12fead15d..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/cloud-tests/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/controls/loading.tsx b/apps/app/src/app/(app)/[orgId]/controls/loading.tsx index 14b69a064..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/controls/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/controls/loading.tsx @@ -1,7 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( - } padding="default" /> - ); + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx index da7363f98..63b50c2ac 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FindingsOverview.tsx @@ -1,13 +1,13 @@ 'use client'; -import { Badge } from '@comp/ui/badge'; import { Button } from '@comp/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@comp/ui/card'; import { ScrollArea } from '@comp/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@comp/ui/tabs'; import { Finding, FindingStatus } from '@db'; import { ArrowRight, CheckCircle2, FileWarning } from 'lucide-react'; import Link from 'next/link'; -import { useMemo, useState } from 'react'; +import { useMemo } from 'react'; const STATUS_COLORS: Record = { open: 'bg-red-100 text-red-700 border-red-200', @@ -28,34 +28,6 @@ const TYPE_LABELS: Record = { iso27001: 'ISO 27001', }; -// Filter button styles matching status colors -const FILTER_BUTTON_STYLES: Record = { - open: { - active: 'bg-red-100 text-red-700 border-red-300 hover:bg-red-100', - inactive: 'hover:bg-red-50 hover:text-red-700 hover:border-red-200', - }, - ready_for_review: { - active: 'bg-yellow-100 text-yellow-700 border-yellow-300 hover:bg-yellow-100', - inactive: 'hover:bg-yellow-50 hover:text-yellow-700 hover:border-yellow-200', - }, - needs_revision: { - active: 'bg-orange-100 text-orange-700 border-orange-300 hover:bg-orange-100', - inactive: 'hover:bg-orange-50 hover:text-orange-700 hover:border-orange-200', - }, - closed: { - active: 'bg-primary/10 text-primary border-primary/30 hover:bg-primary/10', - inactive: 'hover:bg-primary/5 hover:text-primary hover:border-primary/20', - }, -}; - -// Order for displaying filter buttons -const STATUS_DISPLAY_ORDER: FindingStatus[] = [ - FindingStatus.open, - FindingStatus.needs_revision, - FindingStatus.ready_for_review, - FindingStatus.closed, -]; - interface FindingWithTask extends Finding { task: { id: string; @@ -63,55 +35,77 @@ interface FindingWithTask extends Finding { }; } -export function FindingsOverview({ +function FindingsList({ findings, organizationId, }: { findings: FindingWithTask[]; organizationId: string; }) { - const [activeFilter, setActiveFilter] = useState(null); + return ( + + + + {findings.map((finding, index) => ( + + + + + + + + + {finding.task.title} + + + {finding.content} + + + + + + + + + + {index < findings.length - 1 && } + + ))} + + + + ); +} - // Sort findings by updatedAt only (most recently updated first) - const sortedFindings = useMemo(() => { - return [...findings].sort((a, b) => { - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); - }); +export function FindingsOverview({ + findings, + organizationId, +}: { + findings: FindingWithTask[]; + organizationId: string; +}) { + // Split findings into open and closed, sorted by most recently updated + const openFindings = useMemo(() => { + return [...findings] + .filter((f) => f.status !== FindingStatus.closed) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); }, [findings]); - // Filter findings based on active filter - const filteredFindings = useMemo(() => { - if (activeFilter === null) return sortedFindings; - return sortedFindings.filter((f) => f.status === activeFilter); - }, [sortedFindings, activeFilter]); - - // Count by status for filter buttons - const statusCounts = useMemo(() => { - return findings.reduce( - (acc, finding) => { - acc[finding.status] = (acc[finding.status] || 0) + 1; - return acc; - }, - {} as Record, - ); + const closedFindings = useMemo(() => { + return [...findings] + .filter((f) => f.status === FindingStatus.closed) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); }, [findings]); - const closedCount = statusCounts.closed || 0; + const closedCount = closedFindings.length; const totalCount = findings.length; const progressWidth = totalCount > 0 ? (closedCount / totalCount) * 100 : 100; - const handleFilterClick = (status: FindingStatus) => { - setActiveFilter((current) => (current === status ? null : status)); - }; - return ( - - - Findings - + Findings @@ -122,113 +116,42 @@ export function FindingsOverview({ }} /> - - {closedCount} of {totalCount} findings resolved - - - {/* Status filter buttons */} - {totalCount > 0 && ( - - {STATUS_DISPLAY_ORDER.map((status) => { - const count = statusCounts[status] || 0; - if (count === 0) return null; - const isActive = activeFilter === status; - const styles = FILTER_BUTTON_STYLES[status]; + - return ( - handleFilterClick(status)} - className={` - inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium - border transition-colors cursor-pointer - ${isActive ? styles.active : `bg-transparent border-border ${styles.inactive}`} - `} - > - {STATUS_LABELS[status]} - - {count} - - - ); - })} - {activeFilter !== null && ( - setActiveFilter(null)} - className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer" - > - Clear - + + 0 ? 'open' : 'closed'} className="w-full"> + + + + Open ({openFindings.length}) + + + + Closed ({closedFindings.length}) + + + + + {openFindings.length === 0 ? ( + + + No open findings + + ) : ( + )} - - )} - + - - {findings.length === 0 ? ( - - - No findings - - ) : filteredFindings.length === 0 ? ( - - - No {activeFilter ? STATUS_LABELS[activeFilter].toLowerCase() : ''} findings - - - ) : ( - - - - {filteredFindings.map((finding, index) => ( - - - - - - - - - {finding.task.title} - - - - {STATUS_LABELS[finding.status]} - - - {TYPE_LABELS[finding.type] || finding.type} - - - - {finding.content} - - - - - - - - - - {index < filteredFindings.length - 1 && ( - - )} - - ))} + + {closedFindings.length === 0 ? ( + + No closed findings - - - )} + ) : ( + + )} + + ); diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx index d453d36df..f8971b6db 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/layout.tsx @@ -1,9 +1,3 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; - export default async function Layout({ children }: { children: React.ReactNode }) { - return ( - } padding="default"> - {children} - - ); + return children; } diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx index 4f38f9a92..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/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]/frameworks/page.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx index eaffde8b7..30fdc346d 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/page.tsx @@ -1,5 +1,6 @@ import { auth } from '@/utils/auth'; -import { db, FindingStatus } from '@db'; +import { db } from '@db'; +import { PageHeader, PageLayout } from '@trycompai/design-system'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { cache } from 'react'; @@ -70,18 +71,20 @@ export default async function DashboardPage({ params }: { params: Promise<{ orgI const findings = await getOrganizationFindings(organizationId); return ( - + }> + + ); } @@ -165,10 +168,7 @@ const getOrganizationFindings = cache(async (organizationId: string) => { }, }, }, - orderBy: [ - { status: 'asc' }, - { createdAt: 'desc' }, - ], + orderBy: [{ status: 'asc' }, { createdAt: 'desc' }], }); return findings; diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx index 10b8ed210..da6f2c223 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/Fields/JoinDate.tsx @@ -1,10 +1,12 @@ +'use client'; + import { Button } from '@comp/ui/button'; -import { Calendar } from '@comp/ui/calendar'; -import { cn } from '@comp/ui/cn'; import { FormControl, FormField, FormItem, FormLabel, FormMessage } from '@comp/ui/form'; import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; +import { Calendar } from '@trycompai/design-system'; import { format } from 'date-fns'; -import { CalendarIcon } from 'lucide-react'; +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; import type { Control } from 'react-hook-form'; import type { EmployeeFormValues } from '../EmployeeDetails'; @@ -15,45 +17,60 @@ export const JoinDate = ({ control: Control; disabled: boolean; }) => { + const [open, setOpen] = useState(false); + return ( ( - - - Join Date - - - - - { + return ( + + + + - {field.value ? format(field.value, 'PPP') : Pick a date} - - - - - - date > new Date() // Explicitly type the date argument - } - initialFocus - /> - - - - - )} + Join Date + + + + {field.value ? format(field.value, 'PPP') : Pick a date} + + + + + date && field.onChange(date)} + captionLayout="dropdown" + disabled={(date) => date > new Date()} + /> + + setOpen(false)} + > + Done + + + + + + + + ); + }} /> ); }; 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 008d2c03c..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/loading.tsx @@ -1,10 +1,5 @@ -import Loader from '@/components/ui/loader'; -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( - } padding="default"> - - - ); + 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 3bc4ab5df..3c700371b 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx @@ -1,12 +1,22 @@ 'use client'; -import { Edit, Laptop, MoreHorizontal, Trash2 } from 'lucide-react'; +import { Laptop } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; import { useState } from 'react'; -import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; -import { Badge } from '@comp/ui/badge'; +import { + Avatar, + AvatarFallback, + AvatarImage, + Badge, + HStack, + Label, + TableCell, + TableRow, + Text, +} from '@trycompai/design-system'; +import { Edit, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; import { Button } from '@comp/ui/button'; import { Dialog, @@ -22,7 +32,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; -import { Label } from '@comp/ui/label'; import type { Role } from '@db'; import { toast } from 'sonner'; @@ -40,7 +49,6 @@ interface MemberRowProps { isCurrentUserOwner: boolean; } -// Helper to get initials function getInitials(name?: string | null, email?: string | null): string { if (name) { return name @@ -55,6 +63,31 @@ function getInitials(name?: string | null, email?: string | null): string { return '??'; } +function getRoleLabel(role: string): string { + switch (role) { + case 'owner': + return 'Owner'; + case 'admin': + return 'Admin'; + case 'auditor': + return 'Auditor'; + case 'employee': + return 'Employee'; + case 'contractor': + return 'Contractor'; + default: + return '???'; + } +} + +function parseRoles(role: Role | Role[] | string): Role[] { + if (Array.isArray(role)) return role as Role[]; + if (typeof role === 'string' && role.includes(',')) { + return role.split(',').map((r) => r.trim()) as Role[]; + } + return [role as Role]; +} + export function MemberRow({ member, onRemove, @@ -69,9 +102,7 @@ export function MemberRow({ const [isRemoveDeviceAlertOpen, setIsRemoveDeviceAlertOpen] = useState(false); const [isUpdateRolesOpen, setIsUpdateRolesOpen] = useState(false); const [dropdownOpen, setDropdownOpen] = useState(false); - const [selectedRoles, setSelectedRoles] = useState( - Array.isArray(member.role) ? member.role : ([member.role] as Role[]), - ); + const [selectedRoles, setSelectedRoles] = useState(() => parseRoles(member.role)); const [isUpdating, setIsUpdating] = useState(false); const [isRemoving, setIsRemoving] = useState(false); const [isRemovingDevice, setIsRemovingDevice] = useState(false); @@ -80,23 +111,17 @@ export function MemberRow({ const memberEmail = member.user.email || ''; const memberAvatar = member.user.image; const memberId = member.id; - const currentRoles = ( - Array.isArray(member.role) - ? member.role - : typeof member.role === 'string' && member.role.includes(',') - ? (member.role.split(',') as Role[]) - : [member.role] - ) as Role[]; + const currentRoles = parseRoles(member.role); const isOwner = currentRoles.includes('owner'); const canRemove = !isOwner; - const isDeactivated = member.deactivated; - const canViewProfile = !isDeactivated; - const profileHref = canViewProfile ? `/${orgId}/people/${memberId}` : null; + const isDeactivated = member.deactivated || !member.isActive; + const profileHref = `/${orgId}/people/${memberId}`; const handleEditRolesClick = () => { - setDropdownOpen(false); // Close dropdown first - setIsUpdateRolesOpen(true); // Then open dialog + setSelectedRoles(parseRoles(member.role)); + setDropdownOpen(false); + setIsUpdateRolesOpen(true); }; const handleUpdateRolesClick = async () => { @@ -105,7 +130,6 @@ export function MemberRow({ rolesToUpdate = [...rolesToUpdate, 'owner']; } - // Don't update if no roles are selected if (rolesToUpdate.length === 0) { return; } @@ -113,7 +137,7 @@ export function MemberRow({ setIsUpdating(true); await onUpdateRole(memberId, rolesToUpdate); setIsUpdating(false); - setIsUpdateRolesOpen(false); // Close dialog after update + setIsUpdateRolesOpen(false); }; const handleRemoveClick = async () => { @@ -141,124 +165,96 @@ export function MemberRow({ return ( <> - - - - - {getInitials(member.user.name, member.user.email)} - - - - {profileHref ? ( - - {memberName} - - ) : ( - - {memberName} - - )} - {isDeactivated && ( - - Deactivated - - )} - {profileHref && ( - - ({'View Profile'}) - - )} + + {/* NAME */} + + + + + + {getInitials(member.user.name, member.user.email)} + - {memberEmail} - - - - - {currentRoles.map((role) => ( - + - {(() => { - switch (role) { - case 'owner': - return 'Owner'; - case 'admin': - return 'Admin'; - case 'auditor': - return 'Auditor'; - case 'employee': - return 'Employee'; - case 'contractor': - return 'Contractor'; - default: - return '???'; - } - })()} + {memberName} + + {memberEmail} + + + + + {/* STATUS */} + + {isDeactivated ? ( + Inactive + ) : ( + Active + )} + + + {/* ROLE */} + + + {currentRoles.map((role) => ( + + {getRoleLabel(role)} ))} + + {/* ACTIONS */} + {!isDeactivated && ( - - - - - - - - {canEdit && ( - - - {'Edit Roles'} - - )} - {member.fleetDmLabelId && isCurrentUserOwner && ( - { - setDropdownOpen(false); - setIsRemoveDeviceAlertOpen(true); - }} - > - - {'Remove Device'} - - )} - {canRemove && ( - { - setDropdownOpen(false); - setIsRemoveAlertOpen(true); - }} - > - - {'Remove Member'} - - )} - - + + + + + + + + + {canEdit && ( + + + Edit Roles + + )} + {member.fleetDmLabelId && isCurrentUserOwner && ( + { + setDropdownOpen(false); + setIsRemoveDeviceAlertOpen(true); + }} + > + + Remove Device + + )} + {canRemove && ( + { + setDropdownOpen(false); + setIsRemoveAlertOpen(true); + }} + > + + Remove Member + + )} + + + )} - - + + - {'Are you sure you want to remove all devices for this user '} {memberName}?{' '} - {'This will disconnect all devices from the organization.'} + Are you sure you want to remove all devices for this user{' '} + {memberName}? This will disconnect all devices from the organization. > - )} + } onOpenChange={setIsRemoveDeviceAlertOpen} onRemove={handleRemoveDeviceClick} isRemoving={isRemovingDevice} /> - {/* Edit Roles Dialog - moved outside DropdownMenu to avoid overlay conflicts */} + {/* Edit Roles Dialog */} - {'Edit Member Roles'} - - {'Change roles for'} {memberName} - + Edit Member Roles + Change roles for {memberName} - {'Roles'} + Roles {isOwner && ( - {'The owner role cannot be removed.'} + The owner role cannot be removed. )} - {'Members must have at least one role.'} + Members must have at least one role. @@ -317,7 +311,7 @@ export function MemberRow({ onClick={handleUpdateRolesClick} disabled={isUpdating || selectedRoles.length === 0} > - {'Update'} + Update diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx index 19349a629..ea5c8ab89 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/PendingInvitationRow.tsx @@ -1,25 +1,29 @@ 'use client'; -import { Avatar, AvatarFallback } from '@comp/ui/avatar'; -import { Badge } from '@comp/ui/badge'; -import { Button } from '@comp/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@comp/ui/dialog'; +import type { Invitation } from '@db'; import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Avatar, + AvatarFallback, + Badge, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, -} from '@comp/ui/dropdown-menu'; -import type { Invitation } from '@db'; -import { Clock, MoreHorizontal, Trash2 } from 'lucide-react'; -import { useEffect, useMemo, useState } from 'react'; + HStack, + TableCell, + TableRow, + Text, +} from '@trycompai/design-system'; +import { OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons'; +import { useEffect, useState } from 'react'; interface PendingInvitationRowProps { invitation: Invitation & { @@ -65,87 +69,86 @@ export function PendingInvitationRow({ } }, [pendingRemove, isCancelDialogOpen, onCancel, invitation.id]); - const roleDisplay = useMemo(() => { - return invitation.role; - }, [invitation.role]); + const roles = Array.isArray(invitation.role) + ? invitation.role + : typeof invitation.role === 'string' && invitation.role.includes(',') + ? invitation.role.split(',') + : [invitation.role]; return ( <> - - - - {invitation.email.charAt(0).toUpperCase()} - - - - {invitation.email} - - - Pending - + + {/* NAME */} + + + + {invitation.email.charAt(0).toUpperCase()} + + + {invitation.email} - {/* No secondary email line for invitations */} - - - - - {(Array.isArray(invitation.role) - ? invitation.role - : typeof invitation.role === 'string' && invitation.role.includes(',') - ? invitation.role.split(',') - : [invitation.role] - ).map((role: string) => ( - + + + + {/* STATUS */} + + Pending + + + {/* ROLE */} + + + {roles.map((role: string) => ( + {role.charAt(0).toUpperCase() + role.slice(1)} ))} - - - - - Open menu - - - - + + {/* ACTIONS */} + + + + e.stopPropagation()} > - - Cancel Invitation - - - - - + + + + + + Cancel Invitation + + + + + + - - e.preventDefault()} - showCloseButton={false} - > - - Cancel Invitation - + + + + Cancel Invitation + Are you sure you want to cancel the invitation for {invitation.email}? - - + + This action cannot be undone. - - setIsCancelDialogOpen(false)}> - Cancel - - + + Cancel + Confirm - - - - + + + + > ); } 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 ef42ff910..001fa1080 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 @@ -1,19 +1,36 @@ 'use client'; -import { Loader2, Mail, Search, UserPlus, X } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import { parseAsString, useQueryState } from 'nuqs'; import { useState } from 'react'; import { toast } from 'sonner'; +import { usePeopleActions } from '@/hooks/use-people-api'; import { authClient } from '@/utils/auth-client'; -import { Button } from '@comp/ui/button'; -import { Card, CardContent } from '@comp/ui/card'; -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 { + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + InputGroup, + InputGroupAddon, + InputGroupInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Separator, + Stack, + Table, + TableBody, + TableHead, + TableHeader, + TableRow, +} from '@trycompai/design-system'; +import { Search } from '@trycompai/design-system/icons'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; @@ -23,10 +40,8 @@ import type { MemberWithUser, TeamMembersData } from './TeamMembers'; import type { removeMember } from '../actions/removeMember'; import type { revokeInvitation } from '../actions/revokeInvitation'; -import { usePeopleActions } from '@/hooks/use-people-api'; import type { EmployeeSyncConnectionsData } from '../data/queries'; import { useEmployeeSync } from '../hooks/useEmployeeSync'; -import { InviteMembersModal } from './InviteMembersModal'; // Define prop types using typeof for the actions still used interface TeamMembersClientProps { @@ -65,15 +80,14 @@ export function TeamMembersClient({ employeeSyncData, }: TeamMembersClientProps) { const router = useRouter(); - const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')); - const [roleFilter, setRoleFilter] = useQueryState('role', parseAsString.withDefault('all')); - const [statusFilter, setStatusFilter] = useQueryState('status', parseAsString.withDefault('all')); + const [searchQuery, setSearchQuery] = useState(''); + const [roleFilter, setRoleFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); const { unlinkDevice } = usePeopleActions(); - // Add state for the modal - const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); - // Employee sync hook with server-fetched initial data const { googleWorkspaceConnectionId, @@ -111,17 +125,19 @@ export function TeamMembersClient({ ? member.role : [member.role as Role]; + const isInactive = member.deactivated || !member.isActive; + return { ...member, type: 'member' as const, displayName: member.user.name || member.user.email || '', displayEmail: member.user.email || '', displayRole: member.role, // Keep original for filtering - displayStatus: member.deactivated ? ('deactivated' as const) : ('active' as const), + displayStatus: isInactive ? ('deactivated' as const) : ('active' as const), displayId: member.id, // Add processed roles for rendering processedRoles: roles, - isDeactivated: member.deactivated, + isDeactivated: isInactive, }; }), ...data.pendingInvitations.map((invitation) => { @@ -153,13 +169,13 @@ export function TeamMembersClient({ item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); // Check if the role filter matches any of the member's roles - const matchesRole = roleFilter === 'all' || item.processedRoles.includes(roleFilter as Role); + const matchesRole = !roleFilter || item.processedRoles.includes(roleFilter as Role); // Status filter: 'active' shows non-deactivated members + pending invitations // 'deactivated' shows only deactivated members - // 'all' shows everything + // empty shows everything const matchesStatus = - statusFilter === 'all' || + !statusFilter || (statusFilter === 'active' && item.displayStatus !== 'deactivated') || (statusFilter === 'deactivated' && item.displayStatus === 'deactivated'); @@ -169,6 +185,14 @@ export function TeamMembersClient({ const activeMembers = filteredItems.filter((item) => item.type === 'member'); const pendingInvites = filteredItems.filter((item) => item.type === 'invitation'); + // Combine all items for table display + const allDisplayItems = [...activeMembers, ...pendingInvites]; + const totalItems = allDisplayItems.length; + const pageCount = Math.ceil(totalItems / perPage); + const paginatedItems = allDisplayItems.slice((page - 1) * perPage, page * perPage); + + const pageSizeOptions = [10, 25, 50, 100]; + const handleCancelInvitation = async (invitationId: string) => { const result = await revokeInvitationAction({ invitationId }); if (result?.data) { @@ -247,19 +271,10 @@ export function TeamMembersClient({ }; return ( - - {/* Render the Invite Modal */} - - - - + + {/* Search and Filters */} + + @@ -267,59 +282,63 @@ export function TeamMembersClient({ setSearchQuery(e.target.value || null)} + onChange={(e) => setSearchQuery(e.target.value)} /> - {searchQuery && ( - setSearchQuery(null)} - > - - - )} {/* Status Filter Select */} - setStatusFilter(value === 'all' ? null : value)} - > - - - - - {'All People'} - {'Active'} - {'Deactivated'} - - - {/* Role Filter Select: Hidden on mobile, block on sm+ */} - setRoleFilter(value === 'all' ? null : value)} - > - - - - - {'All Roles'} - {'Owner'} - {'Admin'} - {'Auditor'} - {'Employee'} - - + + { + setStatusFilter(value === 'all' ? '' : (value ?? '')); + setPage(1); + }} + > + + + + + All People + Active + Deactivated + + + + {/* Role Filter Select */} + + { + setRoleFilter(value === 'all' ? '' : (value ?? '')); + setPage(1); + }} + > + + + + + All Roles + Owner + Admin + Auditor + Employee + + + {hasAnyConnection && ( - + - handleEmployeeSync(value as 'google-workspace' | 'rippling' | 'jumpcloud' | 'ramp') - } + onValueChange={(value) => { + if (value) { + handleEmployeeSync( + value as 'google-workspace' | 'rippling' | 'jumpcloud' | 'ramp', + ); + } + }} disabled={isSyncing || !canManageMembers} > - + {isSyncing ? ( <> @@ -361,7 +380,7 @@ export function TeamMembersClient({ 'Select a provider to enable auto-sync' )} - + {googleWorkspaceConnectionId && ( @@ -438,62 +457,67 @@ export function TeamMembersClient({ )} - setIsInviteModalOpen(true)} disabled={!canInviteUsers}> - - {'Add User'} - - - - - {activeMembers.map((member) => ( - - ))} - - {/* Conditionally render separator only if both sections have content */} - {activeMembers.length > 0 && pendingInvites.length > 0 && } - - {pendingInvites.length > 0 && ( - - {pendingInvites.map((invitation) => ( + {/* Table */} + {totalItems === 0 ? ( + + + {searchQuery ? 'No people found' : 'No employees yet'} + + {searchQuery + ? 'Try adjusting your search or filters.' + : 'Get started by inviting your first team member.'} + + + + ) : ( + { + setPerPage(size); + setPage(1); + }, + }} + > + + + NAME + STATUS + ROLE + ACTIONS + + + + {paginatedItems.map((item) => + item.type === 'member' ? ( + + ) : ( - ))} - - )} - - {activeMembers.length === 0 && pendingInvites.length === 0 && ( - - - {'No employees yet'} - - {'Get started by inviting your first team member.'} - - setIsInviteModalOpen(true)} - disabled={!canInviteUsers} - > - - {'Add User'} - - - )} - - - + ), + )} + + + )} + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx index 46b6eb0f0..8af9e307a 100644 --- a/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/components/PeoplePageTabs.tsx @@ -1,6 +1,7 @@ 'use client'; import { + Button, PageHeader, PageLayout, Tabs, @@ -8,13 +9,19 @@ import { TabsList, TabsTrigger, } from '@trycompai/design-system'; +import { Add } from '@trycompai/design-system/icons'; import type { ReactNode } from 'react'; +import { useState } from 'react'; +import { InviteMembersModal } from '../all/components/InviteMembersModal'; interface PeoplePageTabsProps { peopleContent: ReactNode; employeeTasksContent: ReactNode | null; devicesContent: ReactNode; showEmployeeTasks: boolean; + canInviteUsers: boolean; + canManageMembers: boolean; + organizationId: string; } export function PeoplePageTabs({ @@ -22,7 +29,12 @@ export function PeoplePageTabs({ employeeTasksContent, devicesContent, showEmployeeTasks, + canInviteUsers, + canManageMembers, + organizationId, }: PeoplePageTabsProps) { + const [isInviteModalOpen, setIsInviteModalOpen] = useState(false); + return ( Employee Devices } + actions={ + } + onClick={() => setIsInviteModalOpen(true)} + disabled={!canInviteUsers} + > + Add User + + } /> } > @@ -47,6 +68,15 @@ export function PeoplePageTabs({ )} {devicesContent} + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/people/loading.tsx b/apps/app/src/app/(app)/[orgId]/people/loading.tsx index 7a4aded9f..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/people/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } loading={true} />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/people/page.tsx b/apps/app/src/app/(app)/[orgId]/people/page.tsx index 105ce94d6..ec0f50eed 100644 --- a/apps/app/src/app/(app)/[orgId]/people/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/page.tsx @@ -77,6 +77,9 @@ export default async function PeoplePage({ params }: { params: Promise<{ orgId: > } showEmployeeTasks={showEmployeeTasks} + canInviteUsers={canInviteUsers} + canManageMembers={canManageMembers} + organizationId={orgId} /> ); } diff --git a/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx b/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx index 3af989b1f..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/(overview)/loading.tsx @@ -1,11 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return ( - } - /> - ); + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/loading.tsx index f56b30886..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/questionnaire/loading.tsx b/apps/app/src/app/(app)/[orgId]/questionnaire/loading.tsx index 6d6de5d07..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/questionnaire/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/questionnaire/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/settings/context-hub/data/getContextEntries.ts b/apps/app/src/app/(app)/[orgId]/settings/context-hub/data/getContextEntries.ts index 85e429cd8..83cc74d6d 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/context-hub/data/getContextEntries.ts +++ b/apps/app/src/app/(app)/[orgId]/settings/context-hub/data/getContextEntries.ts @@ -4,6 +4,48 @@ import { headers } from 'next/headers'; import { cache } from 'react'; import 'server-only'; +const FRAMEWORK_ID_PATTERN = /\bfrk_[a-z0-9]+\b/g; + +/** + * Detects framework IDs (frk_xxx) in context entry answers and replaces them + * with human-readable framework names. Handles legacy data from before the + * write-time fix was applied. + */ +async function resolveFrameworkIdsInEntries( + entries: T[], +): Promise { + // Collect all unique framework IDs across all entries + const allIds = new Set(); + for (const entry of entries) { + const matches = entry.answer.match(FRAMEWORK_ID_PATTERN); + if (matches) { + for (const id of matches) { + allIds.add(id); + } + } + } + + if (allIds.size === 0) return entries; + + // Batch-fetch framework names for all IDs + const frameworks = await db.frameworkEditorFramework.findMany({ + where: { id: { in: Array.from(allIds) } }, + select: { id: true, name: true }, + }); + + const idToName = new Map(frameworks.map((f) => [f.id, f.name])); + + // Replace IDs with names in each entry's answer + return entries.map((entry) => { + const resolvedAnswer = entry.answer.replace( + FRAMEWORK_ID_PATTERN, + (id) => idToName.get(id) ?? id, + ); + if (resolvedAnswer === entry.answer) return entry; + return { ...entry, answer: resolvedAnswer }; + }); +} + export const getContextEntries = cache( async ({ orgId, @@ -42,6 +84,10 @@ export const getContextEntries = cache( }); const total = await db.context.count({ where }); const pageCount = Math.ceil(total / perPage); - return { data: entries, pageCount }; + + // Resolve any legacy framework IDs to display names + const resolvedEntries = await resolveFrameworkIdsInEntries(entries); + + return { data: resolvedEntries, pageCount }; }, ); diff --git a/apps/app/src/app/(app)/[orgId]/settings/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/page.tsx index 65e174dbe..4d06016bf 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/page.tsx @@ -2,6 +2,7 @@ import { APP_AWS_ORG_ASSETS_BUCKET, s3Client } from '@/app/s3'; import { DeleteOrganization } from '@/components/forms/organization/delete-organization'; import { TransferOwnership } from '@/components/forms/organization/transfer-ownership'; import { UpdateOrganizationAdvancedMode } from '@/components/forms/organization/update-organization-advanced-mode'; + import { UpdateOrganizationLogo } from '@/components/forms/organization/update-organization-logo'; import { UpdateOrganizationName } from '@/components/forms/organization/update-organization-name'; import { UpdateOrganizationWebsite } from '@/components/forms/organization/update-organization-website'; diff --git a/apps/app/src/app/(app)/[orgId]/settings/secrets/components/table/SecretsTable.tsx b/apps/app/src/app/(app)/[orgId]/settings/secrets/components/table/SecretsTable.tsx index fc2a74be3..b825e6a03 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/secrets/components/table/SecretsTable.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/secrets/components/table/SecretsTable.tsx @@ -1,12 +1,48 @@ 'use client'; -import { Badge } from '@comp/ui/badge'; -import { Button } from '@comp/ui/button'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@comp/ui/table'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@comp/ui/tooltip'; -import { format } from 'date-fns'; -import { Copy, Edit, Eye, EyeOff, Loader2, Trash2 } from 'lucide-react'; -import { useState } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Badge, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + Empty, + EmptyDescription, + EmptyHeader, + EmptyTitle, + HStack, + InputGroup, + InputGroupAddon, + InputGroupInput, + Spinner, + Stack, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Text, +} from '@trycompai/design-system'; +import { + Edit, + OverflowMenuVertical, + Search, + TrashCan, + View, + ViewOff, +} from '@trycompai/design-system/icons'; +import { Copy } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { toast } from 'sonner'; import { EditSecretDialog } from '../EditSecretDialog'; @@ -24,14 +60,36 @@ interface SecretsTableProps { secrets: Secret[]; } +const CATEGORY_MAP: Record = { + api_keys: 'API Keys', + database: 'Database', + authentication: 'Authentication', + integration: 'Integration', + other: 'Other', +}; + +function formatDate(date: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(date)); +} + export function SecretsTable({ secrets }: SecretsTableProps) { const [revealedSecrets, setRevealedSecrets] = useState>({}); const [loadingSecrets, setLoadingSecrets] = useState>({}); const [editingSecret, setEditingSecret] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [secretToDelete, setSecretToDelete] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(25); + const pageSizeOptions = [10, 25, 50, 100]; const handleRevealSecret = async (secretId: string) => { if (revealedSecrets[secretId]) { - // Hide the secret setRevealedSecrets((prev) => { const next = { ...prev }; delete next[secretId]; @@ -40,13 +98,11 @@ export function SecretsTable({ secrets }: SecretsTableProps) { return; } - // Reveal the secret setLoadingSecrets((prev) => ({ ...prev, [secretId]: true })); try { - // Get organizationId from the URL path const pathSegments = window.location.pathname.split('/'); - const orgId = pathSegments[1]; // Assuming path is /{orgId}/settings/secrets + const orgId = pathSegments[1]; const response = await fetch(`/api/secrets/${secretId}?organizationId=${orgId}`); if (!response.ok) { @@ -63,230 +119,250 @@ export function SecretsTable({ secrets }: SecretsTableProps) { } }; - const handleDeleteSecret = async (secretId: string) => { - if (!confirm('Are you sure you want to delete this secret? This action cannot be undone.')) { - return; + const handleCopySecret = (secretId: string) => { + const value = revealedSecrets[secretId]; + if (value) { + navigator.clipboard.writeText(value); + toast.success('Secret copied to clipboard'); } + }; + const handleDeleteClick = (secret: Secret) => { + setSecretToDelete(secret); + setDeleteDialogOpen(true); + }; + + const handleConfirmDelete = async () => { + if (!secretToDelete) return; + + setIsDeleting(true); try { - // Get organizationId from the URL path const pathSegments = window.location.pathname.split('/'); - const orgId = pathSegments[1]; // Assuming path is /{orgId}/settings/secrets + const orgId = pathSegments[1]; - const response = await fetch(`/api/secrets/${secretId}?organizationId=${orgId}`, { - method: 'DELETE', - }); + const response = await fetch( + `/api/secrets/${secretToDelete.id}?organizationId=${orgId}`, + { method: 'DELETE' }, + ); if (!response.ok) { throw new Error('Failed to delete secret'); } toast.success('Secret deleted successfully'); - // Reload the page to refresh the list + setDeleteDialogOpen(false); + setSecretToDelete(null); window.location.reload(); } catch (error) { toast.error('Failed to delete secret'); console.error('Error deleting secret:', error); + } finally { + setIsDeleting(false); } }; + const filteredSecrets = useMemo(() => { + if (!searchQuery) return secrets; + const query = searchQuery.toLowerCase(); + return secrets.filter( + (secret) => + secret.name.toLowerCase().includes(query) || + secret.description?.toLowerCase().includes(query), + ); + }, [secrets, searchQuery, ]); + + const pageCount = Math.max(1, Math.ceil(filteredSecrets.length / perPage)); + const paginatedSecrets = filteredSecrets.slice((page - 1) * perPage, page * perPage); + + const isEmpty = secrets.length === 0; + const isSearchEmpty = filteredSecrets.length === 0 && searchQuery; + return ( - + + {/* Search Bar */} + + + + + + { + setSearchQuery(e.target.value); + setPage(1); + }} + /> + + + {/* Table */} - - + {isEmpty || isSearchEmpty ? ( + + + + {searchQuery ? 'No secrets found' : 'No secrets yet'} + + + {searchQuery + ? 'Try adjusting your search.' + : 'Create your first secret to enable AI automations.'} + + + + ) : ( + { + setPerPage(size); + setPage(1); + }, + }} + > - - - Name - - - Value - - - Category - - - Description - - - Last Used - - - Created - - - Actions - + + NAME + VALUE + CATEGORY + LAST USED + CREATED + ACTIONS - {secrets.length === 0 ? ( - - - - - - - - No secrets yet - - Create your first secret to enable AI automations - - - + {paginatedSecrets.map((secret) => ( + + + {secret.name} - - ) : ( - secrets.map((secret) => ( - - - {secret.name} - - - - {loadingSecrets[secret.id] ? ( - - - Loading... - - ) : revealedSecrets[secret.id] ? ( - - - - { - navigator.clipboard.writeText(revealedSecrets[secret.id]); - toast.success('Secret copied to clipboard'); - }} - className="inline-flex items-center gap-1.5 text-sm bg-primary/10 px-3 py-1.5 rounded-md font-mono hover:bg-primary/20 transition-all max-w-[240px] group border border-primary/20" - > - - {revealedSecrets[secret.id]} - - - - - - Click to copy - - - - ) : ( - - •••••••••••• - - )} - - - - handleRevealSecret(secret.id)} - disabled={loadingSecrets[secret.id]} - > - {revealedSecrets[secret.id] ? ( - - ) : ( - - )} - - - - {revealedSecrets[secret.id] ? 'Hide' : 'Reveal'} secret - - - - - - - {secret.category ? ( - + + {loadingSecrets[secret.id] ? ( + + + + Loading... + + + ) : revealedSecrets[secret.id] ? ( + handleCopySecret(secret.id)} + className="inline-flex items-center gap-1.5 rounded-md border border-border bg-muted px-2 py-1 font-mono text-sm transition-colors hover:bg-muted/80" > - {secret.category.replace('_', ' ')} - - ) : ( - — - )} - - - {secret.description ? ( - - - - - {secret.description} - - - - {secret.description} - - - + + {revealedSecrets[secret.id]} + + + ) : ( - — + + •••••••••••• + )} - - - - {secret.lastUsedAt - ? format(new Date(secret.lastUsedAt), 'MMM d, yyyy') - : 'Never'} - - - - - {format(new Date(secret.createdAt), 'MMM d, yyyy')} - - - - - - - - setEditingSecret(secret)} - > - - - - - Edit secret - - - - - - - handleDeleteSecret(secret.id)} - > - - - - - Delete secret - - - - - - - )) - )} + handleRevealSecret(secret.id)} + disabled={loadingSecrets[secret.id]} + className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50" + > + {revealedSecrets[secret.id] ? ( + + ) : ( + + )} + + + + + {secret.category ? ( + + {CATEGORY_MAP[secret.category] || secret.category.replace('_', ' ')} + + ) : ( + + — + + )} + + + + {secret.lastUsedAt ? formatDate(secret.lastUsedAt) : 'Never'} + + + + + {formatDate(secret.createdAt)} + + + + + + e.stopPropagation()} + > + + + + { + e.stopPropagation(); + setEditingSecret(secret); + }} + > + + Edit + + + { + e.stopPropagation(); + handleDeleteClick(secret); + }} + > + + Delete + + + + + + + ))} - + )} + + {/* Delete Confirmation Dialog */} + + + + Delete Secret + + Are you sure you want to delete "{secretToDelete?.name}"? This action + cannot be undone. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + {/* Edit Secret Dialog */} {editingSecret && ( @@ -297,6 +373,6 @@ export function SecretsTable({ secrets }: SecretsTableProps) { onSecretUpdated={() => window.location.reload()} /> )} - + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx index bd90c2b4a..58aabc9c4 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx @@ -1,6 +1,8 @@ 'use client'; import { regenerateTaskAction } from '@/actions/tasks/regenerate-task-action'; +import { SelectAssignee } from '@/components/SelectAssignee'; +import { useOrganizationMembers } from '@/hooks/use-organization-members'; import { apiClient } from '@/lib/api-client'; import { downloadTaskEvidenceZip } from '@/lib/evidence-download'; import { useActiveMember } from '@/utils/auth-client'; @@ -30,7 +32,8 @@ import { type TaskStatus, type User, } from '@db'; -import { ChevronRight, Download, RefreshCw, Trash2 } from 'lucide-react'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@trycompai/design-system'; +import { CheckCircle2, ChevronRight, Clock, Download, RefreshCw, SendHorizontal, Trash2, XCircle } from 'lucide-react'; import { useAction } from 'next-safe-action/hooks'; import Link from 'next/link'; import { useParams } from 'next/navigation'; @@ -38,6 +41,7 @@ import { useState } from 'react'; import { toast } from 'sonner'; import { Comments } from '../../../../../../components/comments/Comments'; import { useTask } from '../hooks/use-task'; +import { useTaskActivity } from '../hooks/use-task-activity'; import { useTaskAutomations } from '../hooks/use-task-automations'; import { BrowserAutomations } from './BrowserAutomations'; import { FindingHistoryPanel } from './findings/FindingHistoryPanel'; @@ -47,6 +51,7 @@ import { TaskAutomationStatusBadge } from './TaskAutomationStatusBadge'; import { TaskDeleteDialog } from './TaskDeleteDialog'; import { TaskIntegrationChecks } from './TaskIntegrationChecks'; import { TaskMainContent } from './TaskMainContent'; +import { TaskActivityFull } from './TaskActivity'; import { TaskPropertiesSidebar } from './TaskPropertiesSidebar'; type AutomationWithRuns = EvidenceAutomation & { @@ -59,6 +64,7 @@ interface SingleTaskProps { initialAutomations: AutomationWithRuns[]; isWebAutomationsEnabled: boolean; isPlatformAdmin: boolean; + evidenceApprovalEnabled?: boolean; } export function SingleTask({ @@ -66,11 +72,11 @@ export function SingleTask({ initialAutomations, isWebAutomationsEnabled, isPlatformAdmin, + evidenceApprovalEnabled = false, }: SingleTaskProps) { const params = useParams(); const orgId = params.orgId as string; - // Use SWR hooks with initial data from server const { task, isLoading, @@ -81,15 +87,14 @@ export function SingleTask({ const { automations } = useTaskAutomations({ initialData: initialAutomations, }); + const { mutate: mutateActivity } = useTaskActivity(); - // Get current member role information for findings permissions const { data: activeMember } = useActiveMember(); + const { members } = useOrganizationMembers(); - // Parse member roles const memberRoles = activeMember?.role?.split(',').map((r: string) => r.trim()) || []; const isAuditor = memberRoles.includes('auditor'); const isAdminOrOwner = memberRoles.includes('admin') || memberRoles.includes('owner'); - // isPlatformAdmin is passed from the server component (page.tsx) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [isRegenerateConfirmOpen, setRegenerateConfirmOpen] = useState(false); @@ -97,6 +102,11 @@ export function SingleTask({ null, ); + // Approval dialog state + const [isApprovalDialogOpen, setApprovalDialogOpen] = useState(false); + const [reviewApproverId, setReviewApproverId] = useState(null); + const [isSubmittingForReview, setIsSubmittingForReview] = useState(false); + const regenerate = useAction(regenerateTaskAction, { onSuccess: () => { toast.success('Task updated with latest template content.'); @@ -106,14 +116,83 @@ export function SingleTask({ }, }); + const handleRequestApproval = () => { + // Pre-populate with existing approver if one is already assigned + setReviewApproverId(task?.approverId ?? null); + setApprovalDialogOpen(true); + }; + + const handleSubmitForReview = async () => { + if (!task || !orgId || !reviewApproverId) return; + setIsSubmittingForReview(true); + try { + const response = await apiClient.post( + `/v1/tasks/${task.id}/submit-for-review`, + { approverId: reviewApproverId }, + orgId, + ); + if (response.error) { + throw new Error(response.error); + } + toast.success('Task submitted for approval'); + setApprovalDialogOpen(false); + setReviewApproverId(null); + await Promise.all([mutateTask(), mutateActivity()]); + } catch (error) { + console.error('Failed to submit for review:', error); + toast.error(error instanceof Error ? error.message : 'Failed to submit for review'); + } finally { + setIsSubmittingForReview(false); + } + }; + + const handleApproveTask = async () => { + if (!task || !orgId) return; + try { + const response = await apiClient.post( + `/v1/tasks/${task.id}/approve`, + {}, + orgId, + ); + if (response.error) { + throw new Error(response.error); + } + toast.success('Task approved successfully'); + await Promise.all([mutateTask(), mutateActivity()]); + } catch (error) { + console.error('Failed to approve task:', error); + toast.error(error instanceof Error ? error.message : 'Failed to approve task'); + } + }; + + const handleRejectTask = async () => { + if (!task || !orgId) return; + try { + const response = await apiClient.post( + `/v1/tasks/${task.id}/reject`, + {}, + orgId, + ); + if (response.error) { + throw new Error(response.error); + } + toast.success('Task review rejected'); + await Promise.all([mutateTask(), mutateActivity()]); + } catch (error) { + console.error('Failed to reject task:', error); + toast.error(error instanceof Error ? error.message : 'Failed to reject task'); + } + }; + const handleUpdateTask = async ( - data: Partial>, + data: Partial>, ) => { if (!task || !orgId) return; const updatePayload: { status?: TaskStatus; assigneeId?: string | null; + approverId?: string | null; frequency?: string | null; department?: string | null; reviewDate?: string | null; @@ -128,6 +207,9 @@ export function SingleTask({ if (data.assigneeId !== undefined) { updatePayload.assigneeId = data.assigneeId; } + if (data.approverId !== undefined) { + updatePayload.approverId = data.approverId ?? null; + } if (Object.prototype.hasOwnProperty.call(data, 'frequency')) { updatePayload.frequency = data.frequency ?? null; } @@ -148,7 +230,6 @@ export function SingleTask({ throw new Error(response.error); } - // Refresh the task data from the server await mutateTask(); } catch (error) { console.error('Failed to update task:', error); @@ -157,11 +238,24 @@ export function SingleTask({ } }; - // Early return if task doesn't exist if (!task || isLoading) { return null; } + // Approval state + const isInReview = task.status === 'in_review'; + const isCurrentUserApprover = + activeMember?.id && task.approverId && activeMember.id === task.approverId; + const canApprove = evidenceApprovalEnabled && isInReview && isCurrentUserApprover; + const isCurrentUserAssignee = + activeMember?.id && task.assigneeId && activeMember.id === task.assigneeId; + const canCancel = + evidenceApprovalEnabled && isInReview && isAdminOrOwner && !isCurrentUserApprover; + + // Find the approver member for the banner + const approverMember = + !task.approverId || !members ? null : members.find((m) => m.id === task.approverId); + return ( {/* Breadcrumb */} @@ -190,130 +284,204 @@ export function SingleTask({ - {/* Main Content Layout */} - - {/* Left Column - Title, Description, Automations (Front & Center) */} - - {/* Header Section */} - - - - - - {task.title} - - + + + Overview + Activity + + + + + {/* Approval Banner */} + {evidenceApprovalEnabled && isInReview && ( + + {canApprove ? ( + + + + + Your approval is required + + Review the evidence for this task and approve or reject it. + + + + + + Reject + + + + Approve + + + + + ) : canCancel ? ( + + + + + Pending approval + + Waiting for {approverMember ? `${approverMember.user.name || approverMember.user.email}` : 'the approver'} to review and approve this task. + + + + + Cancel + + + + ) : ( + + + + + Pending approval + + Waiting for {approverMember ? `${approverMember.user.name || approverMember.user.email}` : 'the approver'} to review and approve this task. + + + + + )} + + )} + + + {/* Left Column */} + + {/* Header Section */} + + + + + {task.title} + + + + {task.description && ( + + {task.description} + + )} + + + { + try { + await downloadTaskEvidenceZip({ + taskId: task.id, + taskTitle: task.title, + organizationId: orgId, + includeJson: true, + }); + toast.success('Task evidence downloaded'); + } catch (err) { + toast.error('Failed to download evidence'); + } + }} + className="h-8 w-8 text-muted-foreground hover:text-foreground" + title="Download task evidence" + > + + + setRegenerateConfirmOpen(true)} + className="h-8 w-8 text-muted-foreground hover:text-foreground" + title="Regenerate task" + > + + + setDeleteDialogOpen(true)} + className="h-8 w-8 text-muted-foreground hover:text-destructive" + title="Delete task" + > + + - {task.description && ( - - {task.description} - - )} - - { - try { - await downloadTaskEvidenceZip({ - taskId: task.id, - taskTitle: task.title, - organizationId: orgId, - includeJson: true, - }); - toast.success('Task evidence downloaded'); - } catch (err) { - toast.error('Failed to download evidence'); - } - }} - className="h-8 w-8 text-muted-foreground hover:text-foreground" - title="Download task evidence" - > - - - setRegenerateConfirmOpen(true)} - className="h-8 w-8 text-muted-foreground hover:text-foreground" - title="Regenerate task" - > - - - setDeleteDialogOpen(true)} - className="h-8 w-8 text-muted-foreground hover:text-destructive" - title="Delete task" - > - - + {/* Attachments */} + + - - - {/* Attachments */} - - - + {/* Integration Checks Section */} + mutateTask()} + isManualTask={task.automationStatus === 'MANUAL'} + /> - {/* Integration Checks Section */} - mutateTask()} - isManualTask={task.automationStatus === 'MANUAL'} - /> - - {/* Findings Section */} - - - {/* Browser Automations Section */} - {isWebAutomationsEnabled && ( - - )} + {/* Findings Section */} + - {/* Custom Automations Section */} - - - {/* Comments Section */} - - - - - - {/* Right Column - Properties */} - - - - - {/* Finding History Panel */} - {selectedFindingIdForHistory && ( - setSelectedFindingIdForHistory(null)} + {/* Browser Automations Section */} + {isWebAutomationsEnabled && ( + + )} + + {/* Custom Automations Section */} + - )} + + {/* Comments Section */} + + + + + + {/* Right Column - Properties */} + + + + + {/* Finding History Panel */} + {selectedFindingIdForHistory && ( + setSelectedFindingIdForHistory(null)} + /> + )} + + - - + + + + + + + + + {/* Delete Dialog */} + + {/* Approval Dialog - opens when user tries to set status to "done" */} + { + if (!open) { + setApprovalDialogOpen(false); + setReviewApproverId(null); + } + }} + > + + + Select Approver + + Select an approver to review the evidence for this task. + Once approved, the task will be marked as done. + + + + setReviewApproverId(id)} + assigneeId={reviewApproverId || ''} + withTitle={false} + /> + + + { + setApprovalDialogOpen(false); + setReviewApproverId(null); + }} + > + Cancel + + + {isSubmittingForReview ? ( + 'Submitting...' + ) : ( + <> + + Submit for Approval + > + )} + + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskActivity.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskActivity.tsx new file mode 100644 index 000000000..f0a0bb458 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskActivity.tsx @@ -0,0 +1,258 @@ +'use client'; + +import { Avatar, AvatarFallback, AvatarImage } from '@comp/ui/avatar'; +import { Badge } from '@comp/ui/badge'; +import { Button } from '@comp/ui/button'; +import { cn } from '@comp/ui/cn'; +import type { AuditLog } from '@db'; +import { format } from 'date-fns'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { useState } from 'react'; +import { type ActivityLog, useTaskActivity } from '../hooks/use-task-activity'; + +type ActionType = 'review' | 'approve' | 'reject' | 'create' | 'update' | 'delete'; + +const actionColors: Record = { + approve: 'bg-primary/15 text-primary border-transparent dark:bg-primary/25 dark:brightness-150', + reject: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', + review: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300', + create: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300', + update: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300', + delete: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300', +}; + +function getAction(log: AuditLog): ActionType { + const data = log.data as Record | null; + return (data?.action as ActionType) || 'update'; +} + +function LogEntry({ log, isLast = false }: { log: ActivityLog; isLast?: boolean }) { + const action = getAction(log); + const user = log.user; + const userName = user?.name || user?.email || 'Unknown'; + + return ( + + {/* Timeline connector */} + + + {user?.image && } + + {userName.charAt(0).toUpperCase()} + + + {!isLast && ( + + )} + + + + {userName} + + {action.charAt(0).toUpperCase() + action.slice(1)} + + + {log.description} + + {format(new Date(log.timestamp), 'MMM d, yyyy h:mm a')} + + + + ); +} + +function LogEntryCompact({ log }: { log: ActivityLog }) { + const action = getAction(log); + const user = log.user; + const userName = user?.name || user?.email || 'Unknown'; + + return ( + + + {user?.image && } + + {userName.charAt(0).toUpperCase()} + + + + + {userName} + + {action.charAt(0).toUpperCase() + action.slice(1)} + + + {log.description} + + {format(new Date(log.timestamp), 'MMM d, yyyy h:mm a')} + + + + ); +} + +const COLLAPSED_COUNT = 3; +const PAGE_SIZE = 10; + +export function TaskActivity() { + const [expanded, setExpanded] = useState(false); + const [take, setTake] = useState(COLLAPSED_COUNT); + const { logs, total } = useTaskActivity({ take }); + + if (logs.length === 0) return null; + + const hasMore = total > COLLAPSED_COUNT; + const hasNextPage = expanded && logs.length < total; + + const handleExpand = () => { + setExpanded(true); + setTake(PAGE_SIZE); + }; + + const handleCollapse = () => { + setExpanded(false); + setTake(COLLAPSED_COUNT); + }; + + const handleLoadMore = () => { + setTake((prev) => prev + PAGE_SIZE); + }; + + return ( + + Activity + + {logs.map((log) => ( + + ))} + + + {hasNextPage && ( + + + Load more + + )} + {hasMore && ( + + {expanded ? ( + <> + + Show less + > + ) : ( + <> + + View all + > + )} + + )} + + + ); +} + +export function TaskActivityFull() { + const [page, setPage] = useState(1); + const skip = (page - 1) * PAGE_SIZE; + const { logs, total, isLoading } = useTaskActivity({ take: PAGE_SIZE, skip }); + + const totalPages = Math.ceil(total / PAGE_SIZE); + + if (isLoading) { + return ( + + Loading activity... + + ); + } + + if (logs.length === 0 && page === 1) { + return ( + + No activity yet. + + ); + } + + return ( + + + {logs.map((log, index) => ( + + ))} + + {totalPages > 1 && ( + + + Page {page} of {totalPages} + + + setPage((p) => p - 1)} + disabled={page <= 1} + > + Previous + + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((p) => p === 1 || p === totalPages || Math.abs(p - page) <= 1) + .reduce<(number | 'ellipsis')[]>((acc, p, i, arr) => { + if (i > 0 && p - (arr[i - 1] as number) > 1) { + acc.push('ellipsis'); + } + acc.push(p); + return acc; + }, []) + .map((item, i) => + item === 'ellipsis' ? ( + ... + ) : ( + setPage(item)} + > + {item} + + ), + )} + setPage((p) => p + 1)} + disabled={page >= totalPages} + > + Next + + + + )} + + ); +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskAutomationStatusBadge.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskAutomationStatusBadge.tsx index 24aef034e..8fcb54398 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskAutomationStatusBadge.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskAutomationStatusBadge.tsx @@ -28,7 +28,7 @@ export function TaskAutomationStatusBadge({ status, className }: TaskAutomationS className, )} > - Automated + Automated diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx index 46a983ef9..28f62f32e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx @@ -15,23 +15,51 @@ import { DEPARTMENT_COLORS, taskDepartments, taskFrequencies, taskStatuses } fro interface TaskPropertiesSidebarProps { handleUpdateTask: ( - data: Partial>, + data: Partial>, ) => void; + evidenceApprovalEnabled?: boolean; + onRequestApproval?: () => void; } -export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSidebarProps) { +export function TaskPropertiesSidebar({ + handleUpdateTask, + evidenceApprovalEnabled = false, + onRequestApproval, +}: TaskPropertiesSidebarProps) { const { orgId } = useParams<{ orgId: string }>(); const { task, isLoading } = useTask(); const { members } = useOrganizationMembers(); - console.log('members', members); - const assignedMember = !task?.assigneeId || !members ? null : members.find((m) => m.id === task.assigneeId); + const approverMember = + !task?.approverId || !members ? null : members.find((m) => m.id === task.approverId); + if (isLoading) return Loading...; if (!task) return null; + // Lock status changes when task is in review and approval is enabled + const isStatusLocked = evidenceApprovalEnabled && task.status === 'in_review'; + + const handleStatusChange = (selectedStatus: string | null) => { + if (!selectedStatus) return; + + // Prevent manual status changes when task is pending approval + if (isStatusLocked) return; + + // Intercept "done" status when evidence approval is enabled + if (evidenceApprovalEnabled && selectedStatus === 'done' && onRequestApproval) { + onRequestApproval(); + return; + } + + handleUpdateTask({ + status: selectedStatus as TaskStatus, + reviewDate: selectedStatus === 'done' ? new Date() : task.reviewDate, + }); + }; + return ( Properties @@ -42,7 +70,7 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba Status value={task.status} - options={taskStatuses} + options={taskStatuses.filter((s) => s !== 'in_review')} getKey={(status) => status} renderOption={(status) => ( @@ -50,16 +78,12 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba {status.replace('_', ' ')} )} - onSelect={(selectedStatus) => { - handleUpdateTask({ - status: selectedStatus as TaskStatus, - reviewDate: selectedStatus === 'done' ? new Date() : task.reviewDate, - }); - }} + onSelect={handleStatusChange} trigger={ {task.status.replace('_', ' ')} @@ -68,6 +92,7 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba searchPlaceholder="Change status..." emptyText="No status found." contentWidth="w-48" + disabled={isStatusLocked} /> @@ -127,11 +152,74 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba emptyText="No member found." contentWidth="w-64" disabled={members?.length === 0} - allowUnassign={true} // Enable unassign option - showCheck={false} // Hide check icon for assignee selector + allowUnassign={true} + showCheck={false} /> + {/* Approver Selector (visible when evidence approval is enabled) */} + {evidenceApprovalEnabled && ( + + Approver + + value={task.approverId} + options={members ?? []} + getKey={(member) => member.id} + getSearchValue={(member) => `${member.user?.name || ''} ${member.user?.email || ''}`.trim() || member.id} + renderOption={(member) => ( + + + {member.user?.image && ( + + )} + + {member.user?.name?.charAt(0) ?? member.user?.email?.charAt(0)?.toUpperCase() ?? '?'} + + + {member.user.name || member.user.email} + + )} + onSelect={(selectedApproverId) => { + handleUpdateTask({ + approverId: selectedApproverId === null ? null : selectedApproverId, + }); + }} + trigger={ + + {approverMember ? ( + <> + + {approverMember.user?.image && ( + + )} + + {approverMember.user?.name?.charAt(0) ?? approverMember.user?.email?.charAt(0)?.toUpperCase() ?? '?'} + + + {approverMember.user.name || approverMember.user.email} + > + ) : ( + Unassigned + )} + + } + searchPlaceholder="Change approver..." + emptyText="No member found." + contentWidth="w-64" + disabled={members?.length === 0} + allowUnassign={true} + showCheck={false} + /> + + )} + {/* Frequency Selector */} Frequency @@ -141,7 +229,6 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba getKey={(freq) => freq} renderOption={(freq) => {freq.replace('_', ' ')}} onSelect={(selectedFreq) => { - // Pass null directly if 'None' (unassign) was selected handleUpdateTask({ frequency: selectedFreq === null ? null : (selectedFreq as TaskFrequency), }); @@ -171,12 +258,10 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba getKey={(dept) => dept} renderOption={(dept) => { if (dept === 'none') { - // Render 'none' as plain text return None; } - // Render other departments as colored badges const mainColor = DEPARTMENT_COLORS[dept] ?? DEPARTMENT_COLORS.none; - const lightBgColor = `${mainColor}1A`; // Add opacity for lighter background + const lightBgColor = `${mainColor}1A`; return ( { const currentDept = task.department ?? 'none'; if (currentDept === 'none') { - // Render 'None' as plain text for the trigger return None; } - // Render other departments as colored badges - const mainColor = DEPARTMENT_COLORS[currentDept] ?? DEPARTMENT_COLORS.none; // Fallback - const lightBgColor = `${mainColor}1A`; // Add opacity + const mainColor = DEPARTMENT_COLORS[currentDept] ?? DEPARTMENT_COLORS.none; + const lightBgColor = `${mainColor}1A`; return ( )} - {/* Review Date Selector */} + + {/* Review Date */} Review Date @@ -261,6 +345,16 @@ export function TaskPropertiesSidebar({ handleUpdateTask }: TaskPropertiesSideba )} + + {/* Approved At - shown when task has been approved */} + {task.approvedAt && ( + + Approved At + + {format(new Date(task.approvedAt), 'M/d/yyyy')} + + + )} ); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-activity.ts b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-activity.ts new file mode 100644 index 000000000..81ec1f917 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/hooks/use-task-activity.ts @@ -0,0 +1,53 @@ +import { api } from '@/lib/api-client'; +import type { AuditLog, User } from '@db'; +import { useParams } from 'next/navigation'; +import useSWR from 'swr'; + +export type ActivityLog = AuditLog & { + user: Pick | null; +}; + +interface ActivityResponse { + logs: ActivityLog[]; + total: number; +} + +interface UseTaskActivityOptions { + take?: number; + skip?: number; +} + +export function useTaskActivity({ take = 3, skip = 0 }: UseTaskActivityOptions = {}) { + const { orgId, taskId } = useParams<{ orgId: string; taskId: string }>(); + + const { data, error, isLoading, mutate } = useSWR( + orgId && taskId ? [`task-activity-${taskId}`, orgId, taskId, skip, take] : null, + async () => { + if (!orgId || !taskId) { + throw new Error('Organization ID and Task ID are required'); + } + + const response = await api.get( + `/v1/tasks/${taskId}/activity?skip=${skip}&take=${take}`, + orgId, + ); + + if (response.error) { + throw new Error(response.error); + } + + return response.data ?? { logs: [], total: 0 }; + }, + { + revalidateOnFocus: true, + }, + ); + + return { + logs: data?.logs ?? [], + total: data?.total ?? 0, + isLoading, + isError: !!error, + mutate, + }; +} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx index f32bfb1f5..32d42508f 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/[taskId]/page.tsx @@ -25,6 +25,7 @@ export default async function TaskPage({ let isWebAutomationsEnabled = false; let isPlatformAdmin = false; + let evidenceApprovalEnabled = false; if (session?.user?.id) { const flags = await getFeatureFlags(session.user.id); @@ -40,12 +41,20 @@ export default async function TaskPage({ isPlatformAdmin = user?.isPlatformAdmin ?? false; } + // Fetch organization setting for evidence approval + const organization = await db.organization.findUnique({ + where: { id: orgId }, + select: { evidenceApprovalEnabled: true }, + }); + evidenceApprovalEnabled = organization?.evidenceApprovalEnabled ?? false; + return ( ); } @@ -63,6 +72,9 @@ const getTask = async (taskId: string) => { }, include: { controls: true, + approver: { + include: { user: true }, + }, }, }); diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx index b6007c592..dae5ece73 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/BulkTaskStatusChangeModal.tsx @@ -1,6 +1,7 @@ 'use client'; import { apiClient } from '@/lib/api-client'; +import { SelectAssignee } from '@/components/SelectAssignee'; import { Button } from '@comp/ui/button'; import { Dialog, @@ -12,7 +13,7 @@ import { } from '@comp/ui/dialog'; import { Label } from '@comp/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@comp/ui/select'; -import { TaskStatus } from '@db'; +import { Member, TaskStatus, User } from '@db'; import { Loader2 } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; @@ -24,6 +25,8 @@ interface BulkTaskStatusChangeModalProps { selectedTaskIds: string[]; onOpenChange: (open: boolean) => void; onSuccess?: () => void; + evidenceApprovalEnabled?: boolean; + members?: (Member & { user: User })[]; } export function BulkTaskStatusChangeModal({ @@ -31,22 +34,33 @@ export function BulkTaskStatusChangeModal({ onOpenChange, selectedTaskIds, onSuccess, + evidenceApprovalEnabled = false, + members = [], }: BulkTaskStatusChangeModalProps) { const router = useRouter(); const params = useParams<{ orgId: string }>(); const orgIdParam = Array.isArray(params.orgId) ? params.orgId[0] : params.orgId; - const statusOptions = useMemo(() => Object.values(TaskStatus) as TaskStatus[], []); + // Filter out in_review from status options - it's only set through approval flow + const statusOptions = useMemo( + () => (Object.values(TaskStatus) as TaskStatus[]).filter((s) => s !== TaskStatus.in_review), + [], + ); const defaultStatus = statusOptions[0]; const [status, setStatus] = useState(defaultStatus); const [isSubmitting, setIsSubmitting] = useState(false); + const [approverId, setApproverId] = useState(null); const selectedCount = selectedTaskIds.length; const isSingular = selectedCount === 1; + // Whether we need approval for this status change + const needsApproval = evidenceApprovalEnabled && status === TaskStatus.done; + useEffect(() => { if (open) { setStatus(defaultStatus); + setApproverId(null); } }, [defaultStatus, open]); @@ -55,26 +69,51 @@ export function BulkTaskStatusChangeModal({ return; } + // If approval is needed, validate approver is selected + if (needsApproval && !approverId) { + toast.error('Please select an approver'); + return; + } + try { setIsSubmitting(true); - const payload = { - taskIds: selectedTaskIds, - status, - ...(status === TaskStatus.done ? { reviewDate: new Date().toISOString() } : {}), - }; - - const response = await apiClient.patch<{ updatedCount: number }>( - '/v1/tasks/bulk', - payload, - orgIdParam, - ); - - if (response.error) { - throw new Error(response.error); + + if (needsApproval) { + // Submit all tasks for review in a single bulk request + const response = await apiClient.post<{ updatedCount: number }>( + '/v1/tasks/bulk/submit-for-review', + { taskIds: selectedTaskIds, approverId }, + orgIdParam, + ); + + if (response.error) { + throw new Error(response.error); + } + + const updatedCount = response.data?.updatedCount ?? selectedTaskIds.length; + toast.success(`${updatedCount} task${updatedCount === 1 ? '' : 's'} submitted for review`); + } else { + // Normal bulk status change + const payload = { + taskIds: selectedTaskIds, + status, + ...(status === TaskStatus.done ? { reviewDate: new Date().toISOString() } : {}), + }; + + const response = await apiClient.patch<{ updatedCount: number }>( + '/v1/tasks/bulk', + payload, + orgIdParam, + ); + + if (response.error) { + throw new Error(response.error); + } + + const updatedCount = response.data?.updatedCount ?? selectedTaskIds.length; + toast.success(`Updated ${updatedCount} task${updatedCount === 1 ? '' : 's'}`); } - const updatedCount = response.data?.updatedCount ?? selectedTaskIds.length; - toast.success(`Updated ${updatedCount} task${updatedCount === 1 ? '' : 's'}`); onSuccess?.(); onOpenChange(false); router.refresh(); @@ -99,31 +138,53 @@ export function BulkTaskStatusChangeModal({ - - Status - setStatus(value as TaskStatus)}> - - - - - {statusOptions.map((option) => ( - - - - {option.replace('_', ' ')} - - - ))} - - + + + Status + setStatus(value as TaskStatus)}> + + + + + {statusOptions.map((option) => ( + + + + {option.replace('_', ' ')} + + + ))} + + + + + {needsApproval && ( + + + Evidence approval is enabled. Select an approver to review these tasks before they can be marked as done. + + + + )} onOpenChange(false)}> Cancel - - {isSubmitting ? : 'Change Status'} + + {isSubmitting ? ( + + ) : needsApproval ? ( + 'Submit for Review' + ) : ( + 'Change Status' + )} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx index fc016b217..1ac66943c 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/ModernSingleStatusTaskList.tsx @@ -36,6 +36,7 @@ interface ModernSingleStatusTaskListProps { })[]; members: (Member & { user: User })[]; handleTaskClick: (taskId: string) => void; + evidenceApprovalEnabled?: boolean; } export function ModernSingleStatusTaskList({ @@ -43,6 +44,7 @@ export function ModernSingleStatusTaskList({ tasks, members, handleTaskClick, + evidenceApprovalEnabled = false, }: ModernSingleStatusTaskListProps) { const [selectable, setSelectable] = useState(false); const [selectedTaskIds, setSelectedTaskIds] = useState([]); @@ -160,6 +162,8 @@ export function ModernSingleStatusTaskList({ open={openBulkStatus} onOpenChange={setOpenBulkStatus} onSuccess={handleBulkActionSuccess} + evidenceApprovalEnabled={evidenceApprovalEnabled} + members={members} /> = { todo: [], in_progress: [], + in_review: [], done: [], failed: [], not_relevant: [], @@ -68,6 +71,7 @@ export function ModernTaskList({ tasks, members, statusFilter }: ModernTaskListP }; const statusOrder: Array = [ + 'in_review', 'todo', 'in_progress', 'done', @@ -94,7 +98,7 @@ export function ModernTaskList({ tasks, members, statusFilter }: ModernTaskListP const config = statusConfig[status]; return ( - + ); })} diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx index bebf84474..e1182bfb4 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskCard.tsx @@ -14,7 +14,7 @@ export const ItemTypes = { }; // Type representing valid task status IDs. -export type StatusId = 'todo' | 'in_progress' | 'done' | 'failed' | 'not_relevant'; +export type StatusId = 'todo' | 'in_progress' | 'in_review' | 'done' | 'failed' | 'not_relevant'; // Interface for the data transferred during drag operations. export interface DragItem { diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx index 5ecd276b3..02fd034ae 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx @@ -1,6 +1,7 @@ 'use client'; import { updateTaskViewPreference } from '@/actions/tasks'; +import type { ReactNode } from 'react'; import type { Member, Task, User } from '@db'; import { Avatar, @@ -28,12 +29,14 @@ import { Check, Circle, FolderTree, List, Search, XCircle } from 'lucide-react'; import { useParams } from 'next/navigation'; import { useQueryState } from 'nuqs'; import { useEffect, useMemo, useState } from 'react'; +import type { FrameworkInstanceForTasks } from '../types'; import { ModernTaskList } from './ModernTaskList'; import { TasksByCategory } from './TasksByCategory'; const statuses = [ { id: 'todo', label: 'Todo', icon: Circle, color: 'text-slate-400' }, { id: 'in_progress', label: 'In Progress', icon: Circle, color: 'text-blue-400' }, + { id: 'in_review', label: 'In Review', icon: Circle, color: 'text-orange-400' }, { id: 'done', label: 'Done', icon: Check, color: 'text-emerald-400' }, { id: 'failed', label: 'Failed', icon: XCircle, color: 'text-red-400' }, { id: 'not_relevant', label: 'Not Relevant', icon: Circle, color: 'text-slate-500' }, @@ -42,8 +45,13 @@ const statuses = [ export function TaskList({ tasks: initialTasks, members, + frameworkInstances, activeTab, + evidenceApprovalEnabled = false, + afterAnalytics, + showFiltersAndList = true, }: { + evidenceApprovalEnabled?: boolean; tasks: (Task & { controls: { id: string; name: string }[]; evidenceAutomations?: Array<{ @@ -61,13 +69,17 @@ export function TaskList({ }>; })[]; members: (Member & { user: User })[]; + frameworkInstances: FrameworkInstanceForTasks[]; activeTab: 'categories' | 'list'; + afterAnalytics?: ReactNode; + showFiltersAndList?: boolean; }) { const params = useParams(); const orgId = params.orgId as string; const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useQueryState('status'); const [assigneeFilter, setAssigneeFilter] = useQueryState('assignee'); + const [frameworkFilter, setFrameworkFilter] = useQueryState('framework'); const [currentTab, setCurrentTab] = useState<'categories' | 'list'>(activeTab); // Sync activeTab prop with state when it changes @@ -75,6 +87,18 @@ export function TaskList({ setCurrentTab(activeTab); }, [activeTab]); + // Clear frameworkFilter when it's invalid or frameworks are empty. + // Prevents invisible filter (no dropdown when empty) and stale bookmarked URLs. + useEffect(() => { + if (!frameworkFilter) return; + const isValid = + frameworkInstances.length > 0 && + frameworkInstances.some((fw) => fw.id === frameworkFilter); + if (!isValid) { + setFrameworkFilter(null); + } + }, [frameworkFilter, frameworkInstances, setFrameworkFilter]); + const handleTabChange = async (value: string) => { const newTab = value as 'categories' | 'list'; setCurrentTab(newTab); @@ -100,7 +124,17 @@ export function TaskList({ }); }, [members]); - // Filter tasks by search query, status, and assignee + // Build a map of control IDs to their framework instances for efficient lookup + const frameworkControlIds = useMemo(() => { + const map = new Map>(); + for (const fw of frameworkInstances) { + const controlIds = new Set(fw.requirementsMapped.map((r) => r.controlId)); + map.set(fw.id, controlIds); + } + return map; + }, [frameworkInstances]); + + // Filter tasks by search query, status, assignee, and framework const filteredTasks = initialTasks.filter((task) => { const matchesSearch = searchQuery === '' || @@ -110,7 +144,16 @@ export function TaskList({ const matchesStatus = !statusFilter || task.status === statusFilter; const matchesAssignee = !assigneeFilter || task.assigneeId === assigneeFilter; - return matchesSearch && matchesStatus && matchesAssignee; + const matchesFramework = + !frameworkFilter || + (() => { + const fwControlIds = frameworkControlIds.get(frameworkFilter); + // Stale/invalid framework ID (e.g. from bookmarked URL): treat as "All frameworks" to match dropdown display + if (!fwControlIds) return true; + return task.controls.some((c) => fwControlIds.has(c.id)); + })(); + + return matchesSearch && matchesStatus && matchesAssignee && matchesFramework; }); // Calculate overall stats from all tasks (not filtered) @@ -120,6 +163,7 @@ export function TaskList({ (t) => t.status === 'done' || t.status === 'not_relevant', ).length; const inProgress = initialTasks.filter((t) => t.status === 'in_progress').length; + const inReview = initialTasks.filter((t) => t.status === 'in_review').length; const todo = initialTasks.filter((t) => t.status === 'todo').length; const completionRate = total > 0 ? Math.round((done / total) * 100) : 0; @@ -252,6 +296,7 @@ export function TaskList({ total, done, inProgress, + inReview, todo, completionRate, tasksWithAutomation, @@ -302,7 +347,7 @@ export function TaskList({ {/* Status Breakdown */} - + {/* Completed */} @@ -335,6 +380,22 @@ export function TaskList({ + {/* In Review */} + + + In Review + + + {overallStats.inReview} + + + {overallStats.total > 0 + ? Math.round((overallStats.inReview / overallStats.total) * 100) + : 0} + % + + + {/* To Do */} @@ -507,10 +568,10 @@ export function TaskList({ - {/* Separator after Analytics */} - + {afterAnalytics} {/* Unified Control Module */} + {showFiltersAndList && ( @@ -571,6 +632,36 @@ export function TaskList({ + {frameworkInstances.length > 0 && ( + setFrameworkFilter(value === 'all' ? null : value)} + > + + + {(() => { + if (!frameworkFilter) return 'All frameworks'; + const selectedFramework = frameworkInstances.find( + (fw) => fw.id === frameworkFilter, + ); + if (!selectedFramework) return 'All frameworks'; + return selectedFramework.framework.name; + })()} + + + + + All frameworks + + {frameworkInstances.map((fw) => ( + + {fw.framework.name} + + ))} + + + )} + setAssigneeFilter(value === 'all' ? null : value)} @@ -629,7 +720,7 @@ export function TaskList({ {/* Result Count */} - {(searchQuery || statusFilter || assigneeFilter) && ( + {(searchQuery || statusFilter || assigneeFilter || frameworkFilter) && ( {filteredTasks.length} {filteredTasks.length === 1 ? 'result' : 'results'} @@ -638,7 +729,7 @@ export function TaskList({ {/* Tabs - visible on all screens */} - + Categories @@ -659,12 +750,12 @@ export function TaskList({ /> - + - + )} ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusIndicator.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusIndicator.tsx index 50614f0e3..22c936cc8 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusIndicator.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TaskStatusIndicator.tsx @@ -1,6 +1,6 @@ import { cn } from '@comp/ui/cn'; import type { TaskStatus } from '@db'; -import { BadgeCheck, Circle, CircleDashed, Loader2, OctagonX } from 'lucide-react'; +import { BadgeCheck, Circle, CircleDashed, Eye, Loader2, OctagonX } from 'lucide-react'; interface TaskStatusIndicatorProps { status: TaskStatus; @@ -14,6 +14,9 @@ export function TaskStatusIndicator({ status, className }: TaskStatusIndicatorPr if (status === 'in_progress') { Icon = Loader2; iconClass = 'text-blue-400'; + } else if (status === 'in_review') { + Icon = Eye; + iconClass = 'text-orange-400'; } else if (status === 'done') { Icon = BadgeCheck; iconClass = 'text-primary'; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TasksByCategory.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TasksByCategory.tsx index a7ed8c453..6981d8ad7 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TasksByCategory.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TasksByCategory.tsx @@ -53,6 +53,7 @@ interface CategoryGroup { const statusPalette = { todo: { indicator: 'bg-border', dot: 'bg-border', label: 'text-muted-foreground' }, in_progress: { indicator: 'bg-blue-400/70', dot: 'bg-blue-400', label: 'text-blue-400' }, + in_review: { indicator: 'bg-orange-400/70', dot: 'bg-orange-400', label: 'text-orange-400' }, done: { indicator: 'bg-primary/70', dot: 'bg-primary', label: 'text-primary' }, failed: { indicator: 'bg-red-500/70', dot: 'bg-red-500', label: 'text-red-500' }, not_relevant: { indicator: 'bg-border', dot: 'bg-border', label: 'text-muted-foreground' }, diff --git a/apps/app/src/app/(app)/[orgId]/tasks/components/TasksPageClient.tsx b/apps/app/src/app/(app)/[orgId]/tasks/components/TasksPageClient.tsx index c7aec158b..347076b0e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/components/TasksPageClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/components/TasksPageClient.tsx @@ -1,5 +1,6 @@ 'use client'; +import { UpdateOrganizationEvidenceApproval } from '@/components/forms/organization/update-organization-evidence-approval'; import { downloadAllEvidenceZip } from '@/lib/evidence-download'; import type { Member, Task, User } from '@db'; import { @@ -13,10 +14,14 @@ import { PopoverTitle, PopoverTrigger, Switch, + Tabs, + TabsList, + TabsTrigger, } from '@trycompai/design-system'; import { Add, ArrowDown } from '@trycompai/design-system/icons'; import { useState } from 'react'; import { toast } from 'sonner'; +import type { FrameworkInstanceForTasks } from '../types'; import { CreateTaskSheet } from './CreateTaskSheet'; import { TaskList } from './TaskList'; @@ -39,25 +44,30 @@ interface TasksPageClientProps { })[]; members: (Member & { user: User })[]; controls: { id: string; name: string }[]; + frameworkInstances: FrameworkInstanceForTasks[]; activeTab: 'categories' | 'list'; orgId: string; organizationName: string | null; hasEvidenceExportAccess: boolean; + evidenceApprovalEnabled: boolean; } export function TasksPageClient({ tasks, members, controls, + frameworkInstances, activeTab, orgId, organizationName, hasEvidenceExportAccess, + evidenceApprovalEnabled, }: TasksPageClientProps) { const [isCreateSheetOpen, setIsCreateSheetOpen] = useState(false); const [isDownloadingAll, setIsDownloadingAll] = useState(false); const [includeRawJson, setIncludeRawJson] = useState(false); const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [mainTab, setMainTab] = useState('evidence-list'); const handleDownloadAllEvidence = async () => { setIsDownloadingAll(true); @@ -70,7 +80,13 @@ export function TasksPageClient({ toast.success('Evidence package downloaded successfully'); setIsPopoverOpen(false); } catch (err) { - toast.error('Failed to download evidence. Please try again.'); + const noEvidence = + err instanceof Error && err.message?.includes('No tasks with evidence found'); + if (noEvidence) { + toast.info('No tasks with evidence found to export.'); + } else { + toast.error('Failed to download evidence. Please try again.'); + } console.error('Evidence download error:', err); } finally { setIsDownloadingAll(false); @@ -78,56 +94,75 @@ export function TasksPageClient({ }; return ( - - {hasEvidenceExportAccess && ( - - - Export All Evidence - - - - Export Options - Download all task evidence as ZIP - - - Include raw JSON files - setIncludeRawJson(checked)} - /> - - } - onClick={handleDownloadAllEvidence} - disabled={isDownloadingAll} - width="full" - > - {isDownloadingAll ? 'Preparing…' : 'Export'} - - - - )} - } onClick={() => setIsCreateSheetOpen(true)}> - Create Evidence - + + + {hasEvidenceExportAccess && ( + + + Export All Evidence + + + + Export Options + Download all task evidence as ZIP + + + Include raw JSON files + setIncludeRawJson(checked)} + /> + + } + onClick={handleDownloadAllEvidence} + disabled={isDownloadingAll} + width="full" + > + {isDownloadingAll ? 'Preparing…' : 'Export'} + + + + )} + } onClick={() => setIsCreateSheetOpen(true)}> + Create Evidence + + + } + /> + } + > + + + Overview + Settings + } + showFiltersAndList={mainTab === 'evidence-list'} /> - } - padding="default" - > - - - + {mainTab === 'settings' && ( + + )} + + + ); } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/layout.tsx b/apps/app/src/app/(app)/[orgId]/tasks/layout.tsx index 1d243b811..42088673e 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/layout.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/layout.tsx @@ -1,7 +1,7 @@ -export default async function Layout({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); +interface LayoutProps { + children: React.ReactNode; +} + +export default function Layout({ children }: LayoutProps) { + return <>{children}>; } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/loading.tsx b/apps/app/src/app/(app)/[orgId]/tasks/loading.tsx index 9a8034df2..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx index 218f04457..6efff3555 100644 --- a/apps/app/src/app/(app)/[orgId]/tasks/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/tasks/page.tsx @@ -27,7 +27,8 @@ export default async function TasksPage({ const tasks = await getTasks(); const members = await getMembersWithMetadata(); const controls = await getControls(); - const { hasEvidenceExportAccess, organizationName } = + const frameworkInstances = await getFrameworkInstances(); + const { hasEvidenceExportAccess, organizationName, evidenceApprovalEnabled } = await getEvidenceExportContext(orgId); // Read tab preference from cookie (server-side, no hydration issues) @@ -40,10 +41,12 @@ export default async function TasksPage({ tasks={tasks} members={members} controls={controls} + frameworkInstances={frameworkInstances} activeTab={activeTab} orgId={orgId} organizationName={organizationName} hasEvidenceExportAccess={hasEvidenceExportAccess} + evidenceApprovalEnabled={evidenceApprovalEnabled} /> ); } @@ -63,7 +66,11 @@ const getEvidenceExportContext = async (organizationId: string) => { }); if (!session) { - return { hasEvidenceExportAccess: false, organizationName: null }; + return { + hasEvidenceExportAccess: false, + organizationName: null, + evidenceApprovalEnabled: false, + }; } const [member, organization] = await Promise.all([ @@ -77,19 +84,18 @@ const getEvidenceExportContext = async (organizationId: string) => { }), db.organization.findUnique({ where: { id: organizationId }, - select: { name: true }, + select: { name: true, evidenceApprovalEnabled: true }, }), ]); const roles = parseRolesString(member?.role); const hasEvidenceExportAccess = - roles.includes(Role.auditor) || - roles.includes(Role.admin) || - roles.includes(Role.owner); + roles.includes(Role.auditor) || roles.includes(Role.admin) || roles.includes(Role.owner); return { hasEvidenceExportAccess, organizationName: organization?.name ?? null, + evidenceApprovalEnabled: organization?.evidenceApprovalEnabled ?? false, }; }; @@ -195,3 +201,36 @@ const getControls = async () => { return controls; }; + +const getFrameworkInstances = async () => { + const session = await auth.api.getSession({ + headers: await headers(), + }); + + const orgId = session?.session.activeOrganizationId; + + if (!orgId) { + return []; + } + + const frameworkInstances = await db.frameworkInstance.findMany({ + where: { + organizationId: orgId, + }, + include: { + framework: { + select: { + id: true, + name: true, + }, + }, + requirementsMapped: { + select: { + controlId: true, + }, + }, + }, + }); + + return frameworkInstances; +}; diff --git a/apps/app/src/app/(app)/[orgId]/tasks/types.ts b/apps/app/src/app/(app)/[orgId]/tasks/types.ts new file mode 100644 index 000000000..9cbb311bd --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/tasks/types.ts @@ -0,0 +1,10 @@ +import type { FrameworkInstance } from '@db'; + +/** + * Shape of framework instance as returned by getFrameworkInstances and used by + * TaskList and TasksPageClient. Single source of truth to avoid type drift. + */ +export type FrameworkInstanceForTasks = Pick & { + framework: { id: string; name: string }; + requirementsMapped: { controlId: string }[]; +}; diff --git a/apps/app/src/app/(app)/[orgId]/trust/access-requests/loading.tsx b/apps/app/src/app/(app)/[orgId]/trust/access-requests/loading.tsx new file mode 100644 index 000000000..834c94b27 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/access-requests/loading.tsx @@ -0,0 +1,5 @@ +import { PageLayout } from '@trycompai/design-system'; + +export default function Loading() { + return ; +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/loading.tsx b/apps/app/src/app/(app)/[orgId]/trust/loading.tsx index 6b5907d34..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/trust/page.tsx b/apps/app/src/app/(app)/[orgId]/trust/page.tsx index 66b57518c..298ca768a 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/page.tsx @@ -255,7 +255,7 @@ export default async function TrustPage({ params }: { params: Promise<{ orgId: s }> + }> Visit Trust Portal diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx index b18e2932e..6af993ac2 100644 --- a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/components/TrustPortalSwitch.tsx @@ -14,17 +14,8 @@ import { useForm } from 'react-hook-form'; import { toast } from 'sonner'; import { z } from 'zod'; import { updateTrustPortalFrameworks } from '../actions/update-trust-portal-frameworks'; -import { - TrustPortalAdditionalDocumentsSection, - type TrustPortalDocument, -} from './TrustPortalAdditionalDocumentsSection'; -import { TrustPortalCustomLinks } from './TrustPortalCustomLinks'; -import { TrustPortalFaqBuilder } from './TrustPortalFaqBuilder'; -import { TrustPortalOverview } from './TrustPortalOverview'; -import { TrustPortalVendors } from './TrustPortalVendors'; -import { UpdateTrustFavicon } from './UpdateTrustFavicon'; -import { BrandSettings } from './BrandSettings'; import type { FaqItem } from '../types/faq'; +import { BrandSettings } from './BrandSettings'; import { GDPR, HIPAA, @@ -36,6 +27,15 @@ import { SOC2Type1, SOC2Type2, } from './logos'; +import { + TrustPortalAdditionalDocumentsSection, + type TrustPortalDocument, +} from './TrustPortalAdditionalDocumentsSection'; +import { TrustPortalCustomLinks } from './TrustPortalCustomLinks'; +import { TrustPortalFaqBuilder } from './TrustPortalFaqBuilder'; +import { TrustPortalOverview } from './TrustPortalOverview'; +import { TrustPortalVendors } from './TrustPortalVendors'; +import { UpdateTrustFavicon } from './UpdateTrustFavicon'; // Client-side form schema for framework state const trustPortalFormSchema = z.object({ @@ -100,15 +100,7 @@ type TrustCustomLink = { }; type ComplianceBadge = { - type: - | 'soc2' - | 'iso27001' - | 'iso42001' - | 'gdpr' - | 'hipaa' - | 'pci_dss' - | 'nen7510' - | 'iso9001'; + type: 'soc2' | 'iso27001' | 'iso42001' | 'gdpr' | 'hipaa' | 'pci_dss' | 'nen7510' | 'iso9001'; verified: boolean; }; @@ -334,10 +326,10 @@ export function TrustPortalSwitch({ return ( - + - Mission Frameworks + Mission Branding Vendors Links @@ -669,10 +661,7 @@ export function TrustPortalSwitch({ - + diff --git a/apps/app/src/app/(app)/[orgId]/trust/portal-settings/loading.tsx b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/loading.tsx new file mode 100644 index 000000000..834c94b27 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/portal-settings/loading.tsx @@ -0,0 +1,5 @@ +import { PageLayout } from '@trycompai/design-system'; + +export default function Loading() { + return ; +} diff --git a/apps/app/src/app/(app)/[orgId]/trust/settings/loading.tsx b/apps/app/src/app/(app)/[orgId]/trust/settings/loading.tsx new file mode 100644 index 000000000..834c94b27 --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/trust/settings/loading.tsx @@ -0,0 +1,5 @@ +import { PageLayout } from '@trycompai/design-system'; + +export default function Loading() { + return ; +} diff --git a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/loading.tsx b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/loading.tsx index fe5a41076..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/(overview)/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/(overview)/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx index 3123e106b..834c94b27 100644 --- a/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx +++ b/apps/app/src/app/(app)/[orgId]/vendors/[vendorId]/loading.tsx @@ -1,5 +1,5 @@ -import { PageHeader, PageLayout } from '@trycompai/design-system'; +import { PageLayout } from '@trycompai/design-system'; export default function Loading() { - return } />; + return ; } diff --git a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts index 3854b6c12..3d82a3789 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization-minimal.ts @@ -2,9 +2,9 @@ import { initializeOrganization } from '@/actions/organization/lib/initialize-organization'; import { authActionClientWithoutOrg } from '@/actions/safe-action'; +import { env } from '@/env.mjs'; import { createTrainingVideoEntries } from '@/lib/db/employee'; import { auth } from '@/utils/auth'; -import { env } from '@/env.mjs'; import { db } from '@db'; import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; @@ -46,6 +46,13 @@ export const createOrganizationMinimal = authActionClientWithoutOrg // Check if self-hosted const isSelfHosted = env.NEXT_PUBLIC_SELF_HOSTED === 'true'; + // Resolve framework IDs to display names (e.g. "SOC 2", "ISO 27001") + const frameworks = await db.frameworkEditorFramework.findMany({ + where: { id: { in: parsedInput.frameworkIds } }, + select: { name: true }, + }); + const frameworkNames = frameworks.map((f) => f.name).join(', '); + // Create a new organization const newOrg = await db.organization.create({ data: { @@ -68,7 +75,7 @@ export const createOrganizationMinimal = authActionClientWithoutOrg context: { create: { question: 'Which compliance frameworks do you need?', - answer: parsedInput.frameworkIds.join(', '), + answer: frameworkNames || parsedInput.frameworkIds.join(', '), tags: ['onboarding'], }, }, diff --git a/apps/app/src/app/(app)/setup/actions/create-organization.ts b/apps/app/src/app/(app)/setup/actions/create-organization.ts index ab24c1f9b..0fc8d689c 100644 --- a/apps/app/src/app/(app)/setup/actions/create-organization.ts +++ b/apps/app/src/app/(app)/setup/actions/create-organization.ts @@ -41,6 +41,13 @@ export const createOrganization = authActionClientWithoutOrg // Create a new organization directly in the database const randomSuffix = Math.floor(100000 + Math.random() * 900000).toString(); + // Resolve framework IDs to display names (e.g. "SOC 2", "ISO 27001") + const frameworks = await db.frameworkEditorFramework.findMany({ + where: { id: { in: parsedInput.frameworkIds } }, + select: { name: true }, + }); + const frameworkNames = frameworks.map((f) => f.name).join(', '); + const newOrg = await db.organization.create({ data: { name: parsedInput.organizationName, @@ -62,7 +69,7 @@ export const createOrganization = authActionClientWithoutOrg question: step.question, answer: step.key === 'frameworkIds' - ? parsedInput.frameworkIds.join(', ') + ? frameworkNames || parsedInput.frameworkIds.join(', ') : (parsedInput[step.key as keyof typeof parsedInput] as string), tags: ['onboarding'], })), diff --git a/apps/app/src/components/forms/organization/update-organization-evidence-approval.tsx b/apps/app/src/components/forms/organization/update-organization-evidence-approval.tsx new file mode 100644 index 000000000..87dcdb468 --- /dev/null +++ b/apps/app/src/components/forms/organization/update-organization-evidence-approval.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { updateOrganizationEvidenceApprovalAction } from '@/actions/organization/update-organization-evidence-approval-action'; +import { organizationEvidenceApprovalSchema } from '@/actions/schema'; +import { Form, FormControl, FormField, FormItem, FormMessage } from '@comp/ui/form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Switch } from '@trycompai/design-system'; +import { Loader2 } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import type { z } from 'zod'; + +export function UpdateOrganizationEvidenceApproval({ + evidenceApprovalEnabled, +}: { + evidenceApprovalEnabled: boolean; +}) { + const updateEvidenceApproval = useAction(updateOrganizationEvidenceApprovalAction, { + onSuccess: () => { + toast.success('Evidence approval setting updated'); + }, + onError: () => { + toast.error('Error updating evidence approval setting'); + }, + }); + + const form = useForm>({ + resolver: zodResolver(organizationEvidenceApprovalSchema), + defaultValues: { + evidenceApprovalEnabled, + }, + }); + + const onSubmit = (data: z.infer) => { + updateEvidenceApproval.execute(data); + }; + + return ( + + + + ( + + + Evidence Approval + + When enabled, evidence tasks can be submitted for review before being marked as done. + An approver can be assigned to each task who must approve the evidence before completion. + + {updateEvidenceApproval.status === 'executing' && ( + + + Saving... + + )} + + + { + field.onChange(checked); + form.handleSubmit(onSubmit)(); + }} + /> + + + + )} + /> + + + + ); +} diff --git a/apps/app/src/components/status-indicator.tsx b/apps/app/src/components/status-indicator.tsx index 90be1ed3c..3cd9dc587 100644 --- a/apps/app/src/components/status-indicator.tsx +++ b/apps/app/src/components/status-indicator.tsx @@ -22,6 +22,7 @@ export const STATUS_TYPES = [ // Task 'todo', + 'in_review', 'done', 'not_relevant', 'failed', @@ -49,6 +50,9 @@ export const STATUS_COLORS: Record = { pending: 'bg-yellow-500 dark:bg-yellow-400', in_progress: 'bg-yellow-500 dark:bg-yellow-400', + // In Review - Orange + in_review: 'bg-orange-500 dark:bg-orange-400', + // Warning/Error - Red needs_review: 'bg-red-600 dark:bg-red-400', not_started: 'bg-red-600 dark:bg-red-400', @@ -67,6 +71,8 @@ export const getStatusTranslation = (status: StatusType) => { return 'Todo'; case 'in_progress': return 'In Progress'; + case 'in_review': + return 'In Review'; case 'done': return 'Done'; case 'published': diff --git a/apps/app/src/trigger/tasks/auditor/generate-auditor-content.ts b/apps/app/src/trigger/tasks/auditor/generate-auditor-content.ts index 13372cf55..d35fdc365 100644 --- a/apps/app/src/trigger/tasks/auditor/generate-auditor-content.ts +++ b/apps/app/src/trigger/tasks/auditor/generate-auditor-content.ts @@ -1,5 +1,5 @@ import { getOrganizationContext } from '@/trigger/tasks/onboarding/onboard-organization-helpers'; -import { groq } from '@ai-sdk/groq'; +import { openai } from '@ai-sdk/openai'; import { db } from '@db'; import { logger, metadata, schemaTask } from '@trigger.dev/sdk'; import { generateText } from 'ai'; @@ -89,24 +89,54 @@ RULES: - No bullet points. ${TONE_RULES}`, - 'critical-vendors': `List vendors in this EXACT format, one per line: + 'critical-vendors': `Using the provided vendor/software list, narrow it down to ONLY the critical vendors from a SOC 2 perspective for the audit report. +A critical vendor is one that: +- Hosts or processes customer data (cloud infrastructure providers like AWS, GCP, Azure) +- Provides core identity / authentication services (e.g. Okta, Google Workspace, Microsoft 365 — but ONLY if used as the primary identity provider) +- Is essential to the company's production system or service delivery +- Handles sensitive data (e.g. payment processors IF the company processes payments as a core service) + +DO NOT INCLUDE vendors that are: +- Internal productivity / collaboration tools (e.g. Notion, Slack, Teams, Jira, Confluence, Asana) +- General business tools (e.g. Stripe, HubSpot, Intercom, Zendesk) +- HR / payroll tools (e.g. Rippling, Gusto, BambooHR) +- Marketing or analytics tools +- Version control or CI/CD tools (e.g. GitHub, GitLab) unless they host production infrastructure +- Security monitoring tools (e.g. Vanta, Drata, CrowdStrike) + +Typically a SOC 2 report includes only 3-6 critical vendors. Be very selective. + +FORMAT — one vendor per line: [Vendor Name] – [Type: SaaS/IaaS/PaaS] – ([Brief description of service]) EXAMPLE: -Zoom – SaaS – (Video conferencing / collaboration) AWS – IaaS / PaaS – (Cloud infrastructure and hosting) -Microsoft 365 – SaaS – (Office productivity and identity) +Google Workspace – SaaS – (Primary identity provider and email) +Datadog – SaaS – (Production monitoring and observability) RULES: - Do NOT include the section title. - Each vendor on its own line. - Follow the exact format: Name – Type – (Description) -- Only include vendors explicitly mentioned in sources. +- Only include vendors from the provided sources — do not add vendors not mentioned. +- Aim for 3-6 vendors maximum. ${TONE_RULES}`, - 'subservice-organizations': `List subservice organizations in this EXACT format: + 'subservice-organizations': `Identify the subservice organisations from a SOC 2 perspective. + +A subservice organisation is an external service provider whose infrastructure or platform the company DIRECTLY RELIES ON to deliver its own services to customers. In SOC 2 terms, these are typically the main cloud infrastructure / hosting providers (IaaS/PaaS) — e.g. AWS, Google Cloud Platform, Microsoft Azure. + +DO NOT INCLUDE: +- SaaS tools the company merely uses internally (e.g. Slack, Notion, Jira, GitHub, Stripe, HubSpot) +- Communication or collaboration platforms (e.g. Teams, Zoom) +- HR, payroll, or admin tools +- Security or monitoring tools +- Any tool that is NOT the primary infrastructure hosting the company's production system + +Typically there is only 1 (sometimes 2) subservice organisations. Be very selective. +FORMAT: Subservice organisations: [Name1], [Name2], ... If only one: "Subservice organisations: [Name]" @@ -118,7 +148,7 @@ RULES: - Do NOT include the section title. - Use "Subservice organisations:" prefix. - Just list the names, comma-separated if multiple. -- Only include organizations explicitly mentioned as subservice providers in sources. +- Look for where the company hosts its applications and data — that is the subservice organisation. ${TONE_RULES}`, }; @@ -145,7 +175,7 @@ async function scrapeWebsite(website: string): Promise { urls: [website], prompt: 'Extract all text content from this website, including company information, services, mission, vision, and any other relevant business information. Return the content as plain text or markdown.', - limit: 10 + limit: 10, }), }); @@ -223,7 +253,7 @@ async function generateSectionContent( contextHubText: string, ): Promise { const { text } = await generateText({ - model: groq('openai/gpt-oss-120b'), + model: openai('gpt-5.2'), system: `You are an expert at extracting and organizing company information for audit purposes. CRITICAL RULES: @@ -326,10 +356,16 @@ export const generateAuditorContentTask = schemaTask({ }; } - // Build context from organization data (excluding auditor sections to avoid circular reference) + // Build context from organization data, excluding: + // 1. Auditor sections (to avoid circular reference) + // 2. Framework selection (contains raw IDs like "frk_xxx" and isn't relevant to auditor content) const auditorQuestions = new Set(Object.values(SECTION_QUESTIONS)); + const excludedQuestions = new Set([ + ...auditorQuestions, + 'Which compliance frameworks do you need?', + ]); const contextHubText = questionsAndAnswers - .filter((qa) => !auditorQuestions.has(qa.question)) + .filter((qa) => !excludedQuestions.has(qa.question)) .map((qa) => `Q: ${qa.question}\nA: ${qa.answer}`) .join('\n\n'); diff --git a/apps/portal/package.json b/apps/portal/package.json index f95396b2c..2451904cc 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -29,7 +29,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-email": "^4.0.15", - "react-hook-form": "^7.68.0", + "react-hook-form": "^7.71.1", "sonner": "^2.0.5", "zod": "3" }, diff --git a/bun.lock b/bun.lock index 8986f344c..f8ccf00e0 100644 --- a/bun.lock +++ b/bun.lock @@ -10,11 +10,11 @@ "@types/react-syntax-highlighter": "^15.5.13", "@upstash/vector": "^1.2.2", "better-auth": "1.4.5", - "cheerio": "^1.1.2", + "cheerio": "^1.2.0", "react-syntax-highlighter": "^15.6.6", "unpdf": "^1.4.0", "xlsx": "^0.18.5", - "zod": "^4.2.1", + "zod": "^4.3.6", }, "devDependencies": { "@azure/core-http": "^3.0.5", @@ -24,7 +24,7 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@hookform/resolvers": "^5.2.2", - "@number-flow/react": "^0.5.10", + "@number-flow/react": "^0.5.11", "@prisma/adapter-pg": "6.10.1", "@react-email/components": "^0.0.41", "@react-email/render": "^1.4.0", @@ -34,12 +34,12 @@ "@semantic-release/github": "^11.0.6", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.1.0", - "@types/bun": "^1.3.4", + "@types/bun": "^1.3.8", "@types/d3": "^7.4.3", - "@types/lodash": "^4.17.21", - "@types/react": "^19.2.7", + "@types/lodash": "^4.17.23", + "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "ai": "^5.0.115", + "ai": "^5.0.129", "concurrently": "^9.2.1", "d3": "^7.9.0", "date-fns": "^4.1.0", @@ -48,22 +48,22 @@ "gitmoji": "^1.1.1", "gray-matter": "^4.0.3", "husky": "^9.1.7", - "prettier": "^3.7.4", + "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.6.14", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-email": "^4.3.2", - "react-hook-form": "^7.68.0", + "react-hook-form": "^7.71.1", "semantic-release": "^24.2.9", "semantic-release-discord": "^1.2.0", "semantic-release-discord-notifier": "^1.1.1", "sharp": "^0.34.5", "syncpack": "^13.0.4", "tsup": "^8.5.1", - "turbo": "^2.6.3", + "turbo": "^2.8.3", "typescript": "^5.9.3", - "use-debounce": "^10.0.6", + "use-debounce": "^10.1.0", }, }, "apps/api": { @@ -339,7 +339,7 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-email": "^4.0.15", - "react-hook-form": "^7.68.0", + "react-hook-form": "^7.71.1", "sonner": "^2.0.5", "zod": "3", }, @@ -605,7 +605,7 @@ "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DDNZSZn6OuExVBJBAWdk3VeyQPH+pYwSykixePhzll9EnT3aakapMYr5gjw3wMl+eZ0tLplythHL1TfIehUZ0g=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-fMzhC9artgY2s2GgXEWB+cECRJEHHoFJKzDpzsuneguNQ656vydPHhvDdoMjbWW+UtLc4nGf3VwlqG0t4FeQ/w=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.51", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5VMHdZTP4th00hthmh98jP+BZmxiTRMB9R2qh/AuF6OkQeiJikqxZg3hrWDfYrCmQ12wDjy6CbIypnhlwZiYrg=="], @@ -621,9 +621,9 @@ "@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zwzcnk08R2J3mZcQPn4Ifl4wYGrvANR7jsBB0hCTUSbb+Rx3ybpikSWiGuXQXxdiRc1I5MWXgj70m+bZaLPvHw=="], - "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "@ai-sdk/react": ["@ai-sdk/react@2.0.118", "", { "dependencies": { "@ai-sdk/provider-utils": "3.0.19", "ai": "5.0.116", "swr": "^2.2.5", "throttleit": "2.1.0" }, "peerDependencies": { "react": "^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", "zod": "^3.25.76 || ^4.1.8" }, "optionalPeers": ["zod"] }, "sha512-K/5VVEGTIu9SWrdQ0s/11OldFU8IjprDzeE6TaC2fOcQWhG7dGVGl9H8Z32QBHzdfJyMhFUxEyFKSOgA2j9+VQ=="], @@ -1423,7 +1423,7 @@ "@novu/react": ["@novu/react@3.11.0", "", { "dependencies": { "@novu/js": "3.11.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-0", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-0" }, "optionalPeers": ["react-dom"] }, "sha512-VoDh2DNjyP8JuzsLUXc5md9UEWF1MTQamMWjYeEv8WxUtDuYBkYGYjs7x/80kZXZ0wBJDuPsstypYUzjhsOwnw=="], - "@number-flow/react": ["@number-flow/react@0.5.10", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.5.8" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-a8Wh5eNITn7Km4xbddAH7QH8eNmnduR6k34ER1hkHSGO4H2yU1DDnuAWLQM99vciGInFODemSc0tdxrXkJEpbA=="], + "@number-flow/react": ["@number-flow/react@0.5.11", "", { "dependencies": { "esm-env": "^1.1.4", "number-flow": "0.5.9" }, "peerDependencies": { "react": "^18 || ^19", "react-dom": "^18 || ^19" } }, "sha512-xo7QFAfQlKiIRbLjoIjZNXsNQmY+QxkyxkNWy+BCP9u1/SkqyqQ/1pYWa7d175V2r4OdmvCvlAnACSYOP1pTaw=="], "@nuxt/opencollective": ["@nuxt/opencollective@0.4.1", "", { "dependencies": { "consola": "^3.2.3" }, "bin": { "opencollective": "bin/opencollective.js" } }, "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ=="], @@ -2207,7 +2207,7 @@ "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.8", "", { "dependencies": { "bun-types": "1.3.8" } }, "sha512-3LvWJ2q5GerAXYxO2mffLTqOzEu5qnhEAlh48Vnu8WQfnmSwbgagjGZV6BoHKJztENYEDn6QmVd949W4uESRJA=="], "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], @@ -2337,7 +2337,7 @@ "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], - "@types/lodash": ["@types/lodash@4.17.21", "", {}, "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ=="], + "@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], @@ -2373,7 +2373,7 @@ "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + "@types/react": ["@types/react@19.2.13", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -2613,7 +2613,7 @@ "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], - "ai": ["ai@5.0.116", "", { "dependencies": { "@ai-sdk/gateway": "2.0.23", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ=="], + "ai": ["ai@5.0.129", "", { "dependencies": { "@ai-sdk/gateway": "2.0.35", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-IARdFetNTedDfqpByNMm9p0oHj7JS+SpOrbgLdQdyCiDe70Xk07wnKP4Lub1ckCrxkhAxY3yxOHllGEjbpXgpQ=="], "ai-elements": ["ai-elements@1.6.3", "", { "bin": { "elements": "index.js" } }, "sha512-M0A5NrUqCMV2w9hJV+kOuFi+XKo1BjlawheaqfrG+jovWsyXyCalOOH2dM4w/BwjABwje5yZX5MnMIUwnFmqzg=="], @@ -2819,7 +2819,7 @@ "bufferutil": ["bufferutil@4.1.0", "", { "dependencies": { "node-gyp-build": "^4.3.0" } }, "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "bun-types": ["bun-types@1.3.8", "", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], @@ -2881,7 +2881,7 @@ "check-error": ["check-error@2.1.1", "", {}, "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw=="], - "cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], + "cheerio": ["cheerio@1.2.0", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.1.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.19.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg=="], "cheerio-select": ["cheerio-select@2.1.0", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="], @@ -3751,7 +3751,7 @@ "html2canvas": ["html2canvas@1.4.1", "", { "dependencies": { "css-line-break": "^2.1.0", "text-segmentation": "^1.0.3" } }, "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA=="], - "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + "htmlparser2": ["htmlparser2@10.1.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "entities": "^7.0.1" } }, "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -4513,7 +4513,7 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "number-flow": ["number-flow@0.5.8", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-FPr1DumWyGi5Nucoug14bC6xEz70A1TnhgSHhKyfqjgji2SOTz+iLJxKtv37N5JyJbteGYCm6NQ9p1O4KZ7iiA=="], + "number-flow": ["number-flow@0.5.9", "", { "dependencies": { "esm-env": "^1.1.4" } }, "sha512-o3102c/4qRd6eV4n+rw6B/UP8+FosbhIxj4uA6GsjhryrGZRVtCtKIKEeBiOwUV52cUGJneeu0treELcV7U/lw=="], "number-is-nan": ["number-is-nan@1.0.1", "", {}, "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ=="], @@ -4765,7 +4765,7 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], "prettier-linter-helpers": ["prettier-linter-helpers@1.0.0", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w=="], @@ -4897,7 +4897,7 @@ "react-email": ["react-email@4.3.2", "", { "dependencies": { "@babel/parser": "^7.27.0", "@babel/traverse": "^7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "debounce": "^2.0.0", "esbuild": "^0.25.0", "glob": "^11.0.0", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", "nypm": "0.6.0", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { "email": "dist/index.js" } }, "sha512-WaZcnv9OAIRULY236zDRdk+8r511ooJGH5UOb7FnVsV33hGPI+l5aIZ6drVjXi4QrlLTmLm8PsYvmXRSv31MPA=="], - "react-hook-form": ["react-hook-form@7.69.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-yt6ZGME9f4F6WHwevrvpAjh42HMvocuSnSIHUGycBqXIJdhqGSPQzTpGF+1NLREk/58IdPxEMfPcFCjlMhclGw=="], + "react-hook-form": ["react-hook-form@7.71.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w=="], "react-hotkeys-hook": ["react-hotkeys-hook@5.2.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xbKh6zJxd/vJHT4Bw4+0pBD662Fk20V+VFhLqciCg+manTVO4qlqRqiwFOYelfHN9dBvWj9vxaPkSS26ZSIJGg=="], @@ -5525,19 +5525,19 @@ "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], - "turbo": ["turbo@2.7.1", "", { "optionalDependencies": { "turbo-darwin-64": "2.7.1", "turbo-darwin-arm64": "2.7.1", "turbo-linux-64": "2.7.1", "turbo-linux-arm64": "2.7.1", "turbo-windows-64": "2.7.1", "turbo-windows-arm64": "2.7.1" }, "bin": { "turbo": "bin/turbo" } }, "sha512-zAj9jGc7VDvuAo/5Jbos4QTtWz9uUpkMhMKGyTjDJkx//hdL2bM31qQoJSAbU+7JyK5vb0LPzpwf6DUt3zayqg=="], + "turbo": ["turbo@2.8.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.3", "turbo-darwin-arm64": "2.8.3", "turbo-linux-64": "2.8.3", "turbo-linux-arm64": "2.8.3", "turbo-windows-64": "2.8.3", "turbo-windows-arm64": "2.8.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-8Osxz5Tu/Dw2kb31EAY+nhq/YZ3wzmQSmYa1nIArqxgCAldxv9TPlrAiaBUDVnKA4aiPn0OFBD1ACcpc5VFOAQ=="], - "turbo-darwin-64": ["turbo-darwin-64@2.7.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-EaA7UfYujbY9/Ku0WqPpvfctxm91h9LF7zo8vjielz+omfAPB54Si+ADmUoBczBDC6RoLgbURC3GmUW2alnjJg=="], + "turbo-darwin-64": ["turbo-darwin-64@2.8.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kXRLfcygLOeNcP6JquqRLmGB/ATjjfehiojL2dJkL7GFm3SPSXbq7oNj8UbD8XriYQ5hPaSuz59iF1ijPHkTw=="], - "turbo-darwin-arm64": ["turbo-darwin-arm64@2.7.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/pWGSygtBugd7sKQOeMm+jKY3qN1vyB0RiHBM6bN/6qUOo2VHo8IQwBTIaSgINN4Ue6fzEU+WfePNvonSU9yXw=="], + "turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-xF7uCeC0UY0Hrv/tqax0BMbFlVP1J/aRyeGQPZT4NjvIPj8gSPDgFhfkfz06DhUwDg5NgMo04uiSkAWE8WB/QQ=="], - "turbo-linux-64": ["turbo-linux-64@2.7.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Y5H11mdhASw/dJuRFyGtTCDFX5/MPT73EKsVEiHbw5MkFc77lx3nMc5L/Q7bKEhef/vYJAsAb61QuHsB6qdP8Q=="], + "turbo-linux-64": ["turbo-linux-64@2.8.3", "", { "os": "linux", "cpu": "x64" }, "sha512-vxMDXwaOjweW/4etY7BxrXCSkvtwh0PbwVafyfT1Ww659SedUxd5rM3V2ZCmbwG8NiCfY7d6VtxyHx3Wh1GoZA=="], - "turbo-linux-arm64": ["turbo-linux-arm64@2.7.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-L/r77jD7cqIEXoyu2LGBUrTY5GJSi/XcGLsQ2nZ/fefk6x3MpljTvwsXUVG1BUkiBPc4zaKRj6yGyWMo5MbLxQ=="], + "turbo-linux-arm64": ["turbo-linux-arm64@2.8.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-mQX7uYBZFkuPLLlKaNe9IjR1JIef4YvY8f21xFocvttXvdPebnq3PK1Zjzl9A1zun2BEuWNUwQIL8lgvN9Pm3Q=="], - "turbo-windows-64": ["turbo-windows-64@2.7.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rkeuviXZ/1F7lCare7TNKvYtT/SH9dZR55FAMrxrFRh88b+ZKwlXEBfq5/1OctEzRUo/VLIm+s5LJMOEy+QshA=="], + "turbo-windows-64": ["turbo-windows-64@2.8.3", "", { "os": "win32", "cpu": "x64" }, "sha512-YLGEfppGxZj3VWcNOVa08h6ISsVKiG85aCAWosOKNUjb6yErWEuydv6/qImRJUI+tDLvDvW7BxopAkujRnWCrw=="], - "turbo-windows-arm64": ["turbo-windows-arm64@2.7.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-1rZk9htm3+iP/rWCf/h4/DFQey9sMs2TJPC4T5QQfwqAdMWsphgrxBuFqHdxczlbBCgbWNhVw0CH2bTxe1/GFg=="], + "turbo-windows-arm64": ["turbo-windows-arm64@2.8.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-afTUGKBRmOJU1smQSBnFGcbq0iabAPwh1uXu2BVk7BREg30/1gMnJh9DFEQTah+UD3n3ru8V55J83RQNFfqoyw=="], "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], @@ -5589,7 +5589,7 @@ "underscore": ["underscore@1.13.7", "", {}, "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="], - "undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -5645,7 +5645,7 @@ "use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="], - "use-debounce": ["use-debounce@10.0.6", "", { "peerDependencies": { "react": "*" } }, "sha512-C5OtPyhAZgVoteO9heXMTdW7v/IbFI+8bSVKYCJrSmiWWCLsbUxiBSp4t9v0hNBTGY97bT72ydDIDyGSFWfwXg=="], + "use-debounce": ["use-debounce@10.1.0", "", { "peerDependencies": { "react": "*" } }, "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg=="], "use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="], @@ -5823,7 +5823,7 @@ "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], - "zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod-error": ["zod-error@1.5.0", "", { "dependencies": { "zod": "^3.20.2" } }, "sha512-zzopKZ/skI9iXpqCEPj+iLCKl9b88E43ehcU+sbRoHuwGd9F1IDVGQ70TyO6kmfiRL1g4IXkjsXK+g1gLYl4WQ=="], @@ -5837,7 +5837,69 @@ "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], - "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/azure/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/azure/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/cerebras/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/cerebras/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/deepseek/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + + "@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/groq/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/perplexity/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/perplexity/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/react/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/react/ai": ["ai@5.0.116", "", { "dependencies": { "@ai-sdk/gateway": "2.0.23", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ=="], + + "@ai-sdk/rsc/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/rsc/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/rsc/ai": ["ai@5.0.116", "", { "dependencies": { "@ai-sdk/gateway": "2.0.23", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ=="], + + "@ai-sdk/togetherai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/togetherai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + + "@ai-sdk/xai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/xai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], "@angular-devkit/core/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], @@ -5885,8 +5947,14 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/core/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "@browserbasehq/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@browserbasehq/stagehand/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@browserbasehq/stagehand/ai": ["ai@5.0.116", "", { "dependencies": { "@ai-sdk/gateway": "2.0.23", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-+2hYJ80/NcDWuv9K2/MLP3cTCFgwWHmHlS1tOpFUKKcmLbErAAlE/S2knsKboc3PNAu8pQkDr2N3K/Vle7ENgQ=="], + "@browserbasehq/stagehand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "@browserbasehq/stagehand/puppeteer-core": ["puppeteer-core@22.15.0", "", { "dependencies": { "@puppeteer/browsers": "2.3.0", "chromium-bidi": "0.6.3", "debug": "^4.3.6", "devtools-protocol": "0.0.1312386", "ws": "^8.18.0" } }, "sha512-cHArnywCiAAVXa3t4GGL2vttNxh7GqXtIYGym99egkNJ3oG//wL9LkvO4WE8W1TJe95t1F1ocu9X4xWaGsOKOA=="], @@ -6005,6 +6073,8 @@ "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "@modelcontextprotocol/sdk/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "@nangohq/types/type-fest": ["type-fest@4.32.0", "", {}, "sha512-rfgpoi08xagF3JSdtJlCwMq9DGNDE0IMh3Mkpc1wUypg9vPi786AiqeBBKcqvIkq42azsBM85N490fyZjeUftw=="], "@nestjs/cli/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], @@ -6029,6 +6099,8 @@ "@noble/curves/@noble/hashes": ["@noble/hashes@1.8.0", "", {}, "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="], + "@novu/api/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "@novu/js/socket.io-client": ["socket.io-client@4.7.2", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@15.0.2", "", { "dependencies": { "@octokit/openapi-types": "^26.0.0" } }, "sha512-rR+5VRjhYSer7sC51krfCctQhVTmjyUMAaShfPB8mscVa8tSoLyon3coxQmXu0ahJoLVWl8dSGD/3OGZlFV44Q=="], @@ -6123,6 +6195,8 @@ "@react-email/components/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], + "@react-email/render/prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "@react-three/fiber/scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], "@react-three/postprocessing/maath": ["maath@0.6.0", "", { "peerDependencies": { "@types/three": ">=0.144.0", "three": ">=0.144.0" } }, "sha512-dSb2xQuP7vDnaYqfoKzlApeRcR2xtN8/f7WV/TMAkBC8552TwTLtOO0JTcSygkYMjNDPoo6V01jTw/aPi4JrMw=="], @@ -6209,6 +6283,10 @@ "@ts-morph/common/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="], + "@types/cheerio/cheerio": ["cheerio@1.1.2", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.2.2", "encoding-sniffer": "^0.2.1", "htmlparser2": "^10.0.0", "parse5": "^7.3.0", "parse5-htmlparser2-tree-adapter": "^7.1.0", "parse5-parser-stream": "^7.1.2", "undici": "^7.12.0", "whatwg-mimetype": "^4.0.0" } }, "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg=="], + + "@types/react-syntax-highlighter/@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@uploadthing/shared/effect": ["effect@3.17.7", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-dpt0ONUn3zzAuul6k4nC/coTTw27AL5nhkORXgTi6NfMPzqWYa1M05oKmOMTxpVSTKepqXVcW9vIwkuaaqx9zA=="], @@ -6217,8 +6295,12 @@ "@vercel/sandbox/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "@vercel/sandbox/undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], + "@vercel/sandbox/zod": ["zod@3.24.4", "", {}, "sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg=="], + "@vercel/sdk/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "@xyflow/react/zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -6235,6 +6317,8 @@ "babel-jest/slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="], + "better-auth/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], @@ -6421,7 +6505,7 @@ "html-to-text/htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="], - "htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "htmlparser2/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "humanize-ms/ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -6883,6 +6967,10 @@ "nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], + "ollama-ai-provider-v2/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "ollama-ai-provider-v2/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -7125,6 +7213,14 @@ "zod-error/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@ai-sdk/react/@ai-sdk/provider-utils/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/react/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], + + "@ai-sdk/react/ai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + + "@ai-sdk/rsc/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], + "@angular-devkit/core/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "@angular-devkit/schematics/ora/cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], @@ -7151,6 +7247,10 @@ "@browserbasehq/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + "@browserbasehq/stagehand/ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="], + + "@browserbasehq/stagehand/ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "@browserbasehq/stagehand/puppeteer-core/@puppeteer/browsers": ["@puppeteer/browsers@2.3.0", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.3", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-ioXoq9gPxkss4MYhD+SFaU9p1IHFUX0ILAWFPyjGaBdjLsYAlZw6j1iLA0N/m12uVHLFDfSYNF7EQccjinIMDA=="], "@browserbasehq/stagehand/puppeteer-core/chromium-bidi": ["chromium-bidi@0.6.3", "", { "dependencies": { "mitt": "3.0.1", "urlpattern-polyfill": "10.0.0", "zod": "3.23.8" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-qXlsCmpCZJAnoTYI83Iu6EdYQpMYdVkCfq08KDh2pmlVqK5t5IA9mGs4/LwCwp4fqisSOMXZxP3HIh8w8aRn0A=="], @@ -7321,6 +7421,8 @@ "@radix-ui/react-dropdown-menu/@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@react-email/components/@react-email/render/prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "@semantic-release/git/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@semantic-release/git/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -7411,6 +7513,10 @@ "@trycompai/email/resend/@react-email/render": ["@react-email/render@1.1.2", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3", "react-promise-suspense": "^0.3.4" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-RnRehYN3v9gVlNMehHPHhyp2RQo7+pSkHDtXPvg3s0GbzM9SQMW4Qrf8GRNvtpLC4gsI+Wt0VatNRUFqjvevbw=="], + "@types/cheerio/cheerio/htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], + + "@types/cheerio/cheerio/undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], + "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], @@ -7751,6 +7857,10 @@ "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@ai-sdk/react/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + + "@ai-sdk/rsc/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@angular-devkit/schematics/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "@angular-devkit/schematics/ora/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -7761,6 +7871,8 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@browserbasehq/stagehand/ai/@ai-sdk/gateway/@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="], + "@browserbasehq/stagehand/puppeteer-core/chromium-bidi/zod": ["zod@3.23.8", "", {}, "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g=="], "@calcom/atoms/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], @@ -7775,6 +7887,8 @@ "@comp/app/@prisma/instrumentation/@opentelemetry/instrumentation/require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "@comp/app/resend/@react-email/render/prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + "@dub/embed-react/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.20.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g=="], "@dub/embed-react/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.20.2", "", { "os": "android", "cpu": "arm" }, "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w=="], @@ -7937,6 +8051,10 @@ "@trycompai/email/next/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "@trycompai/email/resend/@react-email/render/prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + + "@types/cheerio/cheerio/htmlparser2/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "cli-highlight/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "env-ci/execa/npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -8015,6 +8133,8 @@ "semantic-release-discord-notifier/semantic-release/@semantic-release/github/@octokit/plugin-paginate-rest": ["@octokit/plugin-paginate-rest@14.0.0", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="], + "semantic-release-discord-notifier/semantic-release/@semantic-release/github/undici": ["undici@7.16.0", "", {}, "sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g=="], + "semantic-release-discord-notifier/semantic-release/@semantic-release/npm/npm": ["npm@11.7.0", "", { "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", "@npmcli/arborist": "^9.1.9", "@npmcli/config": "^10.4.5", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", "@npmcli/package-json": "^7.0.4", "@npmcli/promise-spawn": "^9.0.1", "@npmcli/redact": "^4.0.0", "@npmcli/run-script": "^10.0.3", "@sigstore/tuf": "^4.0.0", "abbrev": "^4.0.0", "archy": "~1.0.0", "cacache": "^20.0.3", "chalk": "^5.6.2", "ci-info": "^4.3.1", "cli-columns": "^4.0.0", "fastest-levenshtein": "^1.0.16", "fs-minipass": "^3.0.3", "glob": "^13.0.0", "graceful-fs": "^4.2.11", "hosted-git-info": "^9.0.2", "ini": "^6.0.0", "init-package-json": "^8.2.4", "is-cidr": "^6.0.1", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", "libnpmdiff": "^8.0.12", "libnpmexec": "^10.1.11", "libnpmfund": "^7.0.12", "libnpmorg": "^8.0.1", "libnpmpack": "^9.0.12", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", "libnpmversion": "^8.0.3", "make-fetch-happen": "^15.0.3", "minimatch": "^10.1.1", "minipass": "^7.1.1", "minipass-pipeline": "^1.2.4", "ms": "^2.1.2", "node-gyp": "^12.1.0", "nopt": "^9.0.0", "npm-audit-report": "^7.0.0", "npm-install-checks": "^8.0.0", "npm-package-arg": "^13.0.2", "npm-pick-manifest": "^11.0.3", "npm-profile": "^12.0.1", "npm-registry-fetch": "^19.1.1", "npm-user-validate": "^4.0.0", "p-map": "^7.0.4", "pacote": "^21.0.4", "parse-conflict-json": "^5.0.1", "proc-log": "^6.1.0", "qrcode-terminal": "^0.12.0", "read": "^5.0.1", "semver": "^7.7.3", "spdx-expression-parse": "^4.0.0", "ssri": "^13.0.0", "supports-color": "^10.2.2", "tar": "^7.5.2", "text-table": "~0.2.0", "tiny-relative-date": "^2.0.2", "treeverse": "^3.0.0", "validate-npm-package-name": "^7.0.0", "which": "^6.0.0" }, "bin": { "npm": "bin/npm-cli.js", "npx": "bin/npx-cli.js" } }, "sha512-wiCZpv/41bIobCoJ31NStIWKfAxxYyD1iYnWCtiyns8s5v3+l8y0HCP/sScuH6B5+GhIfda4HQKiqeGZwJWhFw=="], "semantic-release-discord-notifier/semantic-release/@semantic-release/npm/read-pkg": ["read-pkg@10.0.0", "", { "dependencies": { "@types/normalize-package-data": "^2.4.4", "normalize-package-data": "^8.0.0", "parse-json": "^8.3.0", "type-fest": "^5.2.0", "unicorn-magic": "^0.3.0" } }, "sha512-A70UlgfNdKI5NSvTTfHzLQj7NJRpJ4mT5tGafkllJ4wh71oYuGm/pzphHcmW4s35iox56KSK721AihodoXSc/A=="], diff --git a/package.json b/package.json index a38d973f9..5278b0944 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", "@hookform/resolvers": "^5.2.2", - "@number-flow/react": "^0.5.10", + "@number-flow/react": "^0.5.11", "@prisma/adapter-pg": "6.10.1", "@react-email/components": "^0.0.41", "@react-email/render": "^1.4.0", @@ -19,12 +19,12 @@ "@semantic-release/github": "^11.0.6", "@semantic-release/npm": "^12.0.2", "@semantic-release/release-notes-generator": "^14.1.0", - "@types/bun": "^1.3.4", + "@types/bun": "^1.3.8", "@types/d3": "^7.4.3", - "@types/lodash": "^4.17.21", - "@types/react": "^19.2.7", + "@types/lodash": "^4.17.23", + "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", - "ai": "^5.0.115", + "ai": "^5.0.129", "concurrently": "^9.2.1", "d3": "^7.9.0", "date-fns": "^4.1.0", @@ -33,22 +33,22 @@ "gitmoji": "^1.1.1", "gray-matter": "^4.0.3", "husky": "^9.1.7", - "prettier": "^3.7.4", + "prettier": "^3.8.1", "prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-tailwindcss": "^0.6.14", "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-email": "^4.3.2", - "react-hook-form": "^7.68.0", + "react-hook-form": "^7.71.1", "semantic-release": "^24.2.9", "semantic-release-discord": "^1.2.0", "semantic-release-discord-notifier": "^1.1.1", "sharp": "^0.34.5", "syncpack": "^13.0.4", "tsup": "^8.5.1", - "turbo": "^2.6.3", + "turbo": "^2.8.3", "typescript": "^5.9.3", - "use-debounce": "^10.0.6" + "use-debounce": "^10.1.0" }, "engines": { "node": ">=18" @@ -93,10 +93,10 @@ "@types/react-syntax-highlighter": "^15.5.13", "@upstash/vector": "^1.2.2", "better-auth": "1.4.5", - "cheerio": "^1.1.2", + "cheerio": "^1.2.0", "react-syntax-highlighter": "^15.6.6", "unpdf": "^1.4.0", "xlsx": "^0.18.5", - "zod": "^4.2.1" + "zod": "^4.3.6" } } diff --git a/packages/db/prisma/migrations/20260206173918_add_evidence_approval/migration.sql b/packages/db/prisma/migrations/20260206173918_add_evidence_approval/migration.sql new file mode 100644 index 000000000..0f07da14e --- /dev/null +++ b/packages/db/prisma/migrations/20260206173918_add_evidence_approval/migration.sql @@ -0,0 +1,12 @@ +-- AlterEnum +ALTER TYPE "TaskStatus" ADD VALUE 'in_review'; + +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "evidenceApprovalEnabled" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "approvedAt" TIMESTAMP(3), +ADD COLUMN "approverId" TEXT; + +-- AddForeignKey +ALTER TABLE "Task" ADD CONSTRAINT "Task_approverId_fkey" FOREIGN KEY ("approverId") REFERENCES "Member"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/packages/db/prisma/migrations/20260206184014_add_task_previous_status/migration.sql b/packages/db/prisma/migrations/20260206184014_add_task_previous_status/migration.sql new file mode 100644 index 000000000..c87beac3c --- /dev/null +++ b/packages/db/prisma/migrations/20260206184014_add_task_previous_status/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Task" ADD COLUMN "previousStatus" "TaskStatus"; diff --git a/packages/db/prisma/schema/auth.prisma b/packages/db/prisma/schema/auth.prisma index 9b7cec21c..64eca15af 100644 --- a/packages/db/prisma/schema/auth.prisma +++ b/packages/db/prisma/schema/auth.prisma @@ -117,6 +117,7 @@ model Member { assignedTaskItems TaskItem[] @relation("TaskItemAssignee") createdFindings Finding[] @relation("FindingCreatedBy") publishedPolicyVersions PolicyVersion[] @relation("PolicyVersionPublisher") + approvedTasks Task[] @relation("TaskApprover") } model Invitation { diff --git a/packages/db/prisma/schema/organization.prisma b/packages/db/prisma/schema/organization.prisma index d64efbd2a..e01e7f2ed 100644 --- a/packages/db/prisma/schema/organization.prisma +++ b/packages/db/prisma/schema/organization.prisma @@ -9,7 +9,8 @@ model Organization { website String? onboardingCompleted Boolean @default(false) hasAccess Boolean @default(false) - advancedModeEnabled Boolean @default(false) + advancedModeEnabled Boolean @default(false) + evidenceApprovalEnabled Boolean @default(false) // FleetDM fleetDmLabelId Int? diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma index 457b6eb91..59f9c5b86 100644 --- a/packages/db/prisma/schema/task.prisma +++ b/packages/db/prisma/schema/task.prisma @@ -31,11 +31,18 @@ model Task { EvidenceAutomationRun EvidenceAutomationRun[] integrationCheckRuns IntegrationCheckRun[] findings Finding[] + + // Evidence approval + approverId String? + approver Member? @relation("TaskApprover", fields: [approverId], references: [id]) + approvedAt DateTime? + previousStatus TaskStatus? } enum TaskStatus { todo in_progress + in_review done not_relevant failed diff --git a/packages/docs/openapi.json b/packages/docs/openapi.json index b646c0a3a..490dfe89f 100644 --- a/packages/docs/openapi.json +++ b/packages/docs/openapi.json @@ -7226,6 +7226,71 @@ ] } }, + "/v1/tasks/bulk/submit-for-review": { + "post": { + "description": "Submit multiple tasks for review with a single approver", + "operationId": "TasksController_bulkSubmitForReview_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "taskIds": { + "type": "array", + "items": { + "type": "string" + }, + "example": [ + "tsk_abc123", + "tsk_def456" + ] + }, + "approverId": { + "type": "string", + "example": "mem_abc123", + "description": "Member ID of the approver" + } + }, + "required": [ + "taskIds", + "approverId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Tasks submitted for review" + }, + "400": { + "description": "Invalid request" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Bulk submit tasks for review", + "tags": [ + "Tasks" + ] + } + }, "/v1/tasks/{taskId}": { "get": { "description": "Retrieve a specific task by its ID", @@ -7298,7 +7363,7 @@ ] }, "patch": { - "description": "Update an existing task (status, assignee, frequency, department, reviewDate)", + "description": "Update an existing task (status, assignee, approver, frequency, department, reviewDate)", "operationId": "TasksController_updateTask_v1", "parameters": [ { @@ -7346,6 +7411,12 @@ "example": "mem_abc123", "description": "Assignee member ID, or null to unassign" }, + "approverId": { + "type": "string", + "nullable": true, + "example": "mem_abc123", + "description": "Approver member ID, or null to unassign" + }, "frequency": { "type": "string", "enum": [ @@ -7409,6 +7480,224 @@ ] } }, + "/v1/tasks/{taskId}/activity": { + "get": { + "description": "Retrieve audit log activity for a specific task with pagination", + "operationId": "TasksController_getTaskActivity_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "taskId", + "required": true, + "in": "path", + "description": "Unique task identifier", + "schema": { + "example": "tsk_abc123def456", + "type": "string" + } + }, + { + "name": "skip", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "take", + "required": true, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Activity retrieved successfully" + }, + "400": { + "description": "Task not found" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Get task activity", + "tags": [ + "Tasks" + ] + } + }, + "/v1/tasks/{taskId}/submit-for-review": { + "post": { + "description": "Move task status to in_review and assign an approver.", + "operationId": "TasksController_submitForReview_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "taskId", + "required": true, + "in": "path", + "description": "Unique task identifier", + "schema": { + "example": "tsk_abc123def456", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "approverId": { + "type": "string", + "example": "mem_abc123", + "description": "Member ID of the approver" + } + }, + "required": [ + "approverId" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Task submitted for review" + }, + "400": { + "description": "Invalid request" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Submit task for review", + "tags": [ + "Tasks" + ] + } + }, + "/v1/tasks/{taskId}/approve": { + "post": { + "description": "Approve a task that is in review. Only the assigned approver can approve. Moves status to done and creates an audit comment.", + "operationId": "TasksController_approveTask_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "taskId", + "required": true, + "in": "path", + "description": "Unique task identifier", + "schema": { + "example": "tsk_abc123def456", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task approved successfully" + }, + "400": { + "description": "Task is not in review" + }, + "403": { + "description": "Not the assigned approver" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Approve a task", + "tags": [ + "Tasks" + ] + } + }, + "/v1/tasks/{taskId}/reject": { + "post": { + "description": "Reject a task that is in review. Only the assigned approver can reject. Reverts status to the previous status and creates an audit comment.", + "operationId": "TasksController_rejectTask_v1", + "parameters": [ + { + "name": "X-Organization-Id", + "in": "header", + "description": "Organization ID (required for session auth, optional for API key auth)", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "taskId", + "required": true, + "in": "path", + "description": "Unique task identifier", + "schema": { + "example": "tsk_abc123def456", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Task rejected successfully" + }, + "400": { + "description": "Task is not in review" + }, + "403": { + "description": "Not the assigned approver" + } + }, + "security": [ + { + "apikey": [] + } + ], + "summary": "Reject a task review", + "tags": [ + "Tasks" + ] + } + }, "/v1/tasks/{taskId}/attachments": { "get": { "description": "Retrieve all attachments for a specific task", diff --git a/packages/ui/package.json b/packages/ui/package.json index c1027eff7..d9b9113c2 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -555,4 +555,4 @@ "sideEffects": false, "type": "module", "types": "./dist/index.d.ts" -} +} \ No newline at end of file diff --git a/packages/ui/src/components/quantity-input.tsx b/packages/ui/src/components/quantity-input.tsx index e22978520..e773a43bd 100644 --- a/packages/ui/src/components/quantity-input.tsx +++ b/packages/ui/src/components/quantity-input.tsx @@ -68,7 +68,7 @@ export function QuantityInput({ autoComplete="off" step={0.1} value={rawValue} - onInput={handleInput} + onChange={handleInput} onBlur={onBlur} onFocus={onFocus} inputMode="decimal"
- Automatically monitor your cloud infrastructure for security vulnerabilities and - compliance issues. -
This usually takes 1-2 minutes. We'll show results as soon as they're ready.
+ {finding.content} +
- {closedCount} of {totalCount} findings resolved -
- {finding.content} -
- {'The owner role cannot be removed.'} + The owner role cannot be removed.
- {'Members must have at least one role.'} + Members must have at least one role.
This action cannot be undone.
- {'Get started by inviting your first team member.'} -
No secrets yet
- Create your first secret to enable AI automations -
Click to copy
{revealedSecrets[secret.id] ? 'Hide' : 'Reveal'} secret
{secret.description}
Edit secret
Delete secret
Your approval is required
+ Review the evidence for this task and approve or reject it. +
Pending approval
+ Waiting for {approverMember ? `${approverMember.user.name || approverMember.user.email}` : 'the approver'} to review and approve this task. +
+ {task.description} +
- {task.description} -
{log.description}
Loading activity...
No activity yet.
+ Page {page} of {totalPages} +
+ Evidence approval is enabled. Select an approver to review these tasks before they can be marked as done. +