Skip to content
Merged
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
6 changes: 3 additions & 3 deletions app/entry.server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { createReadableStreamFromReadable } from '@react-router/node'
import * as Sentry from '@sentry/react-router'
import chalk from 'chalk'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import { renderToPipeableStream } from 'react-dom/server.node'
import {
type ActionFunctionArgs,
type HandleDocumentRequestFunction,
type LoaderFunctionArgs,
ServerRouter,
} from 'react-router'
import { getSessionRenewal, sessionKey } from './utils/auth.server.ts'
import { init as initCron } from './utils/cron.server.ts'
import { init as initCron } from './utils/cron-runner.server.ts'
import { getEnv, init as initEnv } from './utils/env.server.ts'
import { getInstanceInfo } from './utils/litefs.server.ts'
import { NonceProvider } from './utils/nonce-provider.ts'
Expand Down Expand Up @@ -75,7 +75,7 @@ export default async function handleRequest(...args: DocRequestArgs) {
: 'onShellReady'

const nonce = loadContext.cspNonce?.toString() ?? ''
return new Promise(async (resolve, reject) => {
return new Promise((resolve, reject) => {
let didError = false
// NOTE: this timing will only include things that are rendered in the shell
// and will not include suspended components and deferred loaders
Expand Down
165 changes: 165 additions & 0 deletions app/utils/cron-runner.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { remember } from '@epic-web/remember'
import {
clearIntervalAsync,
setIntervalAsync,
} from 'set-interval-async/dynamic'
import { prisma } from './db.server.ts'
import { getrecipientsforcron } from './prisma-generated.server/sql.ts'
import {
NEXT_SCHEDULE_SENTINEL_DATE,
PREV_SCHEDULE_SENTINEL_DATE,
} from './schedule-constants.server.ts'
import { sendText, sendTextToRecipient } from './text.server.ts'
import { getScheduleWindow } from './cron.server.ts'

Check warning on line 13 in app/utils/cron-runner.server.ts

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

`./cron.server.ts` import should occur before import of `./db.server.ts`

const cronIntervalRef = remember<{
current: ReturnType<typeof setIntervalAsync> | null
}>('cronInterval', () => ({ current: null }))

export async function init() {
console.log('initializing cron interval')
if (cronIntervalRef.current) await clearIntervalAsync(cronIntervalRef.current)

cronIntervalRef.current = setIntervalAsync(
() => sendNextTexts().catch((err) => console.error(err)),
1000 * 5,
)
}

export async function sendNextTexts() {
const now = new Date()
const reminderWindowMs = 1000 * 60 * 30
const reminderCutoff = new Date(now.getTime() + reminderWindowMs)

// Optimized TypedSQL query that:
// 1. Uses INNER JOIN (we filter by User.stripeId, so LEFT JOIN is unnecessary)
// 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId)
// 3. Handles both regular scheduled recipients and those with sentinel dates
// Note: NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff,
// so recipients with invalid schedules won't be included
const recipients = await prisma.$queryRawTyped(
getrecipientsforcron(reminderCutoff),
)

// Transform the raw result to match the expected format
const formattedRecipients = recipients.map((r) => ({
...r,
user: {
phoneNumber: r.userPhoneNumber,
name: r.userName,
},
}))

if (!formattedRecipients.length) return

let dueSentCount = 0
let reminderSentCount = 0
for (const recipient of formattedRecipients) {
let scheduleWindow: {
prevScheduledAt: Date
nextScheduledAt: Date
}
if (
recipient.prevScheduledAt &&
recipient.nextScheduledAt &&
recipient.nextScheduledAt > now &&
recipient.nextScheduledAt.getTime() !==
NEXT_SCHEDULE_SENTINEL_DATE.getTime()
) {
scheduleWindow = {
prevScheduledAt: recipient.prevScheduledAt,
nextScheduledAt: recipient.nextScheduledAt,
}
} else {
try {
scheduleWindow = getScheduleWindow(
recipient.scheduleCron,
recipient.timeZone,
now,
)
} catch (error) {
console.error(
`Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`,
error instanceof Error ? error.message : error,
)
// Update with sentinel dates to prevent repeated processing
await prisma.recipient.update({
where: { id: recipient.id },
data: {
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
},
})
continue
}
}

const { prevScheduledAt, nextScheduledAt } = scheduleWindow
const shouldUpdateSchedule =
!recipient.prevScheduledAt ||
!recipient.nextScheduledAt ||
recipient.prevScheduledAt.getTime() !== prevScheduledAt.getTime() ||
recipient.nextScheduledAt.getTime() !== nextScheduledAt.getTime()

if (shouldUpdateSchedule) {
await prisma.recipient.update({
where: { id: recipient.id },
data: {
prevScheduledAt,
nextScheduledAt,
},
})
}

const lastSent = new Date(recipient.lastSentAt ?? 0)
const nextIsSoon =
nextScheduledAt.getTime() - now.getTime() < reminderWindowMs
const due = lastSent < prevScheduledAt
const remind =
nextIsSoon &&
new Date(recipient.lastRemindedAt ?? 0).getTime() <
prevScheduledAt.getTime()

if (!due && !remind) continue

const nextMessage = await prisma.message.findFirst({
select: { id: true, updatedAt: true },
where: { recipientId: recipient.id, sentAt: null },
orderBy: { order: 'asc' },
})

if (!nextMessage && remind) {
const reminderResult = await sendText({
to: recipient.user.phoneNumber,
// TODO: don't hardcode the domain somehow...
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}`,
})
if (reminderResult.status === 'success') {
await prisma.recipient.update({
where: { id: recipient.id },
data: { lastRemindedAt: new Date() },
})
reminderSentCount++
}
}

// if the message was last updated after the previous time to send then it's
// overdue and we don't send it automatically
const overDueTimeMs = now.getTime() - prevScheduledAt.getTime()
const tooLongOverdue = overDueTimeMs > 1000 * 60 * 10
const nextMessageWasReady = nextMessage
? nextMessage.updatedAt < prevScheduledAt
: false

if (nextMessage && due && nextMessageWasReady && !tooLongOverdue) {
await sendTextToRecipient({
recipientId: recipient.id,
messageId: nextMessage.id,
})
dueSentCount++
}
}

if (reminderSentCount) console.log(`Sent ${reminderSentCount} reminders`)
if (dueSentCount) console.log(`Sent ${dueSentCount} due texts`)
}
3 changes: 2 additions & 1 deletion app/utils/cron.server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
import { faker } from '@faker-js/faker'
import { expect, test } from 'bun:test'
import { createMessage, createRecipient, createUser } from '#tests/db-utils.ts'
import { getScheduleWindow, sendNextTexts } from './cron.server.ts'
import { getScheduleWindow } from './cron.server.ts'
import { sendNextTexts } from './cron-runner.server.ts'

Check warning on line 6 in app/utils/cron.server.test.ts

View workflow job for this annotation

GitHub Actions / ⬣ ESLint

`./cron-runner.server.ts` import should occur before import of `./cron.server.ts`
import { prisma } from './db.server.ts'

test('does not send any texts if there are none to be sent', async () => {
Expand Down
164 changes: 0 additions & 164 deletions app/utils/cron.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,7 @@
* Probably inngest... But we're gonna stick with this for now and see how far
* it gets us.
*/
import { remember } from '@epic-web/remember'
import { CronExpressionParser } from 'cron-parser'
import {
clearIntervalAsync,
setIntervalAsync,
} from 'set-interval-async/dynamic'
import { prisma } from './db.server.ts'
import { getrecipientsforcron } from './prisma-generated.server/sql.ts'
import {
NEXT_SCHEDULE_SENTINEL_DATE,
PREV_SCHEDULE_SENTINEL_DATE,
} from './schedule-constants.server.ts'
import { sendText, sendTextToRecipient } from './text.server.ts'

export class CronParseError extends Error {
constructor(
Expand Down Expand Up @@ -54,158 +42,6 @@ export function getScheduleWindow(
return { prevScheduledAt, nextScheduledAt }
}

const cronIntervalRef = remember<{
current: ReturnType<typeof setIntervalAsync> | null
}>('cronInterval', () => ({ current: null }))

export async function init() {
console.log('initializing cron interval')
if (cronIntervalRef.current) await clearIntervalAsync(cronIntervalRef.current)

cronIntervalRef.current = setIntervalAsync(
() => sendNextTexts().catch((err) => console.error(err)),
1000 * 5,
)
}

export async function sendNextTexts() {
const now = new Date()
const reminderWindowMs = 1000 * 60 * 30
const reminderCutoff = new Date(now.getTime() + reminderWindowMs)

// Optimized TypedSQL query that:
// 1. Uses INNER JOIN (we filter by User.stripeId, so LEFT JOIN is unnecessary)
// 2. Uses the composite index on (verified, disabled, nextScheduledAt, userId)
// 3. Handles both regular scheduled recipients and those with sentinel dates
// Note: NEXT_SCHEDULE_SENTINEL_DATE (9999-12-31) will always be > reminderCutoff,
// so recipients with invalid schedules won't be included
const recipients = await prisma.$queryRawTyped(
getrecipientsforcron(reminderCutoff),
)

// Transform the raw result to match the expected format
const formattedRecipients = recipients.map((r) => ({
...r,
user: {
phoneNumber: r.userPhoneNumber,
name: r.userName,
},
}))

if (!formattedRecipients.length) return

let dueSentCount = 0
let reminderSentCount = 0
for (const recipient of formattedRecipients) {
let scheduleWindow: {
prevScheduledAt: Date
nextScheduledAt: Date
}
if (
recipient.prevScheduledAt &&
recipient.nextScheduledAt &&
recipient.nextScheduledAt > now &&
recipient.nextScheduledAt.getTime() !==
NEXT_SCHEDULE_SENTINEL_DATE.getTime()
) {
scheduleWindow = {
prevScheduledAt: recipient.prevScheduledAt,
nextScheduledAt: recipient.nextScheduledAt,
}
} else {
try {
scheduleWindow = getScheduleWindow(
recipient.scheduleCron,
recipient.timeZone,
now,
)
} catch (error) {
console.error(
`Invalid cron string "${recipient.scheduleCron}" for recipient ${recipient.id}:`,
error instanceof Error ? error.message : error,
)
// Update with sentinel dates to prevent repeated processing
await prisma.recipient.update({
where: { id: recipient.id },
data: {
prevScheduledAt: PREV_SCHEDULE_SENTINEL_DATE,
nextScheduledAt: NEXT_SCHEDULE_SENTINEL_DATE,
},
})
continue
}
}

const { prevScheduledAt, nextScheduledAt } = scheduleWindow
const shouldUpdateSchedule =
!recipient.prevScheduledAt ||
!recipient.nextScheduledAt ||
recipient.prevScheduledAt.getTime() !== prevScheduledAt.getTime() ||
recipient.nextScheduledAt.getTime() !== nextScheduledAt.getTime()

if (shouldUpdateSchedule) {
await prisma.recipient.update({
where: { id: recipient.id },
data: {
prevScheduledAt,
nextScheduledAt,
},
})
}

const lastSent = new Date(recipient.lastSentAt ?? 0)
const nextIsSoon =
nextScheduledAt.getTime() - now.getTime() < reminderWindowMs
const due = lastSent < prevScheduledAt
const remind =
nextIsSoon &&
new Date(recipient.lastRemindedAt ?? 0).getTime() <
prevScheduledAt.getTime()

if (!due && !remind) continue

const nextMessage = await prisma.message.findFirst({
select: { id: true, updatedAt: true },
where: { recipientId: recipient.id, sentAt: null },
orderBy: { order: 'asc' },
})

if (!nextMessage && remind) {
const reminderResult = await sendText({
to: recipient.user.phoneNumber,
// TODO: don't hardcode the domain somehow...
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}`,
})
if (reminderResult.status === 'success') {
await prisma.recipient.update({
where: { id: recipient.id },
data: { lastRemindedAt: new Date() },
})
reminderSentCount++
}
}

// if the message was last updated after the previous time to send then it's
// overdue and we don't send it automatically
const overDueTimeMs = now.getTime() - prevScheduledAt.getTime()
const tooLongOverdue = overDueTimeMs > 1000 * 60 * 10
const nextMessageWasReady = nextMessage
? nextMessage.updatedAt < prevScheduledAt
: false

if (nextMessage && due && nextMessageWasReady && !tooLongOverdue) {
await sendTextToRecipient({
recipientId: recipient.id,
messageId: nextMessage.id,
})
dueSentCount++
}
}

if (reminderSentCount) console.log(`Sent ${reminderSentCount} reminders`)
if (dueSentCount) console.log(`Sent ${dueSentCount} due texts`)
}

export function getSendTime(
scheduleCron: string,
options: { tz: string },
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@
"test:browser": "bunx --bun vitest",
"coverage": "bunx --bun vitest run --coverage",
"test:e2e": "bun run test:e2e:dev --silent",
"test:e2e:dev": "playwright test --ui",
"test:e2e:dev": "bunx playwright test --ui",
"pretest:e2e:run": "bun run build",
"test:e2e:run": "cross-env CI=true playwright test",
"test:e2e:run": "cross-env CI=true bunx playwright test",
"test:e2e:install": "bunx playwright install --with-deps chromium",
"typecheck": "react-router typegen && tsc",
"benchmark:data": "bun ./scripts/benchmark-data-load.ts",
Expand Down
Loading
Loading