diff --git a/apps/app/public/badges/iso42001.svg b/apps/app/public/badges/iso42001.svg new file mode 100644 index 000000000..d5715a140 --- /dev/null +++ b/apps/app/public/badges/iso42001.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx index a17329ff9..45fe711c9 100644 --- a/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/frameworks/components/FrameworksOverview.tsx @@ -28,6 +28,10 @@ export function mapFrameworkToBadge(framework: FrameworkInstanceWithControls) { return '/badges/iso27001.svg'; } + if (framework.framework.name === 'ISO 42001') { + return '/badges/iso42001.svg'; + } + if (framework.framework.name === 'HIPAA') { return '/badges/hipaa.svg'; } diff --git a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx index a7fa20827..c9c9fcacd 100644 --- a/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx @@ -114,6 +114,7 @@ const getPoliciesTasks = async (employeeId: string) => { const policies = await db.policy.findMany({ where: { organizationId: organizationId, + status: 'published', }, orderBy: { name: 'asc', diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/actions/update-trust-portal-frameworks.ts b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/actions/update-trust-portal-frameworks.ts index 192624f84..050b5c9ee 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/actions/update-trust-portal-frameworks.ts +++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/actions/update-trust-portal-frameworks.ts @@ -10,12 +10,14 @@ interface UpdateTrustPortalFrameworksParams { soc2type1?: boolean; soc2type2?: boolean; iso27001?: boolean; + iso42001?: boolean; gdpr?: boolean; hipaa?: boolean; pcidss?: boolean; soc2type1Status?: 'started' | 'in_progress' | 'compliant'; soc2type2Status?: 'started' | 'in_progress' | 'compliant'; iso27001Status?: 'started' | 'in_progress' | 'compliant'; + iso42001Status?: 'started' | 'in_progress' | 'compliant'; gdprStatus?: 'started' | 'in_progress' | 'compliant'; hipaaStatus?: 'started' | 'in_progress' | 'compliant'; pcidssStatus?: 'started' | 'in_progress' | 'compliant'; @@ -26,12 +28,14 @@ export async function updateTrustPortalFrameworks({ soc2type1, soc2type2, iso27001, + iso42001, gdpr, hipaa, pcidss, soc2type1Status, soc2type2Status, iso27001Status, + iso42001Status, gdprStatus, hipaaStatus, pcidssStatus, @@ -63,6 +67,7 @@ export async function updateTrustPortalFrameworks({ soc2type1: soc2type1 ?? trustPortal.soc2type1, soc2type2: soc2type2 ?? trustPortal.soc2type2, iso27001: iso27001 ?? trustPortal.iso27001, + iso42001: iso42001 ?? trustPortal.iso42001, gdpr: gdpr ?? trustPortal.gdpr, hipaa: hipaa ?? trustPortal.hipaa, pci_dss: pcidss ?? trustPortal.pci_dss, @@ -70,6 +75,7 @@ export async function updateTrustPortalFrameworks({ soc2type1_status: soc2type1Status ?? trustPortal.soc2type1_status, soc2type2_status: soc2type2Status ?? trustPortal.soc2type2_status, iso27001_status: iso27001Status ?? trustPortal.iso27001_status, + iso42001_status: iso42001Status ?? trustPortal.iso42001_status, gdpr_status: gdprStatus ?? trustPortal.gdpr_status, hipaa_status: hipaaStatus ?? trustPortal.hipaa_status, pci_dss_status: pcidssStatus ?? trustPortal.pci_dss_status, diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalSwitch.tsx b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalSwitch.tsx index e8b9310d5..a31dedd30 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalSwitch.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/TrustPortalSwitch.tsx @@ -17,7 +17,7 @@ import { z } from 'zod'; import { isFriendlyAvailable } from '../actions/is-friendly-available'; import { trustPortalSwitchAction } from '../actions/trust-portal-switch'; import { updateTrustPortalFrameworks } from '../actions/update-trust-portal-frameworks'; -import { GDPR, HIPAA, ISO27001, SOC2, SOC2Type1, SOC2Type2, PCIDSS } from './logos'; +import { GDPR, HIPAA, ISO27001, SOC2Type1, SOC2Type2, PCIDSS, ISO42001 } from './logos'; const trustPortalSwitchSchema = z.object({ enabled: z.boolean(), @@ -26,12 +26,14 @@ const trustPortalSwitchSchema = z.object({ soc2type1: z.boolean(), soc2type2: z.boolean(), iso27001: z.boolean(), + iso42001: z.boolean(), gdpr: z.boolean(), hipaa: z.boolean(), pcidss: z.boolean(), soc2type1Status: z.enum(['started', 'in_progress', 'compliant']), soc2type2Status: z.enum(['started', 'in_progress', 'compliant']), iso27001Status: z.enum(['started', 'in_progress', 'compliant']), + iso42001Status: z.enum(['started', 'in_progress', 'compliant']), gdprStatus: z.enum(['started', 'in_progress', 'compliant']), hipaaStatus: z.enum(['started', 'in_progress', 'compliant']), pcidssStatus: z.enum(['started', 'in_progress', 'compliant']), @@ -47,12 +49,14 @@ export function TrustPortalSwitch({ soc2type1, soc2type2, iso27001, + iso42001, gdpr, hipaa, pcidss, soc2type1Status, soc2type2Status, iso27001Status, + iso42001Status, gdprStatus, hipaaStatus, pcidssStatus, @@ -67,12 +71,14 @@ export function TrustPortalSwitch({ soc2type1: boolean; soc2type2: boolean; iso27001: boolean; + iso42001: boolean; gdpr: boolean; hipaa: boolean; pcidss: boolean; soc2type1Status: 'started' | 'in_progress' | 'compliant'; soc2type2Status: 'started' | 'in_progress' | 'compliant'; iso27001Status: 'started' | 'in_progress' | 'compliant'; + iso42001Status: 'started' | 'in_progress' | 'compliant'; gdprStatus: 'started' | 'in_progress' | 'compliant'; hipaaStatus: 'started' | 'in_progress' | 'compliant'; pcidssStatus: 'started' | 'in_progress' | 'compliant'; @@ -97,12 +103,14 @@ export function TrustPortalSwitch({ soc2type1: soc2type1 ?? false, soc2type2: soc2type2 ?? false, iso27001: iso27001 ?? false, + iso42001: iso42001 ?? false, gdpr: gdpr ?? false, hipaa: hipaa ?? false, pcidss: pcidss ?? false, soc2type1Status: soc2type1Status ?? 'started', soc2type2Status: soc2type2Status ?? 'started', iso27001Status: iso27001Status ?? 'started', + iso42001Status: iso42001Status ?? 'started', gdprStatus: gdprStatus ?? 'started', hipaaStatus: hipaaStatus ?? 'started', pcidssStatus: pcidssStatus ?? 'started', @@ -359,6 +367,35 @@ export function TrustPortalSwitch({ } }} /> + {/* ISO 42001 */} + { + try { + await updateTrustPortalFrameworks({ + orgId, + iso42001Status: value as 'started' | 'in_progress' | 'compliant', + }); + toast.success('ISO 42001 status updated'); + } catch (error) { + toast.error('Failed to update ISO 42001 status'); + } + }} + onToggle={async (checked) => { + try { + await updateTrustPortalFrameworks({ + orgId, + iso42001: checked, + }); + toast.success('ISO 42001 status updated'); + } catch (error) { + toast.error('Failed to update ISO 42001 status'); + } + }} + /> {/* GDPR */} + ) : title === 'ISO 42001' ? ( +
+ +
) : title === 'GDPR' ? (
diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx index d353f1282..433e97f1d 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/components/logos.tsx @@ -1,61 +1,5 @@ import * as React from 'react'; -export const SOC2 = (props: React.SVGProps) => ( - - - - - - - - - - - - - - - - - - - -); - export const ISO27001 = (props: React.SVGProps) => ( ) => ( /> ); + +export const ISO42001 = (props: React.SVGProps) => ( + + + + + + + + + + + + +); diff --git a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx index 919ebecc7..59cbf66a8 100644 --- a/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx +++ b/apps/app/src/app/(app)/[orgId]/settings/trust-portal/page.tsx @@ -26,12 +26,14 @@ export default async function TrustPortalSettings({ soc2type1={trustPortal?.soc2type1 ?? false} soc2type2={trustPortal?.soc2type2 ?? false} iso27001={trustPortal?.iso27001 ?? false} + iso42001={trustPortal?.iso42001 ?? false} gdpr={trustPortal?.gdpr ?? false} hipaa={trustPortal?.hipaa ?? false} pcidss={trustPortal?.pcidss ?? false} soc2type1Status={trustPortal?.soc2type1Status ?? 'started'} soc2type2Status={trustPortal?.soc2type2Status ?? 'started'} iso27001Status={trustPortal?.iso27001Status ?? 'started'} + iso42001Status={trustPortal?.iso42001Status ?? 'started'} gdprStatus={trustPortal?.gdprStatus ?? 'started'} hipaaStatus={trustPortal?.hipaaStatus ?? 'started'} pcidssStatus={trustPortal?.pcidssStatus ?? 'started'} @@ -71,12 +73,14 @@ const getTrustPortal = cache(async (orgId: string) => { soc2type1: trustPortal?.soc2type1, soc2type2: trustPortal?.soc2type2 || trustPortal?.soc2, iso27001: trustPortal?.iso27001, + iso42001: trustPortal?.iso42001, gdpr: trustPortal?.gdpr, hipaa: trustPortal?.hipaa, pcidss: trustPortal?.pci_dss, soc2type1Status: trustPortal?.soc2type1_status, soc2type2Status: !trustPortal?.soc2type2 && trustPortal?.soc2 ? trustPortal?.soc2_status : trustPortal?.soc2type2_status, iso27001Status: trustPortal?.iso27001_status, + iso42001Status: trustPortal?.iso42001_status, gdprStatus: trustPortal?.gdpr_status, hipaaStatus: trustPortal?.hipaa_status, pcidssStatus: trustPortal?.pci_dss_status, 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 4bd940bf8..d20b9f3ad 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 @@ -14,8 +14,6 @@ import { useState } from 'react'; import { TaskStatusIndicator } from '../../components/TaskStatusIndicator'; import { PropertySelector } from './PropertySelector'; import { DEPARTMENT_COLORS, taskDepartments, taskFrequencies, taskStatuses } from './constants'; -import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover'; -import { Calendar } from '@comp/ui/calendar'; import { format } from 'date-fns'; interface TaskPropertiesSidebarProps { @@ -40,15 +38,6 @@ export function TaskPropertiesSidebar({ orgId, }: TaskPropertiesSidebarProps) { const [dropdownOpen, setDropdownOpen] = useState(false); - const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); - const [tempDate, setTempDate] = useState(undefined); - - // Function to handle date confirmation - const handleDateConfirm = (date: Date | undefined) => { - setTempDate(date); - setIsDatePickerOpen(false); - handleUpdateTask({ reviewDate: date }); - }; return (
diff --git a/apps/app/src/jobs/tasks/task/task-schedule.ts b/apps/app/src/jobs/tasks/task/task-schedule.ts new file mode 100644 index 000000000..303d781ed --- /dev/null +++ b/apps/app/src/jobs/tasks/task/task-schedule.ts @@ -0,0 +1,243 @@ +import { db } from '@db'; +import { sendTaskReviewNotificationEmail } from '@trycompai/email'; +import { logger, schedules } from '@trigger.dev/sdk'; + +export const taskSchedule = schedules.task({ + id: 'task-schedule', + cron: '0 */12 * * *', // Every 12 hours + maxDuration: 1000 * 60 * 10, // 10 minutes + run: async () => { + const now = new Date(); + + // Find all Done tasks that have a review date and frequency set + const candidateTasks = await db.task.findMany({ + where: { + status: 'done', + reviewDate: { + not: null, + }, + frequency: { + not: null, + }, + }, + include: { + organization: { + select: { + name: true, + }, + }, + assignee: { + include: { + user: true, + }, + }, + }, + }); + + // Helpers to compute next due date based on frequency + const addDaysToDate = (date: Date, days: number) => { + const result = new Date(date.getTime()); + result.setDate(result.getDate() + days); + return result; + }; + + const addMonthsToDate = (date: Date, months: number) => { + const result = new Date(date.getTime()); + const originalDayOfMonth = result.getDate(); + result.setMonth(result.getMonth() + months); + // Handle month rollover (e.g., Jan 31 + 1 month -> Feb 28/29) + if (result.getDate() < originalDayOfMonth) { + result.setDate(0); + } + return result; + }; + + const overdueTasks = candidateTasks.filter((task) => { + if (!task.reviewDate || !task.frequency) return false; + + let nextDueDate: Date | null = null; + switch (task.frequency) { + case 'daily': + nextDueDate = addDaysToDate(task.reviewDate, 1); + break; + case 'weekly': + nextDueDate = addDaysToDate(task.reviewDate, 7); + break; + case 'monthly': + nextDueDate = addMonthsToDate(task.reviewDate, 1); + break; + case 'quarterly': + nextDueDate = addMonthsToDate(task.reviewDate, 3); + break; + case 'yearly': + nextDueDate = addMonthsToDate(task.reviewDate, 12); + break; + default: + nextDueDate = null; + } + + return nextDueDate !== null && nextDueDate <= now; + }); + + logger.info(`Found ${overdueTasks.length} tasks past their computed review deadline`); + + if (overdueTasks.length === 0) { + return { + success: true, + totalTasksChecked: 0, + updatedTasks: 0, + message: 'No tasks found past their computed review deadline', + }; + } + + // Update all overdue tasks to "todo" status + try { + const taskIds = overdueTasks.map((task) => task.id); + + const updateResult = await db.task.updateMany({ + where: { + id: { + in: taskIds, + }, + }, + data: { + status: 'todo', + }, + }); + + + + // Log details about updated tasks + overdueTasks.forEach((task) => { + logger.info( + `Updated task "${task.title}" (${task.id}) from org "${task.organization.name}" - frequency ${task.frequency} - last reviewed ${task.reviewDate?.toISOString()}`, + ); + }); + + logger.info(`Successfully updated ${updateResult.count} tasks to "todo" status`); + + // Build a map of admins by organization for targeted notifications + const uniqueOrgIds = Array.from(new Set(overdueTasks.map((t) => t.organizationId))); + const admins = await db.member.findMany({ + where: { + organizationId: { in: uniqueOrgIds }, + isActive: true, + // role is a comma-separated string sometimes + role: { contains: 'admin' }, + }, + include: { + user: true, + }, + }); + + const adminsByOrgId = new Map(); + admins.forEach((admin) => { + const email = admin.user?.email; + if (!email) return; + const list = adminsByOrgId.get(admin.organizationId) ?? []; + list.push({ email, name: admin.user.name ?? email }); + adminsByOrgId.set(admin.organizationId, list); + }); + + // Rate limit: 2 emails per second + const EMAIL_BATCH_SIZE = 2; + const EMAIL_BATCH_DELAY_MS = 1000; + + // Build a flat list of email jobs + type EmailJob = { + email: string; + name: string; + task: typeof overdueTasks[number]; + }; + const emailJobs: EmailJob[] = []; + + // Helper to compute next due date again for email content + const computeNextDueDate = (reviewDate: Date, frequency: string): Date | null => { + switch (frequency) { + case 'daily': + return addDaysToDate(reviewDate, 1); + case 'weekly': + return addDaysToDate(reviewDate, 7); + case 'monthly': + return addMonthsToDate(reviewDate, 1); + case 'quarterly': + return addMonthsToDate(reviewDate, 3); + case 'yearly': + return addMonthsToDate(reviewDate, 12); + default: + return null; + } + }; + + for (const task of overdueTasks) { + const recipients = new Map(); // email -> name + + // Assignee (if any) + const assigneeEmail = task.assignee?.user?.email; + if (assigneeEmail) { + recipients.set(assigneeEmail, task.assignee?.user?.name ?? assigneeEmail); + } + + // Organization admins + const orgAdmins = adminsByOrgId.get(task.organizationId) ?? []; + orgAdmins.forEach((a) => recipients.set(a.email, a.name)); + + if (recipients.size === 0) { + logger.info(`No recipients found for task ${task.id} (${task.title})`); + continue; + } + + for (const [email, name] of recipients.entries()) { + emailJobs.push({ email, name, task }); + } + } + + for (let i = 0; i < emailJobs.length; i += EMAIL_BATCH_SIZE) { + const batch = emailJobs.slice(i, i + EMAIL_BATCH_SIZE); + + await Promise.all( + batch.map(async ({ email, name, task }) => { + try { + await sendTaskReviewNotificationEmail({ + email, + userName: name, + taskName: task.title, + organizationName: task.organization.name, + organizationId: task.organizationId, + taskId: task.id, + }); + logger.info(`Sent task review notification to ${email} for task ${task.id}`); + } catch (emailError) { + logger.error(`Failed to send review email to ${email} for task ${task.id}: ${emailError}`); + } + }), + ); + + // Only delay if there are more emails to send + if (i + EMAIL_BATCH_SIZE < emailJobs.length) { + await new Promise((resolve) => setTimeout(resolve, EMAIL_BATCH_DELAY_MS)); + } + } + + return { + success: true, + totalTasksChecked: overdueTasks.length, + updatedTasks: updateResult.count, + updatedTaskIds: taskIds, + message: `Updated ${updateResult.count} tasks past their review deadline`, + }; + } catch (error) { + logger.error(`Failed to update overdue tasks: ${error}`); + + return { + success: false, + totalTasksChecked: overdueTasks.length, + updatedTasks: 0, + error: error instanceof Error ? error.message : String(error), + message: 'Failed to update tasks past their review deadline', + }; + } + }, +}); + + diff --git a/packages/db/package.json b/packages/db/package.json index da4a62d1d..64f960252 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,7 +1,7 @@ { "name": "@trycompai/db", "description": "Database package with Prisma client and schema for Comp AI", - "version": "1.3.5", + "version": "1.3.6", "dependencies": { "@prisma/client": "^6.13.0", "dotenv": "^16.4.5" diff --git a/packages/db/prisma/migrations/20250919102634_add_iso42001_fields/migration.sql b/packages/db/prisma/migrations/20250919102634_add_iso42001_fields/migration.sql new file mode 100644 index 000000000..4c8903f3f --- /dev/null +++ b/packages/db/prisma/migrations/20250919102634_add_iso42001_fields/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Trust" ADD COLUMN "iso42001" BOOLEAN NOT NULL DEFAULT false; + +-- AlterTable +ALTER TABLE "Trust" ADD COLUMN "iso42001_status" "FrameworkStatus" NOT NULL DEFAULT 'started'; diff --git a/packages/db/prisma/schema/trust.prisma b/packages/db/prisma/schema/trust.prisma index ae4273cf6..5acbeecb2 100644 --- a/packages/db/prisma/schema/trust.prisma +++ b/packages/db/prisma/schema/trust.prisma @@ -15,6 +15,7 @@ model Trust { soc2type1 Boolean @default(false) soc2type2 Boolean @default(false) iso27001 Boolean @default(false) + iso42001 Boolean @default(false) gdpr Boolean @default(false) hipaa Boolean @default(false) pci_dss Boolean @default(false) @@ -23,6 +24,7 @@ model Trust { soc2type1_status FrameworkStatus @default(started) soc2type2_status FrameworkStatus @default(started) iso27001_status FrameworkStatus @default(started) + iso42001_status FrameworkStatus @default(started) gdpr_status FrameworkStatus @default(started) hipaa_status FrameworkStatus @default(started) pci_dss_status FrameworkStatus @default(started) diff --git a/packages/email/emails/task-review-notification.tsx b/packages/email/emails/task-review-notification.tsx new file mode 100644 index 000000000..5f00cabad --- /dev/null +++ b/packages/email/emails/task-review-notification.tsx @@ -0,0 +1,115 @@ +import { + Body, + Button, + Container, + Font, + Heading, + Html, + Link, + Preview, + Section, + Tailwind, + Text, +} from '@react-email/components'; +import { Footer } from '../components/footer'; +import { Logo } from '../components/logo'; + +interface Props { + email: string; + userName: string; + taskName: string; + organizationName: string; + organizationId: string; + taskId: string; +} + +export const TaskReviewNotificationEmail = ({ + email, + userName, + taskName, + organizationName, + organizationId, + taskId, +}: Props) => { + const link = `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/${organizationId}/tasks/${taskId}`; + const subjectText = 'Task review required'; + + return ( + + + + + + + + + {subjectText} + + + + + + {subjectText} + + + Hi {userName}, + + + The "{taskName}" task for {organizationName} is due for review. Please review and update it. + + +
+ +
+ + + or copy and paste this URL into your browser{' '} + + {link} + + + +
+
+ + This notification was intended for {email}. + +
+ +
+ +