feat: wire GHL webhook to Resend email templates

- Replace raw Resend text emails with sendMail using Resend templates
- automated-user-registration template: firstName, tempPassword,
  verifyUrl, loginUrl
- license-key-delivery template: firstName, license_key
- Remove requestPasswordReset call (temp password is the one-time
  credential; verification link handles email confirmation)
- Add .catch() to Better Auth email callbacks to prevent unhandled
  rejections from missing templates crashing the server
This commit is contained in:
NeilClack
2026-07-08 15:01:08 -05:00
parent e6ef7d3b36
commit 55a29b9c1a
29 changed files with 26572 additions and 21641 deletions
@@ -26,7 +26,7 @@
$effect(() => {
name = String(record.name ?? '');
const existingChildren = (record.children as Array<{ id: number; name: string }>) ?? [];
children = existingChildren.map(c => ({ id: c.id, name: c.name }));
children = existingChildren.map((c) => ({ id: c.id, name: c.name }));
});
function markDirty() {
@@ -108,17 +108,16 @@
<select
id="field-solfeggioFrequencyId"
name="solfeggioFrequencyId"
onchange={(e) => solfeggioFrequencyId = (e.target as HTMLSelectElement).value}
onchange={(e) => (solfeggioFrequencyId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.solfeggioFrequencyId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Frequency --</option>
{#each relationOptionsData.solfeggioFrequencyId ?? [] as opt (String(opt.value))}
<option
value={opt.value}
selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option>
<option value={opt.value} selected={String(opt.value) === solfeggioFrequencyId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.solfeggioFrequencyId}
@@ -143,10 +142,7 @@
>
<option value="">-- Select Category --</option>
{#each parentOptions as cat (String(cat.id))}
<option
value={cat.id}
selected={String(cat.id) === parentCategoryId}
>{cat.name}</option>
<option value={cat.id} selected={String(cat.id) === parentCategoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.parentCategoryId}
@@ -164,17 +160,14 @@
<select
id="field-categoryId"
name="categoryId"
onchange={(e) => categoryId = (e.target as HTMLSelectElement).value}
onchange={(e) => (categoryId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.categoryId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Sub-Category --</option>
{#each childOptions as cat (String(cat.id))}
<option
value={cat.id}
selected={String(cat.id) === categoryId}
>{cat.name}</option>
<option value={cat.id} selected={String(cat.id) === categoryId}>{cat.name}</option>
{/each}
</select>
{#if fieldErrors.categoryId}
@@ -192,17 +185,16 @@
<select
id="field-subscriptionLevelId"
name="subscriptionLevelId"
onchange={(e) => subscriptionLevelId = (e.target as HTMLSelectElement).value}
onchange={(e) => (subscriptionLevelId = (e.target as HTMLSelectElement).value)}
class="w-full border px-3 py-1.5 text-sm {fieldErrors.subscriptionLevelId
? 'border-red-400'
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
>
<option value="">-- Select Level --</option>
{#each relationOptionsData.subscriptionLevelId ?? [] as opt (String(opt.value))}
<option
value={opt.value}
selected={String(opt.value) === subscriptionLevelId}
>{opt.label}</option>
<option value={opt.value} selected={String(opt.value) === subscriptionLevelId}
>{opt.label}</option
>
{/each}
</select>
{#if fieldErrors.subscriptionLevelId}
@@ -218,14 +210,14 @@
>
Content
</label>
<div id="field-content">
{#key record.id}
<TipTap
initialContent={typeof record.content === 'string' ? record.content : ''}
name="content"
/>
{/key}
</div>
<div id="field-content">
{#key record.id}
<TipTap
initialContent={typeof record.content === 'string' ? record.content : ''}
name="content"
/>
{/key}
</div>
{#if fieldErrors.content}
<p class="mt-1 text-xs text-red-600">{fieldErrors.content}</p>
{/if}
@@ -42,24 +42,16 @@ describe('ProclamationForm', () => {
isSuper: true
});
await expect
.element(page.getByRole('textbox', { name: 'Name' }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Frequency$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Category$/ }))
.toBeInTheDocument();
await expect.element(page.getByRole('textbox', { name: 'Name' })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Frequency$/ })).toBeInTheDocument();
await expect.element(page.getByRole('combobox', { name: /^Category$/ })).toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Sub-Category$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('combobox', { name: /^Subscription Level$/ }))
.toBeInTheDocument();
await expect
.element(page.getByRole('button', { name: 'Save' }))
.toBeInTheDocument();
await expect.element(page.getByRole('button', { name: 'Save' })).toBeInTheDocument();
});
it('pre-fills name input when editing an existing record', async () => {
@@ -141,9 +133,7 @@ describe('ProclamationForm', () => {
submitLabel: 'Update'
});
await expect
.element(page.getByRole('button', { name: 'Update' }))
.toBeDisabled();
await expect.element(page.getByRole('button', { name: 'Update' })).toBeDisabled();
});
it('enables submit button when editing and user modifies a field', async () => {
@@ -157,8 +147,6 @@ describe('ProclamationForm', () => {
await page.getByRole('textbox', { name: 'Name' }).fill('New Name');
await expect
.element(page.getByRole('button', { name: 'Update' }))
.toBeEnabled();
await expect.element(page.getByRole('button', { name: 'Update' })).toBeEnabled();
});
});
@@ -3,6 +3,7 @@ import { json, error } from '@sveltejs/kit';
import { auth } from '$lib/auth';
import { prisma } from '$lib/db';
import { sendMail } from '$lib/server/resend';
import { BETTER_AUTH_URL } from '$env/static/private';
function generateLicenseKey(): string {
const hex = crypto.randomUUID().replace(/-/g, '').slice(0, 16).toUpperCase();
@@ -43,10 +44,27 @@ export const POST: RequestHandler = async ({ request }) => {
user = await prisma.user.findUnique({ where: { email } });
await auth.api.requestPasswordReset({
body: {
email,
redirectTo: '/reset-password'
const verification = await prisma.verification.findFirst({
where: {
identifier: email,
expiresAt: { gt: new Date() }
},
orderBy: { createdAt: 'desc' }
});
const verifyUrl = verification
? `${BETTER_AUTH_URL}/api/auth/verify-email?token=${verification.value}&callbackURL=/login?verified=true`
: `${BETTER_AUTH_URL}/login`;
await sendMail({
to: email,
templateId: 'automated-user-registration',
subject: 'Your account is ready',
data: {
firstName: name ?? email.split('@')[0],
tempPassword,
verifyUrl,
loginUrl: `${BETTER_AUTH_URL}/login`
}
});
} catch (err) {
@@ -73,10 +91,11 @@ export const POST: RequestHandler = async ({ request }) => {
try {
await sendMail({
to: email,
templateId: 'license-key-delivery',
subject: 'Your License Key',
data: {
firstName: name ?? 'Valued Customer',
licenseKey: key
license_key: key
}
});
} catch (e) {