Skip to content

Commit 7f4d335

Browse files
committed
chore: merge main into release for new releases
2 parents 72ba4b5 + 1eb9623 commit 7f4d335

9 files changed

Lines changed: 463 additions & 7 deletions

File tree

apps/app/src/app/(app)/[orgId]/policies/[policyId]/components/UpdatePolicyOverview.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ export function UpdatePolicyOverview({
119119
reviewDate.toDateString());
120120

121121
// If policy is draft and being published OR policy is published and has changes
122-
if ((policy.status === 'draft' && status === 'published') || isPublishedWithChanges) {
122+
if ((['draft', 'needs_review'].includes(policy.status) && status === 'published') || isPublishedWithChanges) {
123123
setIsApprovalDialogOpen(true);
124124
setIsSubmitting(false);
125125
} else {
@@ -172,7 +172,7 @@ export function UpdatePolicyOverview({
172172
// Determine button text based on status and form interaction
173173
let buttonText = 'Save';
174174
if (
175-
(policy.status === 'draft' && selectedStatus === 'published') ||
175+
(['draft', 'needs_review'].includes(policy.status) && selectedStatus === 'published') ||
176176
(policy.status === 'published' && hasFormChanges)
177177
) {
178178
buttonText = 'Submit for Approval';

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/SingleTask.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,9 @@ export function SingleTask({ task, members }: SingleTaskProps) {
4747
}, [task.assigneeId, members]);
4848

4949
const handleUpdateTask = (
50-
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department'>>,
50+
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department' | 'reviewDate'>>,
5151
) => {
52-
const updatePayload: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department'>> =
52+
const updatePayload: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department' | 'reviewDate'>> =
5353
{};
5454

5555
if (data.status !== undefined) {
@@ -64,7 +64,9 @@ export function SingleTask({ task, members }: SingleTaskProps) {
6464
if (Object.prototype.hasOwnProperty.call(data, 'frequency')) {
6565
updatePayload.frequency = data.frequency;
6666
}
67-
67+
if (data.reviewDate !== undefined) {
68+
updatePayload.reviewDate = data.reviewDate;
69+
}
6870
if (Object.keys(updatePayload).length > 0) {
6971
updateTask({ id: task.id, ...updatePayload });
7072
}

apps/app/src/app/(app)/[orgId]/tasks/[taskId]/components/TaskPropertiesSidebar.tsx

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,22 @@ import {
88
DropdownMenuTrigger,
99
} from '@comp/ui/dropdown-menu';
1010
import type { Control, Departments, Member, Task, TaskFrequency, TaskStatus, User } from '@db';
11-
import { MoreVertical, RefreshCw, Trash2 } from 'lucide-react';
11+
import { CalendarIcon, MoreVertical, RefreshCw, Trash2 } from 'lucide-react';
1212
import Link from 'next/link';
1313
import { useState } from 'react';
1414
import { TaskStatusIndicator } from '../../components/TaskStatusIndicator';
1515
import { PropertySelector } from './PropertySelector';
1616
import { DEPARTMENT_COLORS, taskDepartments, taskFrequencies, taskStatuses } from './constants';
17+
import { Popover, PopoverContent, PopoverTrigger } from '@comp/ui/popover';
18+
import { Calendar } from '@comp/ui/calendar';
19+
import { format } from 'date-fns';
1720

1821
interface TaskPropertiesSidebarProps {
1922
task: Task & { controls?: Control[] };
2023
members?: (Member & { user: User })[];
2124
assignedMember: (Member & { user: User }) | null | undefined; // Allow undefined
2225
handleUpdateTask: (
23-
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department'>>,
26+
data: Partial<Pick<Task, 'status' | 'assigneeId' | 'frequency' | 'department' | 'reviewDate'>>,
2427
) => void;
2528
onDeleteClick?: () => void;
2629
onRegenerateClick?: () => void;
@@ -37,6 +40,16 @@ export function TaskPropertiesSidebar({
3740
orgId,
3841
}: TaskPropertiesSidebarProps) {
3942
const [dropdownOpen, setDropdownOpen] = useState(false);
43+
const [isDatePickerOpen, setIsDatePickerOpen] = useState(false);
44+
const [tempDate, setTempDate] = useState<Date | undefined>(undefined);
45+
46+
// Function to handle date confirmation
47+
const handleDateConfirm = (date: Date | undefined) => {
48+
setTempDate(date);
49+
setIsDatePickerOpen(false);
50+
handleUpdateTask({ reviewDate: date });
51+
};
52+
4053
return (
4154
<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">
4255
<div className="mb-4 flex items-center justify-between">
@@ -281,6 +294,67 @@ export function TaskPropertiesSidebar({
281294
</div>
282295
</div>
283296
)}
297+
{/* Review Date Selector */}
298+
<div className="flex items-center justify-between text-sm">
299+
<span className="text-muted-foreground">Review Date</span>
300+
<Popover
301+
open={isDatePickerOpen}
302+
onOpenChange={(open) => {
303+
setIsDatePickerOpen(open);
304+
if (!open) {
305+
setTempDate(undefined);
306+
}
307+
}}
308+
>
309+
<PopoverTrigger asChild>
310+
<Button
311+
type="button"
312+
variant="ghost"
313+
className="flex h-auto w-auto items-center justify-end p-0 px-1 hover:bg-transparent data-[state=open]:bg-transparent"
314+
>
315+
{tempDate ? (
316+
format(tempDate, 'M/d/yyyy')
317+
) : task.reviewDate ? (
318+
format(new Date(task.reviewDate), 'M/d/yyyy')
319+
) : (
320+
<span className="text-muted-foreground px-1">Select ...</span>
321+
)}
322+
<CalendarIcon className="ml-auto h-4 w-4 opacity-50" />
323+
</Button>
324+
</PopoverTrigger>
325+
<PopoverContent className="w-auto p-0" align="end">
326+
<Calendar
327+
mode="single"
328+
selected={
329+
tempDate || (task.reviewDate ? new Date(task.reviewDate) : undefined)
330+
}
331+
onSelect={(date) => setTempDate(date)}
332+
disabled={(date) => date <= new Date()}
333+
initialFocus
334+
/>
335+
<div className="mt-4 flex justify-end gap-2 px-4 pb-2">
336+
<Button
337+
type="button"
338+
size="sm"
339+
variant="outline"
340+
onClick={() => {
341+
setIsDatePickerOpen(false);
342+
setTempDate(undefined);
343+
}}
344+
>
345+
Cancel
346+
</Button>
347+
<Button
348+
type="button"
349+
size="sm"
350+
onClick={() => handleDateConfirm(tempDate)}
351+
>
352+
Confirm Date
353+
</Button>
354+
</div>
355+
</PopoverContent>
356+
</Popover>
357+
</div>
284358
</div>
285359
</aside>
286360
);
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { db } from '@db';
2+
import { sendPolicyReviewNotificationEmail } from '@trycompai/email';
3+
import { logger, schedules } from '@trigger.dev/sdk';
4+
5+
export const policySchedule = schedules.task({
6+
id: 'policy-schedule',
7+
cron: '0 */12 * * *', // Every 12 hours
8+
maxDuration: 1000 * 60 * 10, // 10 minutes
9+
run: async () => {
10+
const now = new Date();
11+
12+
// Find all published policies that have a review date and frequency set
13+
const candidatePolicies = await db.policy.findMany({
14+
where: {
15+
status: 'published',
16+
reviewDate: {
17+
not: null,
18+
},
19+
frequency: {
20+
not: null,
21+
},
22+
},
23+
include: {
24+
organization: {
25+
select: {
26+
name: true,
27+
},
28+
},
29+
assignee: {
30+
include: {
31+
user: true,
32+
},
33+
},
34+
},
35+
});
36+
37+
// Compute next due date based on frequency and filter to overdue
38+
const addMonthsToDate = (date: Date, months: number) => {
39+
const result = new Date(date.getTime());
40+
const originalDayOfMonth = result.getDate();
41+
result.setMonth(result.getMonth() + months);
42+
// Handle month rollover (e.g., Jan 31 + 1 month -> Feb 28/29)
43+
if (result.getDate() < originalDayOfMonth) {
44+
result.setDate(0);
45+
}
46+
return result;
47+
};
48+
49+
const overduePolicies = candidatePolicies.filter((policy) => {
50+
if (!policy.reviewDate || !policy.frequency) return false;
51+
52+
let monthsToAdd = 0;
53+
switch (policy.frequency) {
54+
case 'monthly':
55+
monthsToAdd = 1;
56+
break;
57+
case 'quarterly':
58+
monthsToAdd = 3;
59+
break;
60+
case 'yearly':
61+
monthsToAdd = 12;
62+
break;
63+
default:
64+
monthsToAdd = 0;
65+
}
66+
67+
if (monthsToAdd === 0) return false;
68+
69+
const nextDueDate = addMonthsToDate(policy.reviewDate, monthsToAdd);
70+
return nextDueDate <= now;
71+
});
72+
73+
logger.info(`Found ${overduePolicies.length} policies past their computed review deadline`);
74+
75+
if (overduePolicies.length === 0) {
76+
return {
77+
success: true,
78+
totalPoliciesChecked: 0,
79+
updatedPolicies: 0,
80+
message: 'No policies found past their computed review deadline',
81+
};
82+
}
83+
84+
// Update all overdue policies to "needs_review" status
85+
try {
86+
const policyIds = overduePolicies.map((policy) => policy.id);
87+
88+
const updateResult = await db.policy.updateMany({
89+
where: {
90+
id: {
91+
in: policyIds,
92+
},
93+
},
94+
data: {
95+
status: 'needs_review',
96+
},
97+
});
98+
99+
// Log details about updated policies
100+
overduePolicies.forEach((policy) => {
101+
logger.info(
102+
`Updated policy "${policy.name}" (${policy.id}) from org "${policy.organization.name}" - frequency ${policy.frequency} - last reviewed ${policy.reviewDate?.toISOString()}`,
103+
);
104+
});
105+
106+
logger.info(`Successfully updated ${updateResult.count} policies to "needs_review" status`);
107+
108+
// Build a map of owners by organization for targeted notifications
109+
const uniqueOrgIds = Array.from(new Set(overduePolicies.map((p) => p.organizationId)));
110+
const owners = await db.member.findMany({
111+
where: {
112+
organizationId: { in: uniqueOrgIds },
113+
isActive: true,
114+
// role is a comma-separated string sometimes
115+
role: { contains: 'owner' },
116+
},
117+
include: {
118+
user: true,
119+
},
120+
});
121+
122+
const ownersByOrgId = new Map<string, { email: string; name: string }[]>();
123+
owners.forEach((owner) => {
124+
const email = owner.user?.email;
125+
if (!email) return;
126+
const list = ownersByOrgId.get(owner.organizationId) ?? [];
127+
list.push({ email, name: owner.user.name ?? email });
128+
ownersByOrgId.set(owner.organizationId, list);
129+
});
130+
131+
// Send review notifications to org owners and the policy assignee only
132+
// Send review notifications to org owners and the policy assignee only, rate-limited to 2 emails/sec
133+
const EMAIL_BATCH_SIZE = 2;
134+
const EMAIL_BATCH_DELAY_MS = 1000;
135+
136+
// Build a flat list of all emails to send, with their policy context
137+
type EmailJob = {
138+
email: string;
139+
name: string;
140+
policy: typeof overduePolicies[number];
141+
};
142+
const emailJobs: EmailJob[] = [];
143+
144+
for (const policy of overduePolicies) {
145+
const recipients = new Map<string, string>(); // email -> name
146+
147+
// Assignee (if any)
148+
const assigneeEmail = policy.assignee?.user?.email;
149+
if (assigneeEmail) {
150+
recipients.set(assigneeEmail, policy.assignee?.user?.name ?? assigneeEmail);
151+
}
152+
153+
// Organization owners
154+
const orgOwners = ownersByOrgId.get(policy.organizationId) ?? [];
155+
orgOwners.forEach((o) => recipients.set(o.email, o.name));
156+
157+
if (recipients.size === 0) {
158+
logger.info(`No recipients found for policy ${policy.id} (${policy.name})`);
159+
continue;
160+
}
161+
162+
for (const [email, name] of recipients.entries()) {
163+
emailJobs.push({ email, name, policy });
164+
}
165+
}
166+
167+
// Send emails in batches of EMAIL_BATCH_SIZE per second
168+
for (let i = 0; i < emailJobs.length; i += EMAIL_BATCH_SIZE) {
169+
const batch = emailJobs.slice(i, i + EMAIL_BATCH_SIZE);
170+
171+
await Promise.all(
172+
batch.map(async ({ email, name, policy }) => {
173+
try {
174+
await sendPolicyReviewNotificationEmail({
175+
email,
176+
userName: name,
177+
policyName: policy.name,
178+
organizationName: policy.organization.name,
179+
organizationId: policy.organizationId,
180+
policyId: policy.id,
181+
});
182+
logger.info(`Sent policy review notification to ${email} for policy ${policy.id}`);
183+
} catch (emailError) {
184+
logger.error(`Failed to send review email to ${email} for policy ${policy.id}: ${emailError}`);
185+
}
186+
}),
187+
);
188+
189+
// Only delay if there are more emails to send
190+
if (i + EMAIL_BATCH_SIZE < emailJobs.length) {
191+
await new Promise((resolve) => setTimeout(resolve, EMAIL_BATCH_DELAY_MS));
192+
}
193+
}
194+
195+
return {
196+
success: true,
197+
totalPoliciesChecked: overduePolicies.length,
198+
updatedPolicies: updateResult.count,
199+
updatedPolicyIds: policyIds,
200+
message: `Updated ${updateResult.count} policies past their review deadline`,
201+
};
202+
} catch (error) {
203+
logger.error(`Failed to update overdue policies: ${error}`);
204+
205+
return {
206+
success: false,
207+
totalPoliciesChecked: overduePolicies.length,
208+
updatedPolicies: 0,
209+
error: error instanceof Error ? error.message : String(error),
210+
message: 'Failed to update policies past their review deadline',
211+
};
212+
}
213+
},
214+
});
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Add nullable reviewDate column to Task table
2+
ALTER TABLE "Task" ADD COLUMN "reviewDate" TIMESTAMP(3);
3+
4+

packages/db/prisma/schema/task.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ model Task {
1212
createdAt DateTime @default(now())
1313
updatedAt DateTime @updatedAt
1414
lastCompletedAt DateTime?
15+
reviewDate DateTime?
1516
1617
// Relationships
1718
controls Control[]

0 commit comments

Comments
 (0)