|
3 | 3 | * Probably inngest... But we're gonna stick with this for now and see how far |
4 | 4 | * it gets us. |
5 | 5 | */ |
6 | | -import { remember } from '@epic-web/remember' |
7 | 6 | import { CronExpressionParser } from 'cron-parser' |
8 | | -import { |
9 | | - clearIntervalAsync, |
10 | | - setIntervalAsync, |
11 | | -} from 'set-interval-async/dynamic' |
12 | | -import { prisma } from './db.server.ts' |
13 | | -import { getrecipientsforcron } from './prisma-generated.server/sql.ts' |
14 | | -import { |
15 | | - NEXT_SCHEDULE_SENTINEL_DATE, |
16 | | - PREV_SCHEDULE_SENTINEL_DATE, |
17 | | -} from './schedule-constants.server.ts' |
18 | | -import { sendText, sendTextToRecipient } from './text.server.ts' |
19 | 7 |
|
20 | 8 | export class CronParseError extends Error { |
21 | 9 | constructor( |
@@ -54,158 +42,6 @@ export function getScheduleWindow( |
54 | 42 | return { prevScheduledAt, nextScheduledAt } |
55 | 43 | } |
56 | 44 |
|
57 | | -const cronIntervalRef = remember<{ |
58 | | - current: ReturnType<typeof setIntervalAsync> | null |
59 | | -}>('cronInterval', () => ({ current: null })) |
60 | | - |
61 | | -export async function init() { |
62 | | - console.log('initializing cron interval') |
63 | | - if (cronIntervalRef.current) await clearIntervalAsync(cronIntervalRef.current) |
64 | | - |
65 | | - cronIntervalRef.current = setIntervalAsync( |
66 | | - () => sendNextTexts().catch((err) => console.error(err)), |
67 | | - 1000 * 5, |
68 | | - ) |
69 | | -} |
70 | | - |
71 | | -export async function sendNextTexts() { |
72 | | - const now = new Date() |
73 | | - const reminderWindowMs = 1000 * 60 * 30 |
74 | | - const reminderCutoff = new Date(now.getTime() + reminderWindowMs) |
75 | | - |
76 | | - // Optimized TypedSQL query that: |
77 | | - // 1. Uses INNER JOIN (we filter by User.stripeId, so LEFT JOIN is unnecessary) |
78 | | - // 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId) |
79 | | - // 3. Handles both regular scheduled recipients and those with sentinel dates |
80 | | - // Note: NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff, |
81 | | - // so recipients with invalid schedules won't be included |
82 | | - const recipients = await prisma.$queryRawTyped( |
83 | | - getrecipientsforcron(reminderCutoff), |
84 | | - ) |
85 | | - |
86 | | - // Transform the raw result to match the expected format |
87 | | - const formattedRecipients = recipients.map((r) => ({ |
88 | | - ...r, |
89 | | - user: { |
90 | | - phoneNumber: r.userPhoneNumber, |
91 | | - name: r.userName, |
92 | | - }, |
93 | | - })) |
94 | | - |
95 | | - if (!formattedRecipients.length) return |
96 | | - |
97 | | - let dueSentCount = 0 |
98 | | - let reminderSentCount = 0 |
99 | | - for (const recipient of formattedRecipients) { |
100 | | - let scheduleWindow: { |
101 | | - prevScheduledAt: Date |
102 | | - nextScheduledAt: Date |
103 | | - } |
104 | | - if ( |
105 | | - recipient.prevScheduledAt && |
106 | | - recipient.nextScheduledAt && |
107 | | - recipient.nextScheduledAt > now && |
108 | | - recipient.nextScheduledAt.getTime() !== |
109 | | - NEXT_SCHEDULE_SENTINEL_DATE.getTime() |
110 | | - ) { |
111 | | - scheduleWindow = { |
112 | | - prevScheduledAt: recipient.prevScheduledAt, |
113 | | - nextScheduledAt: recipient.nextScheduledAt, |
114 | | - } |
115 | | - } else { |
116 | | - try { |
117 | | - scheduleWindow = getScheduleWindow( |
118 | | - recipient.scheduleCron, |
119 | | - recipient.timeZone, |
120 | | - now, |
121 | | - ) |
122 | | - } catch (error) { |
123 | | - console.error( |
124 | | - `Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`, |
125 | | - error instanceof Error ? error.message : error, |
126 | | - ) |
127 | | - // Update with sentinel dates to prevent repeated processing |
128 | | - await prisma.recipient.update({ |
129 | | - where: { id: recipient.id }, |
130 | | - data: { |
131 | | - prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE, |
132 | | - nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE, |
133 | | - }, |
134 | | - }) |
135 | | - continue |
136 | | - } |
137 | | - } |
138 | | - |
139 | | - const { prevScheduledAt, nextScheduledAt } = scheduleWindow |
140 | | - const shouldUpdateSchedule = |
141 | | - !recipient.prevScheduledAt || |
142 | | - !recipient.nextScheduledAt || |
143 | | - recipient.prevScheduledAt.getTime() !== prevScheduledAt.getTime() || |
144 | | - recipient.nextScheduledAt.getTime() !== nextScheduledAt.getTime() |
145 | | - |
146 | | - if (shouldUpdateSchedule) { |
147 | | - await prisma.recipient.update({ |
148 | | - where: { id: recipient.id }, |
149 | | - data: { |
150 | | - prevScheduledAt, |
151 | | - nextScheduledAt, |
152 | | - }, |
153 | | - }) |
154 | | - } |
155 | | - |
156 | | - const lastSent = new Date(recipient.lastSentAt ?? 0) |
157 | | - const nextIsSoon = |
158 | | - nextScheduledAt.getTime() - now.getTime() < reminderWindowMs |
159 | | - const due = lastSent < prevScheduledAt |
160 | | - const remind = |
161 | | - nextIsSoon && |
162 | | - new Date(recipient.lastRemindedAt ?? 0).getTime() < |
163 | | - prevScheduledAt.getTime() |
164 | | - |
165 | | - if (!due && !remind) continue |
166 | | - |
167 | | - const nextMessage = await prisma.message.findFirst({ |
168 | | - select: { id: true, updatedAt: true }, |
169 | | - where: { recipientId: recipient.id, sentAt: null }, |
170 | | - orderBy: { order: 'asc' }, |
171 | | - }) |
172 | | - |
173 | | - if (!nextMessage && remind) { |
174 | | - const reminderResult = await sendText({ |
175 | | - to: recipient.user.phoneNumber, |
176 | | - // TODO: don't hardcode the domain somehow... |
177 | | - message: `Hello ${recipient.user.name}, you forgot to set up a message for ${recipient.name} and the sending time is coming up.\n\nAdd a thoughtful personal message here: https://www.gratitext.app/recipients/${recipient.id}`, |
178 | | - }) |
179 | | - if (reminderResult.status === 'success') { |
180 | | - await prisma.recipient.update({ |
181 | | - where: { id: recipient.id }, |
182 | | - data: { lastRemindedAt: new Date() }, |
183 | | - }) |
184 | | - reminderSentCount++ |
185 | | - } |
186 | | - } |
187 | | - |
188 | | - // if the message was last updated after the previous time to send then it's |
189 | | - // overdue and we don't send it automatically |
190 | | - const overDueTimeMs = now.getTime() - prevScheduledAt.getTime() |
191 | | - const tooLongOverdue = overDueTimeMs > 1000 * 60 * 10 |
192 | | - const nextMessageWasReady = nextMessage |
193 | | - ? nextMessage.updatedAt < prevScheduledAt |
194 | | - : false |
195 | | - |
196 | | - if (nextMessage && due && nextMessageWasReady && !tooLongOverdue) { |
197 | | - await sendTextToRecipient({ |
198 | | - recipientId: recipient.id, |
199 | | - messageId: nextMessage.id, |
200 | | - }) |
201 | | - dueSentCount++ |
202 | | - } |
203 | | - } |
204 | | - |
205 | | - if (reminderSentCount) console.log(`Sent ${reminderSentCount} reminders`) |
206 | | - if (dueSentCount) console.log(`Sent ${dueSentCount} due texts`) |
207 | | -} |
208 | | - |
209 | 45 | export function getSendTime( |
210 | 46 | scheduleCron: string, |
211 | 47 | options: { tz: string }, |
|
0 commit comments