73 batch sms messaging #85
7 changed files with 197 additions and 89 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
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>
|
||||||
|
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>
|
||||||
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';
|
||||||
20
src/lib/server/twilio/index.ts
Normal file
20
src/lib/server/twilio/index.ts
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
Should return something; maybe an error page redirect Should return something; maybe an error page redirect
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;
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
Why are all of these the same? Why are all of these the same?
what do you mean? what do you mean?
Id = 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
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);
|
||||||
|
|
||||||
|
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
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
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);
|
||||||
|
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue
"Unit #: First Name" or just "Unit #" would work better for more user privacy (admin of condo shouldnt have access to personal data).