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 @@{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 ?? ''}
Kids
{#each household.kids as kid}{kid.firstName} {kid.lastName}
+{kid.firstName} {kid.lastName ?? ''}
Pronouns: {PRONOUNS[kid.pronouns]}
Age: {kid.age}
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}
{parent.firstName} {parent.lastName}
+{parent.firstName} {parent.lastName ?? ''}
{/each}{invite.household.name}
{parent.firstName} {parent.lastName}: {parent.phone}
+{parent.firstName} {parent.lastName ?? ''}: {parent.phone}
{/each}{child.firstName} {child.lastName}
+{child.firstName} {child.lastName ?? ''}
{/each}