Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,9 @@ export function SingleTask({ task, members }: SingleTaskProps) {
}, [task.assigneeId, members]);

const handleUpdateTask = (
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department'>>,
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department' | 'reviewDate'>>,
) => {
const updatePayload: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department'>> =
const updatePayload: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department' | 'reviewDate'>> =
{};

if (data.status !== undefined) {
Expand All @@ -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 });
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department'>>,
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department' | 'reviewDate'>>,
) => void;
onDeleteClick?: () => void;
onRegenerateClick?: () => void;
Expand All @@ -37,6 +40,16 @@ export function TaskPropertiesSidebar({
orgId,
}: TaskPropertiesSidebarProps) {
const [dropdownOpen, setDropdownOpen] = useState(false);
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
const [tempDate, setTempDate] = useState<Date | undefined>(undefined);

// Function to handle date confirmation
const handleDateConfirm = (date: Date | undefined) => {
setTempDate(date);
setIsDatePickerOpen(false);
handleUpdateTask({ reviewDate: date });
};

return (
<aside className="hidden w-full shrink-0 flex-col md:w-64 md:border-l md:pt-8 md:pl-8 lg:flex lg:w-72">
<div className="mb-4 flex items-center justify-between">
Expand Down Expand Up @@ -281,6 +294,67 @@ export function TaskPropertiesSidebar({
</div>
</div>
)}
{/* Review Date Selector */}
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Review Date</span>
<Popover
open={isDatePickerOpen}
onOpenChange={(open) => {
setIsDatePickerOpen(open);
if (!open) {
setTempDate(undefined);
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
className="flex h-auto w-auto items-center justify-end p-0 px-1 hover:bg-transparent data-[state=open]:bg-transparent"
>
{tempDate ? (
format(tempDate, 'M/d/yyyy')
) : task.reviewDate ? (
format(new Date(task.reviewDate), 'M/d/yyyy')
) : (
<span className="text-muted-foreground px-1">Select ...</span>
)}
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={
tempDate || (task.reviewDate ? new Date(task.reviewDate) : undefined)
}
onSelect={(date) => setTempDate(date)}
disabled={(date) => date <= new Date()}
initialFocus
/>
<div className="mt-4 flex justify-end gap-2 px-4 pb-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
setIsDatePickerOpen(false);
setTempDate(undefined);
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => handleDateConfirm(tempDate)}
>
Confirm Date
</Button>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</aside>
);
Expand Down
214 changes: 214 additions & 0 deletions apps/app/src/jobs/tasks/task/policy-schedule.ts
Original file line number Diff line number Diff line change
@@ -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<string, { email: string; name: string }[]>();
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<string, string>(); // 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',
};
}
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Add nullable reviewDate column to Task table
ALTER TABLE "Task" ADD COLUMN "reviewDate" TIMESTAMP(3);


1 change: 1 addition & 0 deletions packages/db/prisma/schema/task.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ model Task {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastCompletedAt DateTime?
reviewDate DateTime?

// Relationships
controls Control[]
Expand Down
Loading
Loading