Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reminder #48

Merged
merged 7 commits into from
Aug 7, 2023
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
45 changes: 22 additions & 23 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
10 changes: 7 additions & 3 deletions src/lib/server/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
36 changes: 36 additions & 0 deletions src/lib/server/login.ts
Original file line number Diff line number Diff line change
@@ -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
}
});
}
148 changes: 146 additions & 2 deletions src/lib/server/twilio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'}`
Expand Down Expand Up @@ -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));
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If Math.random() ever returns exactly 1 (rare but possible), this will fail with out of bounds if it happens on the first iteration

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nevermind, if this is the browser-compliant Math.random() it excludes one. Carry on

[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) {
jho44 marked this conversation as resolved.
Show resolved Hide resolved
// 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({
jho44 marked this conversation as resolved.
Show resolved Hide resolved
where: {
id
},
data: {
reminderDatetime: newReminderDate
}
});
}
});
}
2 changes: 1 addition & 1 deletion src/routes/circle/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@
: 'line-through'}"
>
{parent.firstName}
{parent.lastName}
{parent.lastName ?? ''}
</p>
{/each}
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/routes/household/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -308,15 +308,15 @@
<h4 class="subtitle-3">Sender</h4>
<p class="subtitle-2" style="display: inline; color: black; font-weight: 500;">
{householdInvites[0].fromUser.firstName}
{householdInvites[0].fromUser.lastName}
{householdInvites[0].fromUser.lastName ?? ''}
</p>
<span style="padding: 0 0.5rem; font-size: 15px;"
>({householdInvites[0].fromUser.phone})</span
>

<h4 class="subtitle-3">Kids</h4>
{#each householdInvites[0].household.children as kid}
<p style="font-size: 18px;">{kid.firstName} {kid.lastName}</p>
<p style="font-size: 18px;">{kid.firstName} {kid.lastName ?? ''}</p>
{/each}
</div>

Expand Down Expand Up @@ -375,7 +375,7 @@
<p class="subtitle">Kids</p>
{#each kids as kid, ind}
<div class="card">
<p>{kid.firstName} {kid.lastName}</p>
<p>{kid.firstName} {kid.lastName ?? ''}</p>
<button class="delete-btn" on:click|preventDefault={() => deleteKid(ind)}><hr /></button>
</div>
<hr class="inner-section" />
Expand Down
4 changes: 2 additions & 2 deletions src/routes/household/[householdId]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
<p class="subtitle">Kids</p>
{#each household.kids as kid}
<div class="card">
<p>{kid.firstName} {kid.lastName}</p>
<p>{kid.firstName} {kid.lastName ?? ''}</p>
<p class="small-font">Pronouns: {PRONOUNS[kid.pronouns]}</p>
<p class="small-font">Age: {kid.age}</p>
</div>
Expand All @@ -30,7 +30,7 @@
<p class="subtitle">Adults</p>
{#each household.adults as adult}
<div class="card">
<p>{adult.firstName} {adult.lastName}</p>
<p>{adult.firstName} {adult.lastName ?? ''}</p>
<p class="small-font">Pronouns: {PRONOUNS[adult.pronouns]}</p>
<p class="small-font">
Phone: <a href="tel:{adult.phone}">{adult.phone}</a>
Expand Down
6 changes: 3 additions & 3 deletions src/routes/invites/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@
<p class="household-name">{household.name}</p>
<div>
{#each household.parents as parent}
<p>{parent.firstName} {parent.lastName}</p>
<p>{parent.firstName} {parent.lastName ?? ''}</p>
{/each}
</div>
<a href="tel:{household.phone}">{household.phone}</a>
Expand Down Expand Up @@ -132,12 +132,12 @@
<p class="household-name">{invite.household.name}</p>
<div>
{#each invite.household.parents as parent}
<p>{parent.firstName} {parent.lastName}: {parent.phone}</p>
<p>{parent.firstName} {parent.lastName ?? ''}: {parent.phone}</p>
{/each}
</div>
<div>
{#each invite.household.children as child}
<p>{child.firstName} {child.lastName}</p>
<p>{child.firstName} {child.lastName ?? ''}</p>
{/each}
</div>
<div class="w-full">
Expand Down
Loading