Skip to content

Commit 68f4b0f

Browse files
Move cron runner to separate module
Co-authored-by: me <me@kentcdodds.com>
1 parent a7f58d0 commit 68f4b0f

4 files changed

Lines changed: 168 additions & 166 deletions

File tree

app/entry.server.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
ServerRouter,
1212
} from 'react-router'
1313
import { getSessionRenewal, sessionKey } from './utils/auth.server.ts'
14-
import { init as initCron } from './utils/cron.server.ts'
14+
import { init as initCron } from './utils/cron-runner.server.ts'
1515
import { getEnv, init as initEnv } from './utils/env.server.ts'
1616
import { getInstanceInfo } from './utils/litefs.server.ts'
1717
import { NonceProvider } from './utils/nonce-provider.ts'

app/utils/cron-runner.server.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { remember } from '@epic-web/remember'
2+
import {
3+
clearIntervalAsync,
4+
setIntervalAsync,
5+
} from 'set-interval-async/dynamic'
6+
import { prisma } from './db.server.ts'
7+
import { getrecipientsforcron } from './prisma-generated.server/sql.ts'
8+
import {
9+
NEXT_SCHEDULE_SENTINEL_DATE,
10+
PREV_SCHEDULE_SENTINEL_DATE,
11+
} from './schedule-constants.server.ts'
12+
import { sendText, sendTextToRecipient } from './text.server.ts'
13+
import { getScheduleWindow } from './cron.server.ts'
14+
15+
const cronIntervalRef = remember<{
16+
current: ReturnType<typeof setIntervalAsync> | null
17+
}>('cronInterval', () => ({ current: null }))
18+
19+
export async function init() {
20+
console.log('initializing cron interval')
21+
if (cronIntervalRef.current) await clearIntervalAsync(cronIntervalRef.current)
22+
23+
cronIntervalRef.current = setIntervalAsync(
24+
() => sendNextTexts().catch((err) => console.error(err)),
25+
1000 * 5,
26+
)
27+
}
28+
29+
export async function sendNextTexts() {
30+
const now = new Date()
31+
const reminderWindowMs = 1000 * 60 * 30
32+
const reminderCutoff = new Date(now.getTime() + reminderWindowMs)
33+
34+
// Optimized TypedSQL query that:
35+
// 1. Uses INNER JOIN (we filter by User.stripeId, so LEFT JOIN is unnecessary)
36+
// 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId)
37+
// 3. Handles both regular scheduled recipients and those with sentinel dates
38+
// Note: NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff,
39+
// so recipients with invalid schedules won't be included
40+
const recipients = await prisma.$queryRawTyped(
41+
getrecipientsforcron(reminderCutoff),
42+
)
43+
44+
// Transform the raw result to match the expected format
45+
const formattedRecipients = recipients.map((r) => ({
46+
...r,
47+
user: {
48+
phoneNumber: r.userPhoneNumber,
49+
name: r.userName,
50+
},
51+
}))
52+
53+
if (!formattedRecipients.length) return
54+
55+
let dueSentCount = 0
56+
let reminderSentCount = 0
57+
for (const recipient of formattedRecipients) {
58+
let scheduleWindow: {
59+
prevScheduledAt: Date
60+
nextScheduledAt: Date
61+
}
62+
if (
63+
recipient.prevScheduledAt &&
64+
recipient.nextScheduledAt &&
65+
recipient.nextScheduledAt > now &&
66+
recipient.nextScheduledAt.getTime() !==
67+
NEXT_SCHEDULE_SENTINEL_DATE.getTime()
68+
) {
69+
scheduleWindow = {
70+
prevScheduledAt: recipient.prevScheduledAt,
71+
nextScheduledAt: recipient.nextScheduledAt,
72+
}
73+
} else {
74+
try {
75+
scheduleWindow = getScheduleWindow(
76+
recipient.scheduleCron,
77+
recipient.timeZone,
78+
now,
79+
)
80+
} catch (error) {
81+
console.error(
82+
`Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`,
83+
error instanceof Error ? error.message : error,
84+
)
85+
// Update with sentinel dates to prevent repeated processing
86+
await prisma.recipient.update({
87+
where: { id: recipient.id },
88+
data: {
89+
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
90+
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
91+
},
92+
})
93+
continue
94+
}
95+
}
96+
97+
const { prevScheduledAt, nextScheduledAt } = scheduleWindow
98+
const shouldUpdateSchedule =
99+
!recipient.prevScheduledAt ||
100+
!recipient.nextScheduledAt ||
101+
recipient.prevScheduledAt.getTime() !== prevScheduledAt.getTime() ||
102+
recipient.nextScheduledAt.getTime() !== nextScheduledAt.getTime()
103+
104+
if (shouldUpdateSchedule) {
105+
await prisma.recipient.update({
106+
where: { id: recipient.id },
107+
data: {
108+
prevScheduledAt,
109+
nextScheduledAt,
110+
},
111+
})
112+
}
113+
114+
const lastSent = new Date(recipient.lastSentAt ?? 0)
115+
const nextIsSoon =
116+
nextScheduledAt.getTime() - now.getTime() < reminderWindowMs
117+
const due = lastSent < prevScheduledAt
118+
const remind =
119+
nextIsSoon &&
120+
new Date(recipient.lastRemindedAt ?? 0).getTime() <
121+
prevScheduledAt.getTime()
122+
123+
if (!due && !remind) continue
124+
125+
const nextMessage = await prisma.message.findFirst({
126+
select: { id: true, updatedAt: true },
127+
where: { recipientId: recipient.id, sentAt: null },
128+
orderBy: { order: 'asc' },
129+
})
130+
131+
if (!nextMessage && remind) {
132+
const reminderResult = await sendText({
133+
to: recipient.user.phoneNumber,
134+
// TODO: don't hardcode the domain somehow...
135+
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}`,
136+
})
137+
if (reminderResult.status === 'success') {
138+
await prisma.recipient.update({
139+
where: { id: recipient.id },
140+
data: { lastRemindedAt: new Date() },
141+
})
142+
reminderSentCount++
143+
}
144+
}
145+
146+
// if the message was last updated after the previous time to send then it's
147+
// overdue and we don't send it automatically
148+
const overDueTimeMs = now.getTime() - prevScheduledAt.getTime()
149+
const tooLongOverdue = overDueTimeMs > 1000 * 60 * 10
150+
const nextMessageWasReady = nextMessage
151+
? nextMessage.updatedAt < prevScheduledAt
152+
: false
153+
154+
if (nextMessage && due && nextMessageWasReady && !tooLongOverdue) {
155+
await sendTextToRecipient({
156+
recipientId: recipient.id,
157+
messageId: nextMessage.id,
158+
})
159+
dueSentCount++
160+
}
161+
}
162+
163+
if (reminderSentCount) console.log(`Sent ${reminderSentCount} reminders`)
164+
if (dueSentCount) console.log(`Sent ${dueSentCount} due texts`)
165+
}

app/utils/cron.server.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import '#tests/setup/setup-test-env.ts'
22
import { faker } from '@faker-js/faker'
33
import { expect, test } from 'bun:test'
44
import { createMessage, createRecipient, createUser } from '#tests/db-utils.ts'
5-
import { getScheduleWindow, sendNextTexts } from './cron.server.ts'
5+
import { getScheduleWindow } from './cron.server.ts'
6+
import { sendNextTexts } from './cron-runner.server.ts'
67
import { prisma } from './db.server.ts'
78

89
test('does not send any texts if there are none to be sent', async () => {

app/utils/cron.server.ts

Lines changed: 0 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,7 @@
33
* Probably inngest... But we're gonna stick with this for now and see how far
44
* it gets us.
55
*/
6-
import { remember } from '@epic-web/remember'
76
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'
197

208
export class CronParseError extends Error {
219
constructor(
@@ -54,158 +42,6 @@ export function getScheduleWindow(
5442
return { prevScheduledAt, nextScheduledAt }
5543
}
5644

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-
20945
export function getSendTime(
21046
scheduleCron: string,
21147
options: { tz: string },

0 commit comments

Comments
 (0)