73 batch sms messaging #85

Merged
BenjaminPalko merged 3 commits from 73-batch-sms-messaging into master 2025-02-24 10:48:53 -05:00
7 changed files with 197 additions and 89 deletions

View file

@ -11,7 +11,7 @@
error?: string | Snippet;
label?: string | Snippet;
resizable?: boolean | 'yes' | 'no' | 'x' | 'y';
size?: DaisySize;
size?: DaisySize | 'md';
} & SvelteHTMLElements['textarea'];
let {
bordered,
@ -25,7 +25,7 @@
}: Props = $props();
</script>
<label class="form-control w-full max-w-lg">
<label class="form-control w-full">
<div class="label">
<span
class="label-text flex items-center gap-2"

View file

@ -0,0 +1,64 @@
<script lang="ts" module>
export type Recipient = {
id: string;
name: string;
phone: string;
};
</script>
<script lang="ts">
import clsx from 'clsx';
import type { SvelteHTMLElements } from 'svelte/elements';
import { twMerge } from 'tailwind-merge';
type Props = {
recipients: Recipient[];
selected: Recipient[];
} & Omit<SvelteHTMLElements['table'], 'children'>;
let { recipients, selected = $bindable([]), class: className, ...props }: Props = $props();
let checked = $state(recipients.map(() => false));
$effect(() => {
selected = checked
.entries()
.filter(([, val]) => val)
.map(([index]) => recipients[index])
.toArray();
});
</script>
<table {...props} class={twMerge('table', clsx(className))}>
<thead>
<tr>
<th>
<input
class="checkbox"
type="checkbox"
onchange={({ currentTarget }) => {
checked = checked.map(() => currentTarget.checked);
}}
/>
</th>
<th> Name </th>
DanMihailescu commented 2025-02-18 16:37:18 -05:00 (Migrated from github.com)
Review

"Unit #: First Name" or just "Unit #" would work better for more user privacy (admin of condo shouldnt have access to personal data).

"Unit #: First Name" or just "Unit #" would work better for more user privacy (admin of condo shouldnt have access to personal data).
BenjaminPalko commented 2025-02-19 10:19:41 -05:00 (Migrated from github.com)
Review

Its fine that they can see their names, its not a privacy concern. We also dont have tables for units/residences

Its fine that they can see their names, its not a privacy concern. We also dont have tables for units/residences
</tr>
</thead>
<tbody>
{#each recipients as resident, index}
<tr>
<th
><input
class="checkbox"
type="checkbox"
checked={checked[index]}
onchange={({ currentTarget }) => {
checked[index] = currentTarget.checked;
}}
/></th
>
<td>{resident.name}</td>
</tr>
{/each}
</tbody>
</table>

View file

@ -0,0 +1,2 @@
export * from './RecipientList.svelte';
export { default as RecipientList } from './RecipientList.svelte';

View file

@ -0,0 +1,20 @@
import type { TwilioConfig } from '@prisma/client';
import { decrypt, encrypt } from '../crypto';
type TwilioCore = Pick<TwilioConfig, 'accountSID' | 'authToken' | 'phoneNumber'>;
export function encryptTwilioConfig({ accountSID, authToken, phoneNumber }: TwilioCore) {
return {
accountSID: encrypt(accountSID),
authToken: encrypt(authToken),
phoneNumber: encrypt(phoneNumber),
};
}
export function decryptTwilioConfig({ accountSID, authToken, phoneNumber }: TwilioCore) {
return {
accountSID: decrypt(accountSID),
authToken: decrypt(authToken),
phoneNumber: decrypt(phoneNumber),
};
}

View file

@ -1,7 +1,7 @@
import { PhoneRegex } from '$lib/regex';
import { encrypt } from '$lib/server/crypto/encryption.js';
import { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma';
import { encryptTwilioConfig, decryptTwilioConfig } from '$lib/server/twilio';
import { fail, type Actions } from '@sveltejs/kit';
import zod from 'zod';
@ -21,8 +21,16 @@ export const load = async (event) => {
},
});
if (!configs) {
return {};
}
return {
configs: configs,
configs: {
...(configs.twilioConfig && {
twilioConfig: decryptTwilioConfig(configs.twilioConfig),
}),
},
DanMihailescu commented 2025-02-18 16:31:14 -05:00 (Migrated from github.com)
Review

Should return something; maybe an error page redirect

Should return something; maybe an error page redirect
BenjaminPalko commented 2025-02-19 10:14:32 -05:00 (Migrated from github.com)
Review

Nah, the page handles this. If the config is missing it displays a message

Nah, the page handles this. If the config is missing it displays a message
};
};
@ -66,28 +74,32 @@ export const actions = {
create: {
tenantId: tenantId,
twilioConfig: {
create: {
accountSID: encrypt(accountSID),
authToken: encrypt(authToken),
phoneNumber: encrypt(phoneNumber),
},
create: encryptTwilioConfig({
accountSID: accountSID,
authToken: authToken,
phoneNumber: phoneNumber,
}),
},
},
update: {
tenantId: tenantId,
twilioConfig: {
update: {
update: encryptTwilioConfig({
accountSID: accountSID,
authToken: authToken,
phoneNumber: phoneNumber,
},
}),
},
},
select: { twilioConfig: true },
});
return {
configs: configs,
configs: {
...(configs.twilioConfig && {
twilioConfig: decryptTwilioConfig(configs.twilioConfig),
}),
},
};
},
} satisfies Actions;

View file

@ -1,6 +1,7 @@
import { PhoneRegex } from '$lib/regex';
import type { Recipient } from '$lib/components/SMS';
import { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma/index.js';
import { decryptTwilioConfig } from '$lib/server/twilio/index.js';
import { fail, type Actions } from '@sveltejs/kit';
import twilio from 'twilio';
import zod from 'zod';
@ -24,6 +25,8 @@ export const load = async (event) => {
const { success, error: validationError } = zod
.object({
accountSID: zod.string(),
authToken: zod.string(),
phoneNumber: zod.string(),
})
.safeParse(configs?.twilioConfig);
@ -31,8 +34,20 @@ export const load = async (event) => {
logger.warn(validationError.message);
}
const residents = await prisma.resident.findMany({
where: {
DanMihailescu commented 2025-02-19 10:06:12 -05:00 (Migrated from github.com)
Review

Why are all of these the same?

Why are all of these the same?
BenjaminPalko commented 2025-02-19 10:14:50 -05:00 (Migrated from github.com)
Review

what do you mean?

what do you mean?
DanMihailescu commented 2025-02-19 18:29:02 -05:00 (Migrated from github.com)
Review

Id = zod.string
Token = Zod.string
Phonenumber = zod.string

From that I can see there's no parsing each item

Id = zod.string Token = Zod.string Phonenumber = zod.string From that I can see there's no parsing each item
BenjaminPalko commented 2025-02-19 18:59:39 -05:00 (Migrated from github.com)
Review

its a zod schema for validating the twilio config

its a zod schema for validating the twilio config
tenantId: event.locals.tenant.id,
},
select: {
id: true,
name: true,
phoneNumber: true,
},
});
return {
isTwilioConfigured: success,
residents: residents,
};
};
@ -40,22 +55,18 @@ export const actions = {
default: async (event) => {
const form = await event.request.formData();
if (!form.has('phone')) {
return fail(400, { error: 'phone_missing' });
if (!form.has('recipients')) {
return fail(400, { error: 'recipients_missing' });
}
if (!form.get('message')) {
if (!form.has('message')) {
return fail(400, { error: 'message_missing' });
}
const {
success: phoneSuccess,
data: phone,
error: phoneError,
} = zod.string().regex(PhoneRegex).safeParse(form.get('phone'));
if (!phoneSuccess) {
logger.error(phoneError);
return fail(400, { error: 'invalid_phone' });
const recipients: Recipient[] = JSON.parse(form.get('recipients') as string);
if (!Array.isArray(recipients)) {
return fail(400, { error: 'invalid_recipients' });
}
logger.info(recipients);
DanMihailescu commented 2025-02-19 10:09:10 -05:00 (Migrated from github.com)
Review

Should give user popup saying they need to select recipients; not redirect to error page

Should give user popup saying they need to select recipients; not redirect to error page
BenjaminPalko commented 2025-02-19 10:15:44 -05:00 (Migrated from github.com)
Review

This is the server-side file, it doesn't control the web page. It is also just the form post response which doesn't change the page location

This is the server-side file, it doesn't control the web page. It is also just the form post response which doesn't change the page location
piopi commented 2025-02-22 10:44:11 -05:00 (Migrated from github.com)
Review

Can you put all the errors in an enum and throwing 400 is too generic

Can you put all the errors in an enum and throwing 400 is too generic
const message = form.get('message');
if (typeof message !== 'string') {
@ -76,19 +87,24 @@ export const actions = {
return fail(307, { error: 'no_twilio_config' });
}
const client = twilio(config.accountSID, config.authToken);
const decryptedConfig = decryptTwilioConfig(config);
const client = twilio(decryptedConfig.accountSID, decryptedConfig.authToken);
for (const recipient of recipients) {
try {
const result = await client.messages.create({
to: phone,
to: recipient.phone,
body: message,
from: config.phoneNumber,
from: decryptedConfig.phoneNumber,
});
logger.debug(result);
} catch (e) {
logger.error(e);
fail(500, { success: false });
}
}
return {
success: true,
};

View file

@ -2,11 +2,12 @@
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import { Button } from '$lib/components/Actions';
import { Textarea, TextInput } from '$lib/components/DataInput';
import { Textarea } from '$lib/components/DataInput';
import { Alert } from '$lib/components/Feedback';
import { Link } from '$lib/components/Navigation';
import { RecipientList, type Recipient } from '$lib/components/SMS';
import { messages } from '$lib/i18n';
import { CircleX, MessageCircleMore, PhoneOutgoing, TriangleAlert } from 'lucide-svelte';
import { CircleX, MessageCircleMore, TriangleAlert } from 'lucide-svelte';
import type { ActionData, PageData } from './$types';
type Props = {
@ -14,27 +15,23 @@
form: ActionData;
};
let { data, form }: Props = $props();
let recipients = $derived(
data.residents.map((r) => ({ id: r.id, name: r.name, phone: r.phoneNumber }))
);
let selectedRecipients: Recipient[] = $state([]);
</script>
{#snippet PhoneLabel()}
<PhoneOutgoing size="18" /> {messages.sms_label_phone()}
{/snippet}
{#snippet MessageLabel()}
<MessageCircleMore size="18" /> {messages.sms_label_message()}
{/snippet}
<div class="page">
<div class="card bg-base-200 px-4 pt-4 shadow-xl">
<div class="flex flex-col items-center gap-4">
{#if form?.error}
<Alert status="error">
<Alert class="flex w-fit justify-center" status="error">
{#snippet icon()}
<CircleX />
{/snippet}
<span>{form.error}</span>
</Alert>
{:else if !data.isTwilioConfigured}
<Alert status="warning">
<Alert class="flex w-fit justify-center" status="warning">
{#snippet icon()}
<TriangleAlert />
{/snippet}
@ -45,42 +42,39 @@
</span>
</Alert>
{/if}
<div class="card-title justify-center">
<h2 class="text-2xl font-semibold">{messages.sms_prompt()}</h2>
<h2 class="text-4xl font-semibold">{messages.sms_prompt()}</h2>
<div class="flex justify-center gap-4">
<div class="flex flex-col items-center gap-2">
<h2 class="text-3xl font-medium">Recipients</h2>
<div class="h-full overflow-y-scroll rounded-lg bg-base-100 p-4">
<RecipientList {recipients} bind:selected={selectedRecipients} />
</div>
</div>
<form id="sms" method="POST" use:enhance>
<div class="card-body">
<TextInput
disabled={!data.isTwilioConfigured}
type="tel"
name="phone"
label={PhoneLabel}
placeholder="XXX-XXX-XXXX"
bordered
fade
/>
<div class="flex flex-col gap-4">
<input name="recipients" type="hidden" value={JSON.stringify(selectedRecipients)} />
<Textarea
disabled={!data.isTwilioConfigured}
label={MessageLabel}
size="lg"
error={form?.error}
name="message"
placeholder="..."
form="sms"
resizable={false}
/>
</div>
<div class="card-actions justify-center px-8 pb-4">
<Button disabled={!data.isTwilioConfigured} type="submit" variant="outline" full>
cols={40}
rows={12}
>
{#snippet label()}
<span class="flex items-center gap-2 text-xl">
<MessageCircleMore size="22" />
{messages.sms_label_message()}
</span>
{/snippet}
</Textarea>
<Button full color="primary" disabled={!data.isTwilioConfigured} type="submit">
{messages.sms_button_submit()}
</Button>
</div>
</form>
</div>
</div>
<style>
.page {
@apply flex flex-col items-center justify-around gap-24 py-[10%];
}
</style>