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; 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>
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 { 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),
}),
},
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: { 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: {
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 { 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);
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'); 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);
const client = twilio(decryptedConfig.accountSID, decryptedConfig.authToken);
for (const recipient of recipients) {
try { try {
const result = await client.messages.create({ const result = await client.messages.create({
to: phone, to: recipient.phone,
body: message, body: message,
from: config.phoneNumber, from: decryptedConfig.phoneNumber,
}); });
logger.debug(result); logger.debug(result);
} catch (e) { } catch (e) {
logger.error(e); logger.error(e);
fail(500, { success: false }); 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,27 +15,23 @@
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()}
{/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">
{#if form?.error} {#if form?.error}
<Alert status="error"> <Alert class="flex w-fit justify-center" status="error">
{#snippet icon()} {#snippet icon()}
<CircleX /> <CircleX />
{/snippet} {/snippet}
<span>{form.error}</span> <span>{form.error}</span>
</Alert> </Alert>
{:else if !data.isTwilioConfigured} {:else if !data.isTwilioConfigured}
<Alert status="warning"> <Alert class="flex w-fit justify-center" status="warning">
{#snippet icon()} {#snippet icon()}
<TriangleAlert /> <TriangleAlert />
{/snippet} {/snippet}
@ -45,42 +42,39 @@
</span> </span>
</Alert> </Alert>
{/if} {/if}
<div class="card-title justify-center"> <h2 class="text-4xl font-semibold">{messages.sms_prompt()}</h2>
<h2 class="text-2xl 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> </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>