From 68e0e42fe26ac5391afa696a1cd15ae3148aadd3 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:14:12 -0400 Subject: [PATCH 1/2] feat(app): Add reviewDate column to Task table (#1541) * feat(db): add reviewDate to Task table * feat(app): add Review Date on Task Properties --------- Co-authored-by: chasprowebdev --- .../tasks/[taskId]/components/SingleTask.tsx | 8 +- .../components/TaskPropertiesSidebar.tsx | 78 ++++++++++++++++++- .../migration.sql | 4 + packages/db/prisma/schema/task.prisma | 1 + 4 files changed, 86 insertions(+), 5 deletions(-) create mode 100644 packages/db/prisma/migrations/20250917120000_add_reviewdate_to_task/migration.sql 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 5039d0024..11d4c5a8e 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 @@ -47,9 +47,9 @@ export function SingleTask({ task, members }: SingleTaskProps) { }, [task.assigneeId, members]); const handleUpdateTask = ( - data: Partial>, + data: Partial>, ) => { - const updatePayload: Partial> = + const updatePayload: Partial> = {}; if (data.status !== undefined) { @@ -64,7 +64,9 @@ export function SingleTask({ task, members }: SingleTaskProps) { if (Object.prototype.hasOwnProperty.call(data, 'frequency')) { updatePayload.frequency = data.frequency; } - + if (data.reviewDate !== undefined) { + updatePayload.reviewDate = data.reviewDate; + } if (Object.keys(updatePayload).length > 0) { updateTask({ id: task.id, ...updatePayload }); } 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 1665cc1fc..4bd940bf8 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 @@ -8,19 +8,22 @@ import { DropdownMenuTrigger, } from '@comp/ui/dropdown-menu'; import type { Control, Departments, Member, Task, TaskFrequency, TaskStatus, User } from '@db'; -import { MoreVertical, RefreshCw, Trash2 } from 'lucide-react'; +import { CalendarIcon, MoreVertical, RefreshCw, Trash2 } from 'lucide-react'; import Link from 'next/link'; 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 { task: Task & { controls?: Control[] }; members?: (Member & { user: User })[]; assignedMember: (Member & { user: User }) | null | undefined; // Allow undefined handleUpdateTask: ( - data: Partial>, + data: Partial>, ) => void; onDeleteClick?: () => void; onRegenerateClick?: () => void; @@ -37,6 +40,16 @@ 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/packages/db/prisma/migrations/20250917120000_add_reviewdate_to_task/migration.sql b/packages/db/prisma/migrations/20250917120000_add_reviewdate_to_task/migration.sql new file mode 100644 index 000000000..2105fda35 --- /dev/null +++ b/packages/db/prisma/migrations/20250917120000_add_reviewdate_to_task/migration.sql @@ -0,0 +1,4 @@ +-- Add nullable reviewDate column to Task table +ALTER TABLE "Task" ADD COLUMN "reviewDate" TIMESTAMP(3); + + diff --git a/packages/db/prisma/schema/task.prisma b/packages/db/prisma/schema/task.prisma index 9eb3e27a2..ef9e511bf 100644 --- a/packages/db/prisma/schema/task.prisma +++ b/packages/db/prisma/schema/task.prisma @@ -12,6 +12,7 @@ model Task { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt lastCompletedAt DateTime? + reviewDate DateTime? // Relationships controls Control[] From 1eb9623c05a88b8c04b303ac505d32ae0c13baeb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:22:49 -0400 Subject: [PATCH 2/2] feat(app): Create a scheduled task for Recurring policy (#1540) * feat(app): create a scheduled task for Recurring policy * feat(db): add reviewDate to Task table * fix(task): update policy review email --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../components/UpdatePolicyOverview.tsx | 4 +- .../src/jobs/tasks/task/policy-schedule.ts | 214 ++++++++++++++++++ .../emails/policy-review-notification.tsx | 117 ++++++++++ packages/email/index.ts | 2 + .../email/lib/policy-review-notification.ts | 42 ++++ 5 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/jobs/tasks/task/policy-schedule.ts create mode 100644 packages/email/emails/policy-review-notification.tsx create mode 100644 packages/email/lib/policy-review-notification.ts diff --git a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx index b56ac17db..197c0460f 100644 --- a/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx +++ b/apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx @@ -119,7 +119,7 @@ export function UpdatePolicyOverview({ reviewDate.toDateString()); // If policy is draft and being published OR policy is published and has changes - if ((policy.status === 'draft' && status === 'published') || isPublishedWithChanges) { + if ((['draft', 'needs_review'].includes(policy.status) && status === 'published') || isPublishedWithChanges) { setIsApprovalDialogOpen(true); setIsSubmitting(false); } else { @@ -172,7 +172,7 @@ export function UpdatePolicyOverview({ // Determine button text based on status and form interaction let buttonText = 'Save'; if ( - (policy.status === 'draft' && selectedStatus === 'published') || + (['draft', 'needs_review'].includes(policy.status) && selectedStatus === 'published') || (policy.status === 'published' && hasFormChanges) ) { buttonText = 'Submit for Approval'; diff --git a/apps/app/src/jobs/tasks/task/policy-schedule.ts b/apps/app/src/jobs/tasks/task/policy-schedule.ts new file mode 100644 index 000000000..32fed28a0 --- /dev/null +++ b/apps/app/src/jobs/tasks/task/policy-schedule.ts @@ -0,0 +1,214 @@ +import { db } from '@db'; +import { sendPolicyReviewNotificationEmail } from '@trycompai/email'; +import { logger, schedules } from '@trigger.dev/sdk'; + +export const policySchedule = schedules.task({ + id: 'policy-schedule', + cron: '0 */12 * * *', // Every 12 hours + maxDuration: 1000 * 60 * 10, // 10 minutes + run: async () => { + const now = new Date(); + + // Find all published policies that have a review date and frequency set + const candidatePolicies = await db.policy.findMany({ + where: { + status: 'published', + reviewDate: { + not: null, + }, + frequency: { + not: null, + }, + }, + include: { + organization: { + select: { + name: true, + }, + }, + assignee: { + include: { + user: true, + }, + }, + }, + }); + + // Compute next due date based on frequency and filter to overdue + 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 overduePolicies = candidatePolicies.filter((policy) => { + if (!policy.reviewDate || !policy.frequency) return false; + + let monthsToAdd = 0; + switch (policy.frequency) { + case 'monthly': + monthsToAdd = 1; + break; + case 'quarterly': + monthsToAdd = 3; + break; + case 'yearly': + monthsToAdd = 12; + break; + default: + monthsToAdd = 0; + } + + if (monthsToAdd === 0) return false; + + const nextDueDate = addMonthsToDate(policy.reviewDate, monthsToAdd); + return nextDueDate <= now; + }); + + logger.info(`Found ${overduePolicies.length} policies past their computed review deadline`); + + if (overduePolicies.length === 0) { + return { + success: true, + totalPoliciesChecked: 0, + updatedPolicies: 0, + message: 'No policies found past their computed review deadline', + }; + } + + // Update all overdue policies to "needs_review" status + try { + const policyIds = overduePolicies.map((policy) => policy.id); + + const updateResult = await db.policy.updateMany({ + where: { + id: { + in: policyIds, + }, + }, + data: { + status: 'needs_review', + }, + }); + + // Log details about updated policies + overduePolicies.forEach((policy) => { + logger.info( + `Updated policy "${policy.name}" (${policy.id}) from org "${policy.organization.name}" - frequency ${policy.frequency} - last reviewed ${policy.reviewDate?.toISOString()}`, + ); + }); + + logger.info(`Successfully updated ${updateResult.count} policies to "needs_review" status`); + + // Build a map of owners by organization for targeted notifications + const uniqueOrgIds = Array.from(new Set(overduePolicies.map((p) => p.organizationId))); + const owners = await db.member.findMany({ + where: { + organizationId: { in: uniqueOrgIds }, + isActive: true, + // role is a comma-separated string sometimes + role: { contains: 'owner' }, + }, + include: { + user: true, + }, + }); + + const ownersByOrgId = new Map(); + owners.forEach((owner) => { + const email = owner.user?.email; + if (!email) return; + const list = ownersByOrgId.get(owner.organizationId) ?? []; + list.push({ email, name: owner.user.name ?? email }); + ownersByOrgId.set(owner.organizationId, list); + }); + + // Send review notifications to org owners and the policy assignee only + // Send review notifications to org owners and the policy assignee only, rate-limited to 2 emails/sec + const EMAIL_BATCH_SIZE = 2; + const EMAIL_BATCH_DELAY_MS = 1000; + + // Build a flat list of all emails to send, with their policy context + type EmailJob = { + email: string; + name: string; + policy: typeof overduePolicies[number]; + }; + const emailJobs: EmailJob[] = []; + + for (const policy of overduePolicies) { + const recipients = new Map(); // email -> name + + // Assignee (if any) + const assigneeEmail = policy.assignee?.user?.email; + if (assigneeEmail) { + recipients.set(assigneeEmail, policy.assignee?.user?.name ?? assigneeEmail); + } + + // Organization owners + const orgOwners = ownersByOrgId.get(policy.organizationId) ?? []; + orgOwners.forEach((o) => recipients.set(o.email, o.name)); + + if (recipients.size === 0) { + logger.info(`No recipients found for policy ${policy.id} (${policy.name})`); + continue; + } + + for (const [email, name] of recipients.entries()) { + emailJobs.push({ email, name, policy }); + } + } + + // Send emails in batches of EMAIL_BATCH_SIZE per second + 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, policy }) => { + try { + await sendPolicyReviewNotificationEmail({ + email, + userName: name, + policyName: policy.name, + organizationName: policy.organization.name, + organizationId: policy.organizationId, + policyId: policy.id, + }); + logger.info(`Sent policy review notification to ${email} for policy ${policy.id}`); + } catch (emailError) { + logger.error(`Failed to send review email to ${email} for policy ${policy.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, + totalPoliciesChecked: overduePolicies.length, + updatedPolicies: updateResult.count, + updatedPolicyIds: policyIds, + message: `Updated ${updateResult.count} policies past their review deadline`, + }; + } catch (error) { + logger.error(`Failed to update overdue policies: ${error}`); + + return { + success: false, + totalPoliciesChecked: overduePolicies.length, + updatedPolicies: 0, + error: error instanceof Error ? error.message : String(error), + message: 'Failed to update policies past their review deadline', + }; + } + }, +}); diff --git a/packages/email/emails/policy-review-notification.tsx b/packages/email/emails/policy-review-notification.tsx new file mode 100644 index 000000000..917cf532a --- /dev/null +++ b/packages/email/emails/policy-review-notification.tsx @@ -0,0 +1,117 @@ +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; + policyName: string; + organizationName: string; + organizationId: string; + policyId: string; +} + +export const PolicyReviewNotificationEmail = ({ + email, + userName, + policyName, + organizationName, + organizationId, + policyId, +}: Props) => { + const link = `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/${organizationId}/policies/${policyId}`; + const subjectText = 'Policy review required'; + + return ( + + + + + + + + + {subjectText} + + + + + + {subjectText} + + + Hi {userName}, + + + The "{policyName}" policy for {organizationName} is due for review. Please review and publish. + + +
+ +
+ + + or copy and paste this URL into your browser{' '} + + {link} + + + +
+
+ + This notification was intended for {email}. + +
+ +
+ +