Skip to content

Commit 1eb9623

Browse files
github-actions[bot]chasprowebdevMarfuen
authored
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 <chasgarciaprowebdev@gmail.com> Co-authored-by: Mariano Fuentes <marfuen98@gmail.com>
1 parent 68e0e42 commit 1eb9623

5 files changed

Lines changed: 377 additions & 2 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';
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: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import {
2+
Body,
3+
Button,
4+
Container,
5+
Font,
6+
Heading,
7+
Html,
8+
Link,
9+
Preview,
10+
Section,
11+
Tailwind,
12+
Text,
13+
} from '@react-email/components';
14+
import { Footer } from '../components/footer';
15+
import { Logo } from '../components/logo';
16+
17+
interface Props {
18+
email: string;
19+
userName: string;
20+
policyName: string;
21+
organizationName: string;
22+
organizationId: string;
23+
policyId: string;
24+
}
25+
26+
export const PolicyReviewNotificationEmail = ({
27+
email,
28+
userName,
29+
policyName,
30+
organizationName,
31+
organizationId,
32+
policyId,
33+
}: Props) => {
34+
const link = `${process.env.NEXT_PUBLIC_APP_URL ?? 'https://app.trycomp.ai'}/${organizationId}/policies/${policyId}`;
35+
const subjectText = 'Policy review required';
36+
37+
return (
38+
<Html>
39+
<Tailwind>
40+
<head>
41+
<Font
42+
fontFamily="Geist"
43+
fallbackFontFamily="Helvetica"
44+
webFont={{
45+
url: 'https://app.trycomp.ai/fonts/geist/geist-sans-latin-400-normal.woff2',
46+
format: 'woff2',
47+
}}
48+
fontWeight={400}
49+
fontStyle="normal"
50+
/>
51+
52+
<Font
53+
fontFamily="Geist"
54+
fallbackFontFamily="Helvetica"
55+
webFont={{
56+
url: 'https://app.trycomp.ai/fonts/geist/geist-sans-latin-500-normal.woff2',
57+
format: 'woff2',
58+
}}
59+
fontWeight={500}
60+
fontStyle="normal"
61+
/>
62+
</head>
63+
64+
<Preview>{subjectText}</Preview>
65+
66+
<Body className="mx-auto my-auto bg-[#fff] font-sans">
67+
<Container
68+
className="mx-auto my-[40px] max-w-[600px] border-transparent p-[20px] md:border-[#E8E7E1]"
69+
style={{ borderStyle: 'solid', borderWidth: 1 }}
70+
>
71+
<Logo />
72+
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-[#121212]">
73+
{subjectText}
74+
</Heading>
75+
76+
<Text className="text-[14px] leading-[24px] text-[#121212]">Hi {userName},</Text>
77+
78+
<Text className="text-[14px] leading-[24px] text-[#121212]">
79+
The "{policyName}" policy for <strong>{organizationName}</strong> is due for review. Please review and publish.
80+
</Text>
81+
82+
<Section className="mt-[32px] mb-[42px] text-center">
83+
<Button
84+
className="text-primary border border-solid border-[#121212] bg-transparent px-6 py-3 text-center text-[14px] font-medium text-[#121212] no-underline"
85+
href={link}
86+
>
87+
Review Policy
88+
</Button>
89+
</Section>
90+
91+
<Text className="text-[14px] leading-[24px] break-all text-[#707070]">
92+
or copy and paste this URL into your browser{' '}
93+
<Link href={link} className="text-[#707070] underline">
94+
{link}
95+
</Link>
96+
</Text>
97+
98+
<br />
99+
<Section>
100+
<Text className="text-[12px] leading-[24px] text-[#666666]">
101+
This notification was intended for <span className="text-[#121212]">{email}</span>.
102+
</Text>
103+
</Section>
104+
105+
<br />
106+
107+
<Footer />
108+
</Container>
109+
</Body>
110+
</Tailwind>
111+
</Html>
112+
);
113+
};
114+
115+
export default PolicyReviewNotificationEmail;
116+
117+

packages/email/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ export * from './emails/magic-link';
55
export * from './emails/marketing/welcome';
66
export * from './emails/otp';
77
export * from './emails/policy-notification';
8+
export * from './emails/policy-review-notification';
89
export * from './emails/waitlist';
910

1011
// Email sending functions
1112
export * from './lib/invite-member';
1213
export * from './lib/magic-link';
1314
export * from './lib/policy-notification';
15+
export * from './lib/policy-review-notification';
1416
export * from './lib/resend';
1517
export * from './lib/waitlist';

0 commit comments

Comments
 (0)