73 batch sms messaging (#85)

* medium

* use residents as recipients, batch sms messaging

* crypto for twilio config was not properly implemented
This commit is contained in:
Baobeld 2025-02-24 10:48:52 -05:00 committed by GitHub
parent 40bf33a9e5
commit 060d76d5fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 197 additions and 89 deletions

View file

@ -11,7 +11,7 @@
error?: string | Snippet; error?: string | Snippet;
label?: string | Snippet; label?: string | Snippet;
resizable?: boolean | 'yes' | 'no' | 'x' | 'y'; resizable?: boolean | 'yes' | 'no' | 'x' | 'y';
size?: DaisySize; size?: DaisySize | 'md';
} & SvelteHTMLElements['textarea']; } & SvelteHTMLElements['textarea'];
let { let {
bordered, bordered,
@ -25,7 +25,7 @@
}: Props = $props(); }: Props = $props();
</script> </script>
<label class="form-control w-full max-w-lg"> <label class="form-control w-full">
<div class="label"> <div class="label">
<span <span
class="label-text flex items-center gap-2" 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>
</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 { PhoneRegex } from '$lib/regex';
import { encrypt } from '$lib/server/crypto/encryption.js';
import { logger } from '$lib/server/logger'; import { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma'; import { prisma } from '$lib/server/prisma';
import { encryptTwilioConfig, decryptTwilioConfig } from '$lib/server/twilio';
import { fail, type Actions } from '@sveltejs/kit'; import { fail, type Actions } from '@sveltejs/kit';
import zod from 'zod'; import zod from 'zod';
@ -21,8 +21,16 @@ export const load = async (event) => {
}, },
}); });
if (!configs) {
return {};
}
return { return {
configs: configs, configs: {
...(configs.twilioConfig && {
twilioConfig: decryptTwilioConfig(configs.twilioConfig),
}),
},
}; };
}; };
@ -66,28 +74,32 @@ export const actions = {
create: { create: {
tenantId: tenantId, tenantId: tenantId,
twilioConfig: { twilioConfig: {
create: { create: encryptTwilioConfig({
accountSID: encrypt(accountSID), accountSID: accountSID,
authToken: encrypt(authToken), authToken: authToken,
phoneNumber: encrypt(phoneNumber), phoneNumber: phoneNumber,
}, }),
}, },
}, },
update: { update: {
tenantId: tenantId, tenantId: tenantId,
twilioConfig: { twilioConfig: {
update: { update: encryptTwilioConfig({
accountSID: accountSID, accountSID: accountSID,
authToken: authToken, authToken: authToken,
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
}, }),
}, },
}, },
select: { twilioConfig: true }, select: { twilioConfig: true },
}); });
return { return {
configs: configs, configs: {
...(configs.twilioConfig && {
twilioConfig: decryptTwilioConfig(configs.twilioConfig),
}),
},
}; };
}, },
} satisfies Actions; } 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 { logger } from '$lib/server/logger';
import { prisma } from '$lib/server/prisma/index.js'; import { prisma } from '$lib/server/prisma/index.js';
import { decryptTwilioConfig } from '$lib/server/twilio/index.js';
import { fail, type Actions } from '@sveltejs/kit'; import { fail, type Actions } from '@sveltejs/kit';
import twilio from 'twilio'; import twilio from 'twilio';
import zod from 'zod'; import zod from 'zod';
@ -24,6 +25,8 @@ export const load = async (event) => {
const { success, error: validationError } = zod const { success, error: validationError } = zod
.object({ .object({
accountSID: zod.string(), accountSID: zod.string(),
authToken: zod.string(),
phoneNumber: zod.string(),
}) })
.safeParse(configs?.twilioConfig); .safeParse(configs?.twilioConfig);
@ -31,8 +34,20 @@ export const load = async (event) => {
logger.warn(validationError.message); logger.warn(validationError.message);
} }
const residents = await prisma.resident.findMany({
where: {
tenantId: event.locals.tenant.id,
},
select: {
id: true,
name: true,
phoneNumber: true,
},
});
return { return {
isTwilioConfigured: success, isTwilioConfigured: success,
residents: residents,
}; };
}; };
@ -40,22 +55,18 @@ export const actions = {
default: async (event) => { default: async (event) => {
const form = await event.request.formData(); const form = await event.request.formData();
if (!form.has('phone')) { if (!form.has('recipients')) {
return fail(400, { error: 'phone_missing' }); return fail(400, { error: 'recipients_missing' });
} }
if (!form.get('message')) { if (!form.has('message')) {
return fail(400, { error: 'message_missing' }); return fail(400, { error: 'message_missing' });
} }
const { const recipients: Recipient[] = JSON.parse(form.get('recipients') as string);
success: phoneSuccess, if (!Array.isArray(recipients)) {
data: phone, return fail(400, { error: 'invalid_recipients' });
error: phoneError,
} = zod.string().regex(PhoneRegex).safeParse(form.get('phone'));
if (!phoneSuccess) {
logger.error(phoneError);
return fail(400, { error: 'invalid_phone' });
} }
logger.info(recipients);
const message = form.get('message'); const message = form.get('message');
if (typeof message !== 'string') { if (typeof message !== 'string') {
@ -76,19 +87,24 @@ export const actions = {
return fail(307, { error: 'no_twilio_config' }); return fail(307, { error: 'no_twilio_config' });
} }
const client = twilio(config.accountSID, config.authToken); const decryptedConfig = decryptTwilioConfig(config);
try { const client = twilio(decryptedConfig.accountSID, decryptedConfig.authToken);
const result = await client.messages.create({
to: phone, for (const recipient of recipients) {
body: message, try {
from: config.phoneNumber, const result = await client.messages.create({
}); to: recipient.phone,
logger.debug(result); body: message,
} catch (e) { from: decryptedConfig.phoneNumber,
logger.error(e); });
fail(500, { success: false }); logger.debug(result);
} catch (e) {
logger.error(e);
fail(500, { success: false });
}
} }
return { return {
success: true, success: true,
}; };

View file

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