diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 19484cb..f1c4edc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -31,26 +31,25 @@ jobs: options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 steps: - - name: Checkout repository - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - cache: 'yarn' - - - name: Install dependencies - run: yarn install --frozen-lockfile - - - name: Run postinstall (db stuff) - run: yarn postinstall - - - name: Build in node mode - run: yarn nodebuild - - - name: Install playwright & dependencies - run: yarn exec playwright install --with-deps - - - name: Run tests - run: yarn test - + - name: Checkout repository + uses: actions/checkout@v3 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} + cache: 'yarn' + + - name: Install dependencies + run: yarn install --frozen-lockfile + + - name: Run postinstall (db stuff) + run: yarn postinstall + + - name: Build in node mode + run: yarn nodebuild + + - name: Install playwright & dependencies + run: yarn exec playwright install --with-deps + + - name: Run tests + run: yarn test diff --git a/package.json b/package.json index 656476c..38d5d3e 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "test:unit": "vitest", "lint": "prettier --plugin-search-dir . --check . && eslint .", "format": "prettier --plugin-search-dir . --write .", - "reset": "prisma migrate reset && yarn dev", + "reset": "prisma migrate reset && yarn dev", "postinstall": "prisma migrate dev", "build:prev": "vite build && vite preview", "seed": "prisma db seed" diff --git a/prisma/seed.ts b/prisma/seed.ts index cb5c436..440e866 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -94,6 +94,19 @@ async function main() { .deleteMany() .catch(() => console.log('No friend request table to delete')); + const expiredLink = { + token: '3e99472f1003794c', + phone: '+12015550121', + expires: new Date('8/5/2020') + }; + await prisma.magicLink.upsert({ + where: { + id: 1 + }, + update: expiredLink, + create: expiredLink + }); + // User 1 await prisma.user.upsert({ where: { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 5cb8b3d..0a2aa14 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,11 +1,18 @@ import type { Handle, RequestEvent } from '@sveltejs/kit'; import { PrismaClient } from '@prisma/client'; import type { User, PhoneContactPermissions } from '@prisma/client'; +import * as cron from 'node-cron'; +import { sendNotif } from '$lib/server/twilio'; + const prisma = new PrismaClient(); import { redirect } from '@sveltejs/kit'; import type { MaybePromise, ResolveOptions } from '@sveltejs/kit/types/internal'; +cron.schedule('*/1 * * * *', function () { + sendNotif(); +}); + const setLocal = async ( user: (User & { phonePermissions: PhoneContactPermissions }) | null, phone: string, diff --git a/src/lib/server/db.ts b/src/lib/server/db.ts index 2fe1945..258cc7b 100644 --- a/src/lib/server/db.ts +++ b/src/lib/server/db.ts @@ -423,10 +423,14 @@ async function saveUser( allowInvites, allowReminders } = req; + // Get the current date in the user's timezone + const userLocalDate = new Date().toLocaleString('en-US', { timeZone }); - const d = new Date(); - const day = d.getDay(); - const diff = d.getDate() - day + notifStartDay; + // Convert the user's date to a JavaScript Date object + const d = new Date(userLocalDate); + + // Calculate the desired date based on the user's timezone + const diff = d.getDate() - d.getDay() + notifStartDay; d.setDate(diff); d.setHours(notifHr); d.setMinutes(notifMin); diff --git a/src/lib/server/login.ts b/src/lib/server/login.ts new file mode 100644 index 0000000..bdc502f --- /dev/null +++ b/src/lib/server/login.ts @@ -0,0 +1,36 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export const generate = async () => { + const createdAt = new Date(); + const expires = new Date(); + expires.setHours(createdAt.getHours() + 1); + + let crypto; + try { + crypto = await import('node:crypto'); + } catch (err) { + console.error('crypto support is disabled!'); + return { + token: null + }; + } + const token = crypto.randomBytes(8).toString('hex'); + return { + token, + createdAt, + expires + }; +}; + +export async function save(token: string, phone: string, createdAt: Date, expires: Date) { + await prisma.magicLink.create({ + data: { + token, + phone, + expires, + createdAt + } + }); +} diff --git a/src/lib/server/twilio.ts b/src/lib/server/twilio.ts index 941b702..494aad7 100644 --- a/src/lib/server/twilio.ts +++ b/src/lib/server/twilio.ts @@ -4,6 +4,7 @@ import { error, json } from '@sveltejs/kit'; import Twilio from 'twilio'; import { PrismaClient, type User } from '@prisma/client'; import { circleNotif } from './sanitize'; +import { generate, save } from './login'; const prisma = new PrismaClient(); const MessagingResponse = Twilio.twiml.MessagingResponse; @@ -94,12 +95,46 @@ const msgToSend = async ( msg = `Thanks for subscribing to reminders and friend availability notifications from ${url}! You can disable this at any time on your Profile page or by responding STOP.`; break; } + case 'reminder': { + const { phone } = msgComps; + const { token, createdAt, expires } = await generate(); + + if (!token) { + console.error('token generation failed'); + throw error(500, { + message: 'Token generation failed' + }); + } + + // save these attrs to DB + save(token, phone, createdAt, expires) + .then(async () => { + await prisma.$disconnect(); + }) + .catch(async (e) => { + console.error(e); + await prisma.$disconnect(); + process.exit(1); + }); + + msg = `Hi! It's your periodic reminder to update your schedule: ${url}/login/${phone.slice( + 1 + )}/${token}`; + break; + } + default: + throw error(400, { + message: `Message type ${type} not supported` + }); } return msg; }; -export const sendMsg = async (request: Request, initiator: User | null) => { - const { phone, sendAt, type, ...rest } = await request.json(); +export const sendMsg = async ( + request: { phone: string; sendAt?: Date; type: string }, + initiator: User | null +) => { + const { phone, sendAt, type, ...rest } = request; if (!phone || !type) { throw error(400, { message: `Missing a ${!phone ? 'phone number' : 'type of message to send'}` @@ -229,3 +264,112 @@ export const getMsg = async (url: URL) => { return response; }; + +function shuffleArr(arr: any[]) { + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } +} + +export async function sendNotif() { + const nowLocal = new Date(); + const users = await prisma.user.findMany({ + select: { + id: true, + phone: true, + reminderIntervalDays: true, + timeZone: true, + phonePermissions: { + select: { + allowReminders: true, + blocked: true + } + } + } + }); + + // randomize the order of 'users' + shuffleArr(users); + + users.forEach(async (user) => { + const { id, phone, reminderIntervalDays, phonePermissions, timeZone } = user; + const { allowReminders, blocked } = phonePermissions; + if (!allowReminders || blocked) return; + + const options = { + timeZone + }; + + const formattedDate = nowLocal.toLocaleString('en-US', options); + const now = new Date(formattedDate); + + // sleep for a random amount of time between 1 and 2 seconds prior to each message. + const min = 1000; + const max = 2000; + await new Promise((r) => setTimeout(r, Math.floor(Math.random() * (max - min + 1)) + min)); + + // Re-query the database for the reminderDateTime before the time comparison logic. + // Using the cached data from the initial query greatly increases the odds of duplicate messages. + const userRequery = await prisma.user.findUnique({ + where: { + phone + }, + select: { + reminderDatetime: true + } + }); + + if (!userRequery) + throw error(500, { + message: `Couldn't requery user with phone ${phone}` + }); + + const { reminderDatetime } = userRequery; + + // It would be better to send the notifications late than never. + if (reminderDatetime < now) { + const sameDay = new Date(now); + sameDay.setDate(reminderDatetime.getDate()); + + const diff = Math.abs(sameDay.getTime() - reminderDatetime.getTime()) / (1000 * 60); + + if (diff <= 30) { + await sendMsg({ phone, type: 'reminder' }, null); + + // update reminder date for next notif -- x days from today + const newReminderDate = new Date(now); + newReminderDate.setDate(newReminderDate.getDate() + reminderIntervalDays); + await prisma.user.update({ + where: { + id + }, + data: { + reminderDatetime: newReminderDate + } + }); + } + return; + } + + const timeDifference = Math.abs(now.getTime() - reminderDatetime.getTime()); // Get the absolute time difference in milliseconds + const minuteInMillis = 60 * 1000; // 1 minute in milliseconds + if (timeDifference < minuteInMillis) { + // currently within a minute of when user should be reminded + // send notif + await sendMsg({ phone, type: 'reminder' }, null); + + // update reminder date for next notif + const newReminderDate = new Date(reminderDatetime); + newReminderDate.setDate(reminderDatetime.getDate() + reminderIntervalDays); + await prisma.user.update({ + where: { + id + }, + data: { + reminderDatetime: newReminderDate + } + }); + } + }); +} diff --git a/src/routes/circle/+page.svelte b/src/routes/circle/+page.svelte index 4dcb2ee..160fa6d 100644 --- a/src/routes/circle/+page.svelte +++ b/src/routes/circle/+page.svelte @@ -104,7 +104,7 @@ : 'line-through'}" > {parent.firstName} - {parent.lastName} + {parent.lastName ?? ''}

{/each} diff --git a/src/routes/household/+page.svelte b/src/routes/household/+page.svelte index c3147df..75717d0 100644 --- a/src/routes/household/+page.svelte +++ b/src/routes/household/+page.svelte @@ -308,7 +308,7 @@

Sender

{householdInvites[0].fromUser.firstName} - {householdInvites[0].fromUser.lastName} + {householdInvites[0].fromUser.lastName ?? ''}

({householdInvites[0].fromUser.phone})Kids {#each householdInvites[0].household.children as kid} -

{kid.firstName} {kid.lastName}

+

{kid.firstName} {kid.lastName ?? ''}

{/each} @@ -375,7 +375,7 @@

Kids

{#each kids as kid, ind}
-

{kid.firstName} {kid.lastName}

+

{kid.firstName} {kid.lastName ?? ''}


diff --git a/src/routes/household/[householdId]/+page.svelte b/src/routes/household/[householdId]/+page.svelte index 75f3989..768d51b 100644 --- a/src/routes/household/[householdId]/+page.svelte +++ b/src/routes/household/[householdId]/+page.svelte @@ -21,7 +21,7 @@

Kids

{#each household.kids as kid}
-

{kid.firstName} {kid.lastName}

+

{kid.firstName} {kid.lastName ?? ''}

Pronouns: {PRONOUNS[kid.pronouns]}

Age: {kid.age}

@@ -30,7 +30,7 @@

Adults

{#each household.adults as adult}
-

{adult.firstName} {adult.lastName}

+

{adult.firstName} {adult.lastName ?? ''}

Pronouns: {PRONOUNS[adult.pronouns]}

Phone: {adult.phone} diff --git a/src/routes/invites/+page.svelte b/src/routes/invites/+page.svelte index 8af252d..b98729d 100644 --- a/src/routes/invites/+page.svelte +++ b/src/routes/invites/+page.svelte @@ -80,7 +80,7 @@

{household.name}

{#each household.parents as parent} -

{parent.firstName} {parent.lastName}

+

{parent.firstName} {parent.lastName ?? ''}

{/each}
{household.phone} @@ -132,12 +132,12 @@

{invite.household.name}

{#each invite.household.parents as parent} -

{parent.firstName} {parent.lastName}: {parent.phone}

+

{parent.firstName} {parent.lastName ?? ''}: {parent.phone}

{/each}
{#each invite.household.children as child} -

{child.firstName} {child.lastName}

+

{child.firstName} {child.lastName ?? ''}

{/each}
diff --git a/src/routes/login/+server.ts b/src/routes/login/+server.ts index a998c83..bf3cf0a 100644 --- a/src/routes/login/+server.ts +++ b/src/routes/login/+server.ts @@ -1,42 +1,10 @@ +import { generate, save } from '$lib/server/login'; import { PrismaClient } from '@prisma/client'; const prisma = new PrismaClient({ log: ['query', 'info', 'warn', 'error'] }); -const generate = async () => { - const createdAt = new Date(); - const expires = new Date(); - expires.setHours(createdAt.getHours() + 1); - - let crypto; - try { - crypto = await import('node:crypto'); - } catch (err) { - console.error('crypto support is disabled!'); - return { - token: null - }; - } - const token = crypto.randomBytes(8).toString('hex'); - return { - token, - createdAt, - expires - }; -}; - -async function save(token: string, phone: string, createdAt: Date, expires: Date) { - await prisma.magicLink.create({ - data: { - token, - phone, - expires, - createdAt - } - }); -} - export async function POST({ request }: { request: Request }) { const { phone } = await request.json(); if (!phone) { diff --git a/src/routes/login/[phone]/[token]/+page.server.ts b/src/routes/login/[phone]/[token]/+page.server.ts index 7268c3d..1db3bdf 100644 --- a/src/routes/login/[phone]/[token]/+page.server.ts +++ b/src/routes/login/[phone]/[token]/+page.server.ts @@ -14,28 +14,16 @@ export const load = (async ({ params, cookies }) => { } }); } catch { - return new Response( - JSON.stringify({ - message: "Can't verify token" - }), - { - status: 403 - } - ); + console.error("Can't verify token"); + throw redirect(308, `/?phone=${params.phone}`); } // check DB's expiration date const { phone, expires } = magicLinkInfo as { phone: string; expires: Date }; if (expires < new Date()) { - return new Response( - JSON.stringify({ - message: 'Token has expired' - }), - { - status: 403 - } - ); + console.error('Token has expired'); + throw redirect(308, `/?phone=${params.phone}`); } let crypto; @@ -43,9 +31,7 @@ export const load = (async ({ params, cookies }) => { crypto = await import('node:crypto'); } catch (err) { console.error('crypto support is disabled!'); - return { - token: null - }; + throw redirect(308, `/?phone=${params.phone}`); } const sessionCreatedAt = new Date(); diff --git a/src/routes/reminder/+server.ts b/src/routes/reminder/+server.ts deleted file mode 100644 index 475da70..0000000 --- a/src/routes/reminder/+server.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -import { json } from '@sveltejs/kit'; -import * as cron from 'node-cron'; - -const prisma = new PrismaClient(); - -export function POST({ fetch }: { fetch: any }) { - /** - goes through each user in the db - If it's time to send them a notif, we'll just do so right there. No need to schedule it. - */ - async function sendNotif() { - const nowLocal = new Date(); - const users = await prisma.user.findMany({ - select: { - id: true, - phone: true, - reminderDatetime: true, - reminderIntervalDays: true, - timeZone: true, - phonePermissions: { - select: { - allowReminders: true, - blocked: true - } - } - } - }); - users.forEach(async (user) => { - const { id, phone, reminderDatetime, reminderIntervalDays, phonePermissions, timeZone } = - user; - const { allowReminders, blocked } = phonePermissions; - if (!allowReminders || blocked) return; - - const options = { - timeZone - }; - - const formattedDate = nowLocal.toLocaleString('en-US', options); - const now = new Date(formattedDate); - const timeDifference = Math.abs(now.getTime() - reminderDatetime.getTime()); // Get the absolute time difference in milliseconds - const minuteInMillis = 60 * 1000; // 1 minute in milliseconds - if (timeDifference < minuteInMillis) { - // currently within a minute of when user should be reminded - // send notif - await fetch('/twilio', { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - msg: `Hi! It's your periodic reminder to update your schedule: https://playdate.help/login/${phone}`, - phone - }) - }); - - // update reminder date for next notif - const newReminderDate = new Date(reminderDatetime); - newReminderDate.setDate(reminderDatetime.getDate() + reminderIntervalDays); - await prisma.user.update({ - where: { - id - }, - data: { - reminderDatetime: newReminderDate - } - }); - } - }); - } - - cron.schedule('*/1 * * * *', function () { - sendNotif(); - }); - - return json('ok'); -} diff --git a/src/routes/twilio/+server.ts b/src/routes/twilio/+server.ts index a89dbc2..3850525 100644 --- a/src/routes/twilio/+server.ts +++ b/src/routes/twilio/+server.ts @@ -10,8 +10,7 @@ export async function POST({ }) { const sessionToken = cookies.get('session'); const { user } = await getProfileFromSession(sessionToken); - - return sendMsg(request, user); + return sendMsg(await request.json(), user); } export function GET({ url }: { url: URL }) { diff --git a/tests/login.spec.ts b/tests/login.spec.ts new file mode 100644 index 0000000..0146306 --- /dev/null +++ b/tests/login.spec.ts @@ -0,0 +1,17 @@ +import { test, expect } from '@playwright/test'; +import { run } from '../prisma/seed'; + +const host = 'http://localhost:5173'; + +test.beforeEach(async () => { + await run(); +}); + +test.only('Redirect to login page w/ prefilled phone num on expired magic link', async ({ + page +}) => { + await page.goto('http://localhost:5173/login/12015550121/3e99472f1003794c'); + + await page.waitForURL(`${host}?phone=12015550121`, { waitUntil: 'networkidle' }); + await expect(page).toHaveURL(`${host}?phone=12015550121`); +});