From 92b2dc9312c183e54c09e86e85706dbe01942196 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 10:36:51 -0400 Subject: [PATCH 1/3] fix(app): show only published policies on Employee Tasks (#1547) Co-authored-by: chasprowebdev --- apps/app/src/app/(app)/[orgId]/people/[employeeId]/page.tsx | 1 + 1 file changed, 1 insertion(+) 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', From 777c0db8473c464d909ebce5ea6e0c2c3f72e31f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 19 Sep 2025 15:05:45 -0400 Subject: [PATCH 2/3] feat(app): Auto mark tasks as todo when review period starts (#1546) * feat(app): create a scheduled task for Recurring policy * feat(db): add reviewDate to Task table * feat(app): add Review Date on Task Properties * feat(tasks): add a scheduled task for Recurring Task * fix(app): set reviewDate once task gets moved to done * fix(app): make task reviewDate read-only * fix(app): minor style issue on task reviewDate input --------- Co-authored-by: chasprowebdev Co-authored-by: Mariano Fuentes --- .../components/TaskPropertiesSidebar.tsx | 84 ++---- apps/app/src/jobs/tasks/task/task-schedule.ts | 243 ++++++++++++++++++ .../email/emails/task-review-notification.tsx | 115 +++++++++ packages/email/index.ts | 2 + .../email/lib/task-review-notification.ts | 41 +++ 5 files changed, 417 insertions(+), 68 deletions(-) create mode 100644 apps/app/src/jobs/tasks/task/task-schedule.ts create mode 100644 packages/email/emails/task-review-notification.tsx create mode 100644 packages/email/lib/task-review-notification.ts 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/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}. + +
+ +
+ +