use residents as recipients, batch sms messaging
This commit is contained in:
parent
2ee0224c45
commit
f412c0a906
5 changed files with 150 additions and 80 deletions
64
src/lib/components/SMS/RecipientList.svelte
Normal file
64
src/lib/components/SMS/RecipientList.svelte
Normal 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>
|
||||||
2
src/lib/components/SMS/index.ts
Normal file
2
src/lib/components/SMS/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export * from './RecipientList.svelte';
|
||||||
|
export { default as RecipientList } from './RecipientList.svelte';
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
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 { fail, type Actions } from '@sveltejs/kit';
|
import { fail, type Actions } from '@sveltejs/kit';
|
||||||
|
|
@ -67,9 +66,9 @@ export const actions = {
|
||||||
tenantId: tenantId,
|
tenantId: tenantId,
|
||||||
twilioConfig: {
|
twilioConfig: {
|
||||||
create: {
|
create: {
|
||||||
accountSID: encrypt(accountSID),
|
accountSID: accountSID,
|
||||||
authToken: encrypt(authToken),
|
authToken: authToken,
|
||||||
phoneNumber: encrypt(phoneNumber),
|
phoneNumber: phoneNumber,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
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 { fail, type Actions } from '@sveltejs/kit';
|
import { fail, type Actions } from '@sveltejs/kit';
|
||||||
|
|
@ -31,8 +31,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 +52,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') {
|
||||||
|
|
@ -78,9 +86,10 @@ export const actions = {
|
||||||
|
|
||||||
const client = twilio(config.accountSID, config.authToken);
|
const client = twilio(config.accountSID, config.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: config.phoneNumber,
|
||||||
});
|
});
|
||||||
|
|
@ -89,6 +98,8 @@ export const actions = {
|
||||||
logger.error(e);
|
logger.error(e);
|
||||||
fail(500, { success: false });
|
fail(500, { success: false });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue