diff --git a/app/entry.server.tsx b/app/entry.server.tsx index 249cde0..e63f4c3 100644 --- a/app/entry.server.tsx +++ b/app/entry.server.tsx @@ -3,7 +3,7 @@ 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, @@ -11,7 +11,7 @@ import { 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' @@ -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 diff --git a/app/utils/cron-runner.server.ts b/app/utils/cron-runner.server.ts new file mode 100644 index 0000000..ae2076a --- /dev/null +++ b/app/utils/cron-runner.server.ts @@ -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' + +const cronIntervalRef = remember<{ + current: ReturnType | 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`) +} diff --git a/app/utils/cron.server.test.ts b/app/utils/cron.server.test.ts index 4a3fcda..fe84610 100644 --- a/app/utils/cron.server.test.ts +++ b/app/utils/cron.server.test.ts @@ -2,7 +2,8 @@ import '#tests/setup/setup-test-env.ts' 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' import { prisma } from './db.server.ts' test('does not send any texts if there are none to be sent', async () => { diff --git a/app/utils/cron.server.ts b/app/utils/cron.server.ts index deee633..481aad3 100644 --- a/app/utils/cron.server.ts +++ b/app/utils/cron.server.ts @@ -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( @@ -54,158 +42,6 @@ export function getScheduleWindow( return { prevScheduledAt, nextScheduledAt } } -const cronIntervalRef = remember<{ - current: ReturnType | 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 }, diff --git a/package.json b/package.json index 208896c..1358de9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/tests/setup/global-setup.ts b/tests/setup/global-setup.ts index 14b7b13..47e798e 100644 --- a/tests/setup/global-setup.ts +++ b/tests/setup/global-setup.ts @@ -3,7 +3,6 @@ import { execaCommand } from 'execa' import fsExtra from 'fs-extra' import 'dotenv/config' import '#app/utils/env.server.ts' -import '#app/utils/cache.server.ts' export const BASE_DATABASE_PATH = path.join( process.cwd(),