feat(web): add optional notes when declining requests #93

Merged
eleith merged 1 commits from feat/decline-note into main 2026-10-25 14:15:44 +00:00
23 changed files with 1431 additions and 69 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
'@when/worker': patch
'@when/cli': patch
---
Let hosts add an optional note of up to 500 characters when declining an individual appointment request. Save it with the decline action and show it in decline emails and appointment details without changing the general note or bulk decline behavior. Use concise note dialogs and consistent live character counts across notes and reasons, retaining existing limits and requiredness.
+15 -2
View File
@@ -37,15 +37,28 @@ test('the host declines a request', async ({ page }) => {
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Decline' }).click();
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
const note = dialog.getByLabel('Note (optional)', { exact: true });
await expect(note).toHaveAttribute('name', 'decline_note');
await expect(note).toHaveAttribute('maxlength', '500');
await expect(note).not.toHaveAttribute('required');
await expect(note).toHaveValue('');
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
const row = await readAppointment(appointment.id);
expect(row.status).toBe('declined');
expect(parseActionLog(row.action_log).at(-1)).toMatchObject({
const decline = parseActionLog(row.action_log).at(-1);
expect(decline).toMatchObject({
action: 'decline',
actor: 'host'
});
expect(decline?.payload?.note).toBeUndefined();
expect(readQueuedWorkflows(`${appointment.id}:declined:${row.ics_sequence}`)).toHaveLength(1);
});
+10 -4
View File
@@ -18,13 +18,19 @@ test('a guest cancels a confirmed appointment', async ({ page }) => {
await page.goto(`/appointment/${appointment.id}?token=${appointment.cancel_token}`);
await page.getByRole('button', { name: 'Appointment actions' }).click();
await page.getByRole('menuitem', { name: 'Cancel' }).click();
await page.locator('textarea[name="reason"]').fill('Something came up');
await page.getByRole('button', { name: 'Submit' }).click();
const dialog = page.getByRole('dialog', { name: 'Cancel appointment', exact: true });
const reason = dialog.getByLabel('Reason (required)', { exact: true });
await expect(reason).toHaveAttribute('required', '');
await expect(reason).toHaveAttribute('maxlength', '500');
const initialReason = await reason.inputValue();
await expect(dialog.getByText(`${initialReason.length}/500`, { exact: true })).toBeVisible();
await reason.fill('Something came up');
await expect(dialog.getByText('17/500', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Cancelled', exact: true })).toBeVisible();
// The reason never appears on the page, so the row is the only place it can be
// proved to have survived the form post.
// The action explanation is stored in history, not the general appointment note.
const row = await readAppointment(appointment.id);
expect(row.status).toBe('cancelled');
expect(parseActionLog(row.action_log).at(-1)).toMatchObject({
+241
View File
@@ -0,0 +1,241 @@
import { expect, test } from '@playwright/test';
import { appendActionLogSql, openDb, parseActionLog } from '@when/db';
import { signInAsAdmin } from './support/auth.ts';
import { APP_DB_PATH, readAppointment, readQueuedWorkflows } from './support/database.ts';
import { seedAppointment } from './support/seed.ts';
async function seedWithPrivateNote() {
const appointment = await seedAppointment({ status: 'pending' });
const db = openDb(APP_DB_PATH);
try {
await db
.updateTable('appointments')
.set({
note: `Private appointment note ${appointment.id}`,
action_log: appendActionLogSql({
action: 'edit',
actor: 'host',
at: new Date().toISOString(),
payload: { metadata: { changes: ['note_updated'] } }
})
})
.where('id', '=', appointment.id)
.execute();
} finally {
await db.destroy();
}
const seeded = await readAppointment(appointment.id);
return seeded;
}
test('a decline note is stored separately and rendered as plain text to host and guest', async ({
page,
browser
}) => {
const appointment = await seedWithPrivateNote();
const note = 'Sorry, <b>not this time</b>.\n<script>window.declineNoteInjected = true</script>';
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
const origin = new URL(page.url()).origin;
const guest = await browser.newContext();
try {
const guestPage = await guest.newPage();
const guestUrl = `${origin}/appointment/${appointment.id}?token=${appointment.cancel_token}`;
await guestPage.goto(guestUrl);
await expect(guestPage.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
await expect(guestPage.getByRole('button', { name: 'Decline', exact: true })).toHaveCount(0);
const forbidden = await guest.request.post(
`${origin}/admin/appointment/${appointment.id}?/decline&token=${appointment.cancel_token}&role=host`,
{
headers: { origin, accept: 'application/json', 'x-sveltekit-action': 'true' },
form: {
decline_note: note,
token: appointment.cancel_token,
role: 'host',
isAdmin: 'true'
},
maxRedirects: 0
}
);
expect(forbidden.status()).toBe(403);
const afterForbidden = await readAppointment(appointment.id);
expect(afterForbidden).toEqual(appointment);
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
await dialog.getByLabel('Note (optional)', { exact: true }).fill(note);
await expect(dialog.getByText(`${note.length}/500`, { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
const hostNote = page.locator('.state-note-line');
await expect(hostNote).toBeVisible();
await expect(hostNote.locator('.state-note-text')).toHaveCSS('white-space', 'pre-wrap');
const hostText = await hostNote.textContent();
expect(hostText).toContain(note);
await expect(hostNote.locator('b, script')).toHaveCount(0);
await expect(page.getByText(appointment.note!, { exact: true })).toBeVisible();
const row = await readAppointment(appointment.id);
expect(row.status).toBe('declined');
expect(row.note).toBe(appointment.note);
const log = parseActionLog(row.action_log);
expect(log.slice(0, -1)).toEqual(parseActionLog(appointment.action_log));
expect(log.filter((entry) => entry.action === 'decline')).toHaveLength(1);
expect(log.at(-1)).toMatchObject({ action: 'decline', actor: 'host', payload: { note } });
expect(log.at(-1)?.payload).toEqual({ note });
expect(readQueuedWorkflows(`${appointment.id}:declined:${row.ics_sequence}`)).toEqual([
{ workflow_name: 'reconcile-appointment' }
]);
const guestResponse = await guestPage.goto(guestUrl);
expect(guestResponse?.status()).toBe(200);
const guestHtml = await guestResponse!.text();
expect(guestHtml).not.toContain(appointment.note!);
expect(guestHtml).not.toContain('note_updated');
await expect(guestPage.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
const guestNote = guestPage.locator('.state-note-line');
await expect(guestNote).toBeVisible();
await expect(guestNote.locator('.state-note-text')).toHaveCSS('white-space', 'pre-wrap');
const guestText = await guestNote.textContent();
expect(guestText).toContain(note);
await expect(guestNote.locator('b, script')).toHaveCount(0);
await expect(guestPage.getByText(appointment.note!, { exact: true })).toHaveCount(0);
await expect(guestPage.getByText('Activity log', { exact: true })).toHaveCount(0);
} finally {
await guest.close();
}
});
test('server rejection of 501 characters keeps the decline dialog and draft for a 500-character retry', async ({
page
}) => {
const appointment = await seedAppointment({ status: 'pending' });
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
const field = dialog.getByLabel('Note (optional)', { exact: true });
await expect(field).not.toHaveAttribute('required');
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
const draft = 'Please try another day.\nThank you for understanding.';
await field.fill(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
await expect(field).toHaveAttribute('maxlength', '500');
// Bypass only native maxlength, leaving the real enhanced action and visible draft intact.
await dialog.locator('form').evaluate((element) => {
if (!(element instanceof HTMLFormElement)) throw new Error('Expected decline form');
element.addEventListener(
'formdata',
(event) => {
event.formData.set('decline_note', 'x'.repeat(501));
},
{ once: true }
);
});
const actionPath = `/admin/appointment/${appointment.id}`;
const rejectionPromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === actionPath &&
new URL(response.url()).searchParams.has('/decline')
);
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
const rejection = await rejectionPromise;
const result = await rejection.json();
expect(result).toMatchObject({ type: 'failure', status: 400 });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('alert')).toHaveText(
'Decline note must be 500 characters or fewer.'
);
await expect(field).toHaveValue(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
await expect(field).toBeEnabled();
await expect(page.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
const unchanged = await readAppointment(appointment.id);
expect(unchanged).toEqual(appointment);
expect(parseActionLog(unchanged.action_log).some((entry) => entry.action === 'decline')).toBe(
false
);
expect(
readQueuedWorkflows(`${appointment.id}:declined:${appointment.ics_sequence + 1}`)
).toHaveLength(0);
const corrected = 'x'.repeat(500);
await field.fill(corrected);
await expect(dialog.getByText('500/500', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Declined', exact: true })).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
const saved = await readAppointment(appointment.id);
expect(saved.status).toBe('declined');
expect(saved.note).toBe(appointment.note);
expect(parseActionLog(saved.action_log).at(-1)).toMatchObject({
action: 'decline',
actor: 'host',
payload: { note: corrected }
});
expect(readQueuedWorkflows(`${appointment.id}:declined:${saved.ics_sequence}`)).toHaveLength(1);
});
test('accepting in another host tab makes the stale decline fail without writing its note', async ({
page
}) => {
const appointment = await seedWithPrivateNote();
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Decline', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Decline appointment', exact: true });
await expect(dialog).toBeVisible();
const draft = 'Do not save this stale decline.\nThe request has already been accepted.';
const field = dialog.getByLabel('Note (optional)', { exact: true });
await expect(dialog.getByText('0/500', { exact: true })).toBeVisible();
await field.fill(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
const otherHost = await page.context().newPage();
try {
await otherHost.goto(page.url());
await otherHost.getByRole('button', { name: 'Accept', exact: true }).click();
await expect(otherHost.getByRole('heading', { name: 'Confirmed', exact: true })).toBeVisible();
} finally {
await otherHost.close();
}
const confirmed = await readAppointment(appointment.id);
expect(confirmed.status).toBe('confirmed');
expect(confirmed.note).toBe(appointment.note);
const actionPath = `/admin/appointment/${appointment.id}`;
const conflictPromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
new URL(response.url()).pathname === actionPath &&
new URL(response.url()).searchParams.has('/decline')
);
await dialog.getByRole('button', { name: 'Decline', exact: true }).click();
const conflict = await conflictPromise;
const result = await conflict.json();
expect(result).toMatchObject({ type: 'failure', status: 409 });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('alert')).toHaveText('This appointment can no longer be declined.');
await expect(field).toHaveValue(draft);
await expect(dialog.getByText(`${draft.length}/500`, { exact: true })).toBeVisible();
await expect(field).toBeEnabled();
const unchanged = await readAppointment(appointment.id);
expect(unchanged).toEqual(confirmed);
expect(parseActionLog(unchanged.action_log).some((entry) => entry.action === 'decline')).toBe(
false
);
expect(readQueuedWorkflows(`${appointment.id}:confirmed:${confirmed.ics_sequence}`)).toHaveLength(
1
);
expect(readQueuedWorkflows(`${appointment.id}:declined:${confirmed.ics_sequence}`)).toHaveLength(
0
);
expect(
readQueuedWorkflows(`${appointment.id}:declined:${confirmed.ics_sequence + 1}`)
).toHaveLength(0);
});
+78
View File
@@ -0,0 +1,78 @@
import { expect, test } from '@playwright/test';
import { signInAsAdmin } from './support/auth.ts';
import { readAppointment } from './support/database.ts';
import { seedAppointment } from './support/seed.ts';
function futureWeekday(retry: number): string {
const date = new Date();
date.setUTCDate(date.getUTCDate() + 2 + retry * 7);
while (date.getUTCDay() === 0 || date.getUTCDay() === 6) {
date.setUTCDate(date.getUTCDate() + 1);
}
return date.toISOString().slice(0, 10);
}
test('general notes and existing bulk cancellation use the same counter without changing requiredness', async ({
page
}, testInfo) => {
const day = futureWeekday(testInfo.retry);
const appointment = await seedAppointment({
status: 'confirmed',
start: new Date(`${day}T23:00:00Z`)
});
await signInAsAdmin(page);
await page.goto(`/appointment/${appointment.id}`);
await page.getByRole('button', { name: 'Appointment actions', exact: true }).click();
await page.getByRole('menuitem', { name: 'Add Note', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Add Note', exact: true });
const note = dialog.getByLabel('Note (optional)', { exact: true });
await expect(note).not.toHaveAttribute('required');
await expect(note).toHaveAttribute('maxlength', '1000');
await expect(dialog.getByText('0/1000', { exact: true })).toBeVisible();
await note.fill('A note');
await expect(dialog.getByText('6/1000', { exact: true })).toBeVisible();
await note.clear();
await expect(dialog.getByText('0/1000', { exact: true })).toBeVisible();
await dialog.getByRole('button', { name: 'Close', exact: true }).click();
await page.goto('/admin/appointments/upcoming');
const row = page.locator('.appointment-row').filter({
has: page.getByRole('link', { name: appointment.guest_name, exact: true })
});
await row.getByRole('checkbox', { name: 'Select appointment', exact: true }).check();
await page.getByRole('button', { name: 'Cancel 1', exact: true }).click();
const cancellation = page.getByRole('dialog', { name: 'Cancel 1 Appointment?', exact: true });
const reason = cancellation.getByLabel('Reason for cancelling', { exact: true });
await expect(reason).toHaveAttribute('required', '');
await expect(reason).toHaveAttribute('maxlength', '500');
const initial = await reason.inputValue();
await expect(cancellation.getByText(`${initial.length}/500`, { exact: true })).toBeVisible();
await reason.fill('Testing');
await expect(cancellation.getByText('7/500', { exact: true })).toBeVisible();
await cancellation.getByRole('button', { name: 'Cancel', exact: true }).click();
const unchanged = await readAppointment(appointment.id);
expect(unchanged).toEqual(appointment);
});
test('rescheduling still requires a reason and shows the shared 500-character counter', async ({
page
}, testInfo) => {
const day = futureWeekday(testInfo.retry);
const appointment = await seedAppointment({
status: 'pending',
start: new Date(`${day}T14:00:00Z`)
});
const slot = encodeURIComponent(`${day}T15:00:00Z`);
await page.goto(
`/appointment/${appointment.id}/reschedule?token=${appointment.cancel_token}&slot=${slot}`
);
await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible();
const reason = page.getByLabel('Reason for rescheduling');
await expect(reason).toHaveAttribute('required', '');
await expect(reason).toHaveAttribute('maxlength', '500');
await expect(page.getByText('0/500', { exact: true })).toBeVisible();
await reason.fill('A reason');
await expect(page.getByText('8/500', { exact: true })).toBeVisible();
const unchanged = await readAppointment(appointment.id);
expect(unchanged).toEqual(appointment);
});
@@ -2,6 +2,8 @@
<script lang="ts">
import IconX from 'virtual:icons/ph/x';
import { Dialog } from 'bits-ui';
import CharacterCount from './CharacterCount.svelte';
import { LIMIT_REASON } from '$lib/forms/validation';
interface Props {
open: boolean;
@@ -68,9 +70,6 @@
</Dialog.Close>
</form>
{:else if action === 'cancel'}
<p class="dialog-desc">
Are you sure you want to cancel these {selectedIds.length} selected appointments?
</p>
<form method="POST" action="/admin/appointments?/bulkCancel" class="dialog-form">
{#each selectedIds as id (id)}
<input type="hidden" name="ids" value={id} />
@@ -82,8 +81,10 @@
name="reason"
bind:value={cancelReason}
class="form-textarea"
maxlength={LIMIT_REASON}
required
></textarea>
<CharacterCount value={cancelReason} limit={LIMIT_REASON} />
</div>
<div class="dialog-actions">
<button type="submit" class="btn-confirm btn-confirm-danger">Yes, cancel</button>
@@ -0,0 +1,15 @@
<script lang="ts">
let { value, limit }: { value: string; limit: number } = $props();
</script>
<span class="count">{value.length}/{limit}</span>
<style>
.count {
display: block;
margin-top: var(--space-1);
text-align: right;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest';
import { render } from 'svelte/server';
import CharacterCount from './CharacterCount.svelte';
describe('CharacterCount', () => {
test.each([
['', 500, '0/500'],
['A note', 500, '6/500'],
['A note', 1000, '6/1000'],
['😀', 500, '2/500']
])('renders the existing character-count format for %j', (value, limit, count) => {
const rendered = render(CharacterCount, {
props: { value: String(value), limit: Number(limit) }
});
expect(rendered.body).toContain(String(count));
});
});
@@ -1,6 +1,7 @@
<!-- One labelled guest-input, rendered per configured form field type. -->
<script lang="ts">
import type { FormField } from '@when/config';
import CharacterCount from './CharacterCount.svelte';
import {
PHONE_PATTERN,
LIMIT_SHORT,
@@ -136,7 +137,7 @@
onblur={() => (touched = true)}
></textarea>
{#if !disabled}
<span class="count">{value.length}/{maxLen}</span>
<CharacterCount {value} limit={maxLen} />
{/if}
{:else if field.type === 'choice' || (field.type === 'event_location' && field.choices)}
<select
@@ -236,14 +237,6 @@
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.2);
}
.count {
display: block;
margin-top: var(--space-1);
text-align: right;
font-size: var(--font-size-xs);
color: var(--color-text-muted);
}
.error {
margin: var(--space-2) 0 0;
font-size: var(--font-size-sm);
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { declineAppointment } from './decline';
import { confirmAppointment } from './transitions';
import { systemClock } from '$lib/server/clock';
import { openDb, runMigrations } from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
@@ -29,6 +30,151 @@ async function fetchRow(db: Awaited<ReturnType<typeof makeDb>>, id: string) {
return db.selectFrom('appointments').selectAll().where('id', '=', id).executeTakeFirstOrThrow();
}
const decisionTime = '2026-01-01T12:00:00.000Z';
const fixedClock = {
now: () => new Date(decisionTime),
nowMs: () => Date.parse(decisionTime)
};
describe('declineAppointment with a note', () => {
beforeEach(() => {
vi.mocked(enqueueAppointmentReconciliation).mockReset();
vi.mocked(enqueueAppointmentReconciliation).mockResolvedValue(undefined);
});
test('persists the decline note separately and reconciles the unchanged inherited event pointer', async () => {
const db = await makeDb();
try {
const previous = { action: 'confirm', actor: 'host', at: '2025-12-01T12:00:00Z' };
await db
.insertInto('appointments')
.values({
...baseRow,
id: 'noted',
status: 'pending',
cancel_token: 'original-token',
origin_id: 'original-id',
note: 'Private preparation note',
external_event_id: 'inherited-event',
external_calendar_id: 'work',
calendar_revision: 7,
calendar_synced_revision: 7,
ics_sequence: 3,
action_log: JSON.stringify([previous])
})
.execute();
const row = await fetchRow(db, 'noted');
const note = 'Sorry, I cannot make this.\nPlease try Friday — ありがとう.';
const result = await declineAppointment(
{ db, cfg: validConfig, clock: fixedClock },
{ appointment: row, note }
);
expect(result.ok).toBe(true);
if (!result.ok) return;
const persisted = await fetchRow(db, 'noted');
expect(result.appointment).toEqual(persisted);
expect(persisted).toMatchObject({
id: row.id,
origin_id: row.origin_id,
cancel_token: row.cancel_token,
start_time: row.start_time,
end_time: row.end_time,
status: 'declined',
note: 'Private preparation note',
external_event_id: 'inherited-event',
external_calendar_id: 'work',
calendar_revision: 8,
calendar_synced_revision: 7,
ics_sequence: 4
});
expect(JSON.parse(persisted.action_log!)).toEqual([
previous,
{ action: 'decline', actor: 'host', at: decisionTime, payload: { note } }
]);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
persisted,
'declined'
);
} finally {
await db.destroy();
}
});
test.each(['confirmed', 'declined', 'cancelled', 'rescheduled', 'purged', 'past'] as const)(
'does not save a note or reconcile a gated %s appointment',
async (state) => {
const db = await makeDb();
try {
await db
.insertInto('appointments')
.values({
...baseRow,
id: 'gated-note',
status: state === 'past' ? 'pending' : state,
start_time: state === 'past' ? '2025-01-01T15:00:00Z' : baseRow.start_time,
cancel_token: 'gated-token',
note: 'Keep this private'
})
.execute();
const before = await fetchRow(db, 'gated-note');
const result = await declineAppointment(
{ db, cfg: validConfig, clock: fixedClock },
{ appointment: before, note: 'Must not be saved' }
);
const after = await fetchRow(db, 'gated-note');
expect(result).toEqual({ ok: false, reason: 'gated' });
expect(after).toEqual(before);
expect(enqueueAppointmentReconciliation).not.toHaveBeenCalled();
} finally {
await db.destroy();
}
}
);
test('a concurrent acceptance wins without a partial decline note, log, or counter update', async () => {
const db = await makeDb();
try {
await db
.insertInto('appointments')
.values({
...baseRow,
id: 'accepted-first',
status: 'pending',
cancel_token: 'race-token',
note: 'Original preparation'
})
.execute();
const stale = await fetchRow(db, 'accepted-first');
const accepted = await confirmAppointment(db, stale.id, decisionTime);
const beforeDecline = await fetchRow(db, stale.id);
const result = await declineAppointment(
{ db, cfg: validConfig, clock: fixedClock },
{ appointment: stale, note: 'Losing decline note' }
);
const after = await fetchRow(db, stale.id);
expect(accepted).toEqual({ ok: true });
expect(result).toEqual({ ok: false, reason: 'conflict' });
expect(after).toEqual(beforeDecline);
expect(after.status).toBe('confirmed');
expect(after.note).toBe('Original preparation');
expect(after.calendar_revision).toBe(1);
expect(after.ics_sequence).toBe(0);
expect(JSON.parse(after.action_log!)).toEqual([
{ action: 'confirm', actor: 'host', at: decisionTime }
]);
expect(enqueueAppointmentReconciliation).not.toHaveBeenCalled();
} finally {
await db.destroy();
}
});
});
describe('declineAppointment', () => {
beforeEach(() => {
vi.mocked(enqueueAppointmentReconciliation).mockReset();
@@ -6,6 +6,7 @@ import { getAppointment, type Appointment } from '@when/db';
export interface DeclineAppointmentInput {
appointment: Appointment;
note?: string;
}
export type DeclineAppointmentResult =
@@ -26,11 +27,11 @@ export async function declineAppointment(
}).decline;
if (!gate.allowed) return { ok: false, reason: 'gated' };
// Decline never touches the calendar (pending appointments aren't synced), so no sync.
const result = await declineAppointmentTransition(
ctx.db,
input.appointment.id,
ctx.clock.now().toISOString()
ctx.clock.now().toISOString(),
input.note
);
if (!result.ok) return { ok: false, reason: 'conflict' };
@@ -1,6 +1,11 @@
import { describe, expect, test } from 'vitest';
import type { Meeting, FormField } from '@when/config';
import { parseAndValidateAppointmentForm, resolveTimezone, validateReason } from './form.server';
import {
parseAndValidateAppointmentForm,
resolveTimezone,
validateDeclineNote,
validateReason
} from './form.server';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
const baseEvent: Meeting = {
@@ -219,6 +224,77 @@ describe('validateReason', () => {
});
});
describe('validateDeclineNote', () => {
test.each([
['missing', null],
['empty', ''],
['whitespace', ' \t\r\n '],
['controls only', '\u0000\u0007\u007f']
] as const)('allows %s without a note', (_label, value) => {
const form = new FormData();
if (value !== null) form.set('decline_note', value);
expect(validateDeclineNote(form)).toEqual({ ok: true, note: undefined });
});
test('cleans controls and normalizes newlines while preserving plain multiline text', () => {
const form = fd({
decline_note: ' \u0000Sorry\r\n\tPlease try Friday.\rMerci — またね 👋\u0007\u007f '
});
expect(validateDeclineNote(form)).toEqual({
ok: true,
note: 'Sorry\n\tPlease try Friday.\nMerci — またね 👋'
});
});
test('treats markup as plain text, not as a separate content format', () => {
const note = '<b>Sorry</b>\n**Try another time**';
expect(validateDeclineNote(fd({ decline_note: note }))).toEqual({ ok: true, note });
});
test.each(['x'.repeat(500), '界'.repeat(500), '👋'.repeat(250)])(
'accepts a note at the 500-character string-length limit',
(note) => {
expect(validateDeclineNote(fd({ decline_note: note }))).toEqual({ ok: true, note });
}
);
test.each(['x'.repeat(501), '界'.repeat(501), '👋'.repeat(250) + 'x'])(
'rejects a note beyond the 500-character string-length limit',
(note) => {
expect(validateDeclineNote(fd({ decline_note: note }))).toEqual({
ok: false,
error: 'Decline note must be 500 characters or fewer.'
});
}
);
test('measures length after trimming, control removal, and CRLF normalization', () => {
const note = 'x'.repeat(498) + '\ny';
const form = fd({ decline_note: ` \u0007${'x'.repeat(498)}\r\ny\u007f ` });
expect(validateDeclineNote(form)).toEqual({ ok: true, note });
});
test('rejects a file rather than converting it to a note', () => {
const form = new FormData();
form.set('decline_note', new File(['Sorry'], 'note.txt', { type: 'text/plain' }));
expect(validateDeclineNote(form)).toEqual({
ok: false,
error: 'Please enter a text note.'
});
});
test('does not read general notes or cancellation/reschedule reasons', () => {
const form = fd({ note: 'Private prep', reason: 'Cancel', reschedule_reason: 'Move' });
expect(validateDeclineNote(form)).toEqual({ ok: true, note: undefined });
});
});
describe('resolveTimezone', () => {
test('keeps a valid IANA zone', () => {
expect(resolveTimezone('Europe/London', 'UTC')).toBe('Europe/London');
@@ -136,6 +136,21 @@ export function parseAndValidateAppointmentForm(
return { ok: true, data: { name, email, location, answers } };
}
export type ValidateDeclineNoteResult =
| { ok: true; note: string | undefined }
| { ok: false; error: string };
export function validateDeclineNote(form: FormData): ValidateDeclineNoteResult {
const value = form.get('decline_note');
if (value === null) return { ok: true, note: undefined };
if (typeof value !== 'string') return { ok: false, error: 'Please enter a text note.' };
const note = cleanGuestText(value, true);
if (note.length > LIMIT_REASON) {
return { ok: false, error: `Decline note must be ${LIMIT_REASON} characters or fewer.` };
}
return { ok: true, note: note || undefined };
}
export type ReasonPurpose = 'cancelling' | 'rescheduling';
export type ValidateReasonResult = { ok: true; reason: string } | { ok: false; error: string };
@@ -161,6 +161,133 @@ describe('toPublicEventType', () => {
});
});
describe('toPublicAppointment decline note privacy', () => {
const at = '2026-05-01T13:00:00Z';
const declineNote = 'Sorry, not this time.\nPlease try Friday — merci.';
const declineEntry = {
action: 'decline',
actor: 'host',
at,
payload: {
note: declineNote,
field: 'private-field',
from: 'private-before',
to: 'private-after',
metadata: { internal_id: 'private-id', token: 'private-token' }
}
};
test('exposes only the decline action, actor, time, and note to the declined guest', () => {
const row: Appointment = {
...baseRow,
status: 'declined',
action_log: JSON.stringify([
{ action: 'confirm', actor: 'host', at },
{ action: 'edit', actor: 'host', at, payload: { note: 'Private edit history' } },
{ action: 'rotate', actor: 'host', at },
declineEntry
])
};
const original = structuredClone(row);
const guest = toPublicAppointment(row, 'guest');
expect(guest.note).toBeNull();
expect(guest.location).toBeNull();
expect(guest.video_chat).toBeNull();
expect(guest.action_log).toStrictEqual([
{
action: 'decline',
actor: 'host',
at,
payload: { note: declineNote, metadata: undefined }
}
]);
expect(guest).not.toHaveProperty('cancel_token');
expect(row).toEqual(original);
const host = toPublicAppointment(row, 'host');
expect(host.note).toBe(baseRow.note);
expect(host.action_log).toEqual(JSON.parse(row.action_log!));
});
test.each(['pending', 'confirmed', 'cancelled', 'rescheduled', 'purged'] as const)(
'never exposes inherited decline history on a %s row',
(status) => {
const row: Appointment = {
...baseRow,
status,
action_log: JSON.stringify([declineEntry])
};
const guest = toPublicAppointment(row, 'guest');
const host = toPublicAppointment(row, 'host');
expect(guest.action_log).toEqual([]);
expect(guest.note).toBe(status === 'confirmed' ? baseRow.note : null);
expect(host.action_log).toEqual([declineEntry]);
expect(host.note).toBe(baseRow.note);
}
);
test('a decline without a note does not fall back to the general appointment note', () => {
const row: Appointment = {
...baseRow,
status: 'declined',
action_log: JSON.stringify([{ action: 'decline', actor: 'host', at }])
};
const guest = toPublicAppointment(row, 'guest');
expect(guest.note).toBeNull();
expect(guest.action_log).toStrictEqual([
{ action: 'decline', actor: 'host', at, payload: { note: undefined, metadata: undefined } }
]);
});
test('keeps decline and cancellation notes distinct and preserves only existing public move metadata', () => {
const moveMetadata = { previous_id: 'old-row', next_id: 'new-row' };
const row: Appointment = {
...baseRow,
status: 'declined',
action_log: JSON.stringify([
{
action: 'cancel',
actor: 'guest',
at,
payload: { note: 'Guest cancellation reason' }
},
{
action: 'reschedule',
actor: 'host',
at,
payload: { note: 'Move reason', metadata: moveMetadata }
},
declineEntry
])
};
const guest = toPublicAppointment(row, 'guest');
expect(guest.note).toBeNull();
expect(guest.action_log).toStrictEqual([
{
action: 'cancel',
actor: 'guest',
at,
payload: { note: 'Guest cancellation reason', metadata: undefined }
},
{
action: 'reschedule',
actor: 'host',
at,
payload: { note: 'Move reason', metadata: moveMetadata }
},
{ action: 'decline', actor: 'host', at, payload: { note: declineNote, metadata: undefined } }
]);
});
});
describe('toPublicAppointment', () => {
test('shows location, note, and video_chat for hosts', () => {
const res = toPublicAppointment(baseRow, 'host');
@@ -80,14 +80,19 @@ export function toPublicAppointment(row: Appointment, role: AppointmentRole): Pu
const isConfirmed = row.status === 'confirmed';
const publicLog = action_log
.filter((e) => e.action === 'cancel' || e.action === 'reschedule')
.filter(
(e) =>
e.action === 'cancel' ||
e.action === 'reschedule' ||
(row.status === 'declined' && e.action === 'decline')
)
.map((e) => ({
action: e.action,
actor: e.actor,
at: e.at,
payload: {
note: e.payload?.note || undefined,
metadata: e.payload?.metadata || undefined
metadata: e.action === 'decline' ? undefined : e.payload?.metadata || undefined
}
})) as ActionLogEntry[];
@@ -307,6 +307,143 @@ test('declineAppointmentTransition queues a delete when a re-approval revert car
}
});
test.each([false, true])(
'declineAppointmentTransition atomically appends a note and preserves the row (inherited event: %s)',
async (inheritedEvent) => {
const db = await makeDb();
try {
const previous = { action: 'confirm', actor: 'host', at: '2025-12-01T12:00:00Z' };
await insert(db, {
id: 'noted',
status: 'pending',
cancel_token: 'same-token',
origin_id: 'same-origin',
note: 'General preparation note',
external_event_id: inheritedEvent ? 'inherited-event' : null,
external_calendar_id: inheritedEvent ? 'work' : null,
calendar_revision: 4,
calendar_synced_revision: 4,
ics_sequence: 2,
action_log: JSON.stringify([previous])
});
const before = await fetchRow(db, 'noted');
const note = 'Not available.\nPlease pick another day — désolé.';
const at = '2026-01-01T15:00:00Z';
const result = await declineAppointmentTransition(db, before.id, at, note);
const after = await fetchRow(db, before.id);
expect(result).toEqual({ ok: true });
expect(after).toEqual({
...before,
status: 'declined',
calendar_revision: 5,
ics_sequence: 3,
action_log: JSON.stringify([
previous,
{ action: 'decline', actor: 'host', at, payload: { note } }
]),
updated_at: after.updated_at
});
const repeated = await declineAppointmentTransition(
db,
before.id,
'2026-01-01T16:00:00Z',
'A second note must not replace the first'
);
const afterRepeat = await fetchRow(db, before.id);
expect(repeated).toEqual({ ok: false, reason: 'conflict' });
expect(afterRepeat).toEqual(after);
} finally {
await db.destroy();
}
}
);
test.each(['confirmed', 'cancelled', 'declined', 'rescheduled', 'purged'])(
'declineAppointmentTransition leaves a %s row entirely unchanged even when given a note',
async (status) => {
const db = await makeDb();
try {
await insert(db, {
id: 'gated',
status,
cancel_token: 'original-token',
note: 'Original note',
calendar_revision: 3,
ics_sequence: 2
});
const before = await fetchRow(db, 'gated');
const result = await declineAppointmentTransition(
db,
before.id,
'2026-01-01T15:00:00Z',
'Do not save this'
);
const after = await fetchRow(db, before.id);
expect(result).toEqual({ ok: false, reason: 'conflict' });
expect(after).toEqual(before);
} finally {
await db.destroy();
}
}
);
test('declineAppointmentTransition with a note reports a missing row', async () => {
const db = await makeDb();
try {
const result = await declineAppointmentTransition(
db,
'missing',
'2026-01-01T15:00:00Z',
'No appointment to annotate'
);
expect(result).toEqual({ ok: false, reason: 'not_found' });
} finally {
await db.destroy();
}
});
test('accept and noted decline racing on a pending row commit only the winning decision', async () => {
const db = await makeDb();
try {
await insert(db, {
id: 'race',
status: 'pending',
cancel_token: 'same-token',
note: 'General note'
});
const at = '2026-01-01T15:00:00Z';
const note = 'Decline reason';
const [accepted, declined] = await Promise.all([
confirmAppointment(db, 'race', at),
declineAppointmentTransition(db, 'race', at, note)
]);
const row = await fetchRow(db, 'race');
expect([accepted.ok, declined.ok].filter(Boolean)).toHaveLength(1);
expect(accepted.ok ? declined : accepted).toEqual({ ok: false, reason: 'conflict' });
expect(row.status).toBe(accepted.ok ? 'confirmed' : 'declined');
expect(row.calendar_revision).toBe(1);
expect(row.ics_sequence).toBe(accepted.ok ? 0 : 1);
expect(row.id).toBe('race');
expect(row.cancel_token).toBe('same-token');
expect(row.note).toBe('General note');
expect(JSON.parse(row.action_log!)).toEqual([
accepted.ok
? { action: 'confirm', actor: 'host', at }
: { action: 'decline', actor: 'host', at, payload: { note } }
]);
} finally {
await db.destroy();
}
});
test('rotateGuestTokenTransition replaces the token and records the move', async () => {
const db = await makeDb();
try {
@@ -208,7 +208,8 @@ export async function purgeChainTransition(
export async function declineAppointmentTransition(
db: Kysely<Database>,
id: string,
now: string
now: string,
note?: string
): Promise<TransitionOutcome> {
const result = await db
.updateTable('appointments')
@@ -216,7 +217,12 @@ export async function declineAppointmentTransition(
status: 'declined',
ics_sequence: sql`ics_sequence + 1`,
calendar_revision: sql`calendar_revision + 1`,
action_log: appendActionLogSql({ action: 'decline', actor: 'host', at: now }),
action_log: appendActionLogSql({
action: 'decline',
actor: 'host',
at: now,
payload: note ? { note } : undefined
}),
updated_at: sql`CURRENT_TIMESTAMP`
})
.where('id', '=', id)
@@ -1,6 +1,7 @@
<script lang="ts">
import { Dialog } from 'bits-ui';
import { deserialize } from '$app/forms';
import { applyAction, deserialize, enhance } from '$app/forms';
import type { SubmitFunction } from '@sveltejs/kit';
import { invalidateAll } from '$app/navigation';
import { tick } from 'svelte';
import IconCalendarBlank from 'virtual:icons/ph/calendar-blank';
@@ -14,16 +15,22 @@
import IconVideo from 'virtual:icons/ph/video-conference';
import AppointmentActions from '$lib/components/AppointmentActions.svelte';
import DurationEditor from '$lib/components/DurationEditor.svelte';
import CharacterCount from '$lib/components/CharacterCount.svelte';
import AppointmentLog from '$lib/components/AppointmentLog.svelte';
import AppointmentQuestions from '$lib/components/AppointmentQuestions.svelte';
import AddToCalendar from '$lib/components/AddToCalendar.svelte';
import { formatDateShort, formatWeekday, formatTimeRange, formatTzShort } from '$lib/datetime';
import { LIMIT_LONG, LIMIT_REASON } from '$lib/forms/validation';
let { data, form } = $props();
const isHost = $derived(data.role === 'host');
let cancelDialogOpen = $state(false);
let declineDialogOpen = $state(false);
let declining = $state(false);
let declineNote = $state('');
let declineTextareaEl = $state<HTMLTextAreaElement | null>(null);
let editNoteDialogOpen = $state(false);
let editLocationDialogOpen = $state(false);
let editVideoChatDialogOpen = $state(false);
@@ -39,6 +46,27 @@
let locationInputEl = $state<HTMLInputElement | null>(null);
let videoChatInputEl = $state<HTMLInputElement | null>(null);
const submitDecline: SubmitFunction = ({ cancel }) => {
if (declining) {
cancel();
return;
}
declining = true;
return async ({ result, update }) => {
try {
if (result.type === 'failure') {
await applyAction(result);
} else {
// A cross-route success must also clear any earlier form error.
if (result.type === 'success') await applyAction(result);
await update();
}
} finally {
declining = false;
}
};
};
async function handleGenerateVideoChat() {
await submitVideoChatChange('generateVideoChat', new FormData());
}
@@ -83,6 +111,15 @@
}
});
$effect(() => {
if (declineDialogOpen) {
declineNote = '';
tick().then(() => {
declineTextareaEl?.focus();
});
}
});
$effect(() => {
if (editNoteDialogOpen) {
editNoteValue = data.appointment.note ?? '';
@@ -117,6 +154,10 @@
if (data.appointment.status === 'cancelled') cancelDialogOpen = false;
});
$effect(() => {
if (data.appointment.status === 'declined') declineDialogOpen = false;
});
$effect(() => {
const actionData = form as unknown as { success?: string };
if (actionData?.success === 'edited') {
@@ -166,6 +207,7 @@
);
let cancelEntry = $derived(data.appointment.action_log.findLast((e) => e.action === 'cancel'));
let declineEntry = $derived(data.appointment.action_log.findLast((e) => e.action === 'decline'));
let rescheduleEntry = $derived(
data.appointment.action_log.findLast(
(e) => e.action === 'reschedule' && e.payload?.metadata?.next_id === data.appointment.id
@@ -173,7 +215,11 @@
);
let notes = $derived(
[status === 'cancelled' ? cancelEntry : undefined, rescheduleEntry]
[
status === 'cancelled' ? cancelEntry : undefined,
rescheduleEntry,
status === 'declined' ? declineEntry : undefined
]
.filter((e) => e?.payload?.note)
.map((e) => ({ actor: e!.actor, kind: e!.action, text: e!.payload!.note! }))
);
@@ -211,7 +257,7 @@
</p>
</aside>
{/if}
{#if form?.error}
{#if form?.error && !declineDialogOpen}
<aside class="banner banner-danger" role="alert">
<span class="banner-icon"><IconWarningCircle aria-hidden="true" /></span>
<p class="banner-text">{form.error}</p>
@@ -359,7 +405,7 @@
{#each notes as note, i (i)}
<div class="state-note-line">
{#if note.actor === 'host'}Host{:else if note.actor === 'guest'}Guest{:else}System{/if}:
{note.text}
<span class="state-note-text">{note.text}</span>
</div>
{/each}
</div>
@@ -559,13 +605,15 @@
{:else if showDecideCta}
<section class="card-section card-cta decide-cta">
{#if data.actions.decline.allowed}
<form
method="POST"
action="/admin/appointment/{data.appointment.id}?/decline"
class="decide-form"
>
<button type="submit" class="decide-btn decide-decline">Decline</button>
</form>
<div class="decide-form">
<button
type="button"
class="decide-btn decide-decline"
onclick={() => (declineDialogOpen = true)}
>
Decline
</button>
</div>
{/if}
{#if data.actions.accept.allowed}
<form
@@ -596,6 +644,86 @@
/>
{/if}
<Dialog.Root
bind:open={
() => declineDialogOpen,
(open) => {
if (!declining) declineDialogOpen = open;
}
}
>
<Dialog.Portal>
<Dialog.Overlay>
{#snippet child({ props })}
<div {...props} class="dialog-overlay"></div>
{/snippet}
</Dialog.Overlay>
<Dialog.Content>
{#snippet child({ props })}
<div {...props} class="dialog-content cancel-dialog">
<Dialog.Title>
{#snippet child({ props: titleProps })}
<h2 {...titleProps} class="cancel-dialog-title">Decline appointment</h2>
{/snippet}
</Dialog.Title>
{#if form?.error}
<aside class="banner banner-danger" role="alert">
<span class="banner-icon"><IconWarningCircle aria-hidden="true" /></span>
<p class="banner-text">{form.error}</p>
</aside>
{/if}
<form
method="POST"
action="/admin/appointment/{data.appointment.id}?/decline"
use:enhance={submitDecline}
aria-busy={declining}
>
<Dialog.Description>
{#snippet child({ props: descriptionProps })}
<label {...descriptionProps} for="decline-note" class="note-label"
>Note (optional)</label
>
{/snippet}
</Dialog.Description>
<textarea
id="decline-note"
name="decline_note"
class="cancel-reason-input counted-input"
maxlength={LIMIT_REASON}
rows="3"
bind:value={declineNote}
bind:this={declineTextareaEl}
disabled={declining}
></textarea>
<div class="note-count">
<CharacterCount value={declineNote} limit={LIMIT_REASON} />
</div>
<div class="cancel-dialog-actions">
<button type="submit" class="dialog-btn-danger" disabled={declining}>
Decline
</button>
<Dialog.Close>
{#snippet child({ props: closeProps })}
<button
{...closeProps}
type="button"
class="dialog-btn-secondary"
disabled={declining}
>
Close
</button>
{/snippet}
</Dialog.Close>
</div>
</form>
</div>
{/snippet}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<Dialog.Root bind:open={cancelDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay>
@@ -608,40 +736,40 @@
<div {...props} class="dialog-content cancel-dialog">
<Dialog.Title>
{#snippet child({ props: titleProps })}
<h2 {...titleProps} class="cancel-dialog-title">
Provide your reason for cancelling
</h2>
<h2 {...titleProps} class="cancel-dialog-title">Cancel appointment</h2>
{/snippet}
</Dialog.Title>
<p class="cancel-dialog-desc">
{#if isHost}
<strong>{data.appointment.guest_name}</strong> will be notified by email. This can't be
undone.
{:else}
You'll both be notified by email. This can't be undone.
{/if}
</p>
<form
method="POST"
action={isHost
? `/admin/appointment/${data.appointment.id}?/cancel`
: `?token=${encodeURIComponent(data.token)}&/cancel`}
>
<Dialog.Description>
{#snippet child({ props: descriptionProps })}
<label {...descriptionProps} for="appointment-cancel-reason" class="note-label"
>Reason (required)</label
>
{/snippet}
</Dialog.Description>
<textarea
id="appointment-cancel-reason"
name="reason"
class="cancel-reason-input"
class="cancel-reason-input counted-input"
placeholder="I can no longer attend"
maxlength="500"
maxlength={LIMIT_REASON}
rows="3"
required
bind:value={cancelReason}
bind:this={reasonTextarea}
></textarea>
<div class="note-count">
<CharacterCount value={cancelReason} limit={LIMIT_REASON} />
</div>
<input type="hidden" name="token" value={data.token} />
<div class="cancel-dialog-actions">
<button type="submit" class="dialog-btn-danger">Submit</button>
<button type="submit" class="dialog-btn-danger">Cancel</button>
<Dialog.Close>
{#snippet child({ props: closeProps })}
<button {...closeProps} type="button" class="dialog-btn-secondary">Close</button>
@@ -673,21 +801,27 @@
{/snippet}
</Dialog.Title>
<p class="cancel-dialog-desc">
This note will be included in the calendar ICS and email notifications sent to the
guest.
</p>
<form method="POST" action="/admin/appointment/{data.appointment.id}?/edit">
<Dialog.Description>
{#snippet child({ props: descriptionProps })}
<label {...descriptionProps} for="appointment-note" class="note-label"
>Note (optional)</label
>
{/snippet}
</Dialog.Description>
<textarea
id="appointment-note"
name="note"
class="cancel-reason-input"
class="cancel-reason-input counted-input"
placeholder="e.g., Note or instructions for the guest"
maxlength="1000"
maxlength={LIMIT_LONG}
rows="5"
bind:value={editNoteValue}
bind:this={noteTextareaEl}
></textarea>
<div class="note-count">
<CharacterCount value={editNoteValue} limit={LIMIT_LONG} />
</div>
<div class="cancel-dialog-actions edit-note-actions">
<button type="submit" class="dialog-btn-primary">Save</button>
{#if data.appointment.note}
@@ -1305,6 +1439,22 @@
color: var(--when-color-text);
}
.note-label {
display: block;
margin-bottom: var(--space-2);
font-size: var(--font-size-sm);
font-weight: 600;
color: var(--color-text-secondary);
}
.counted-input {
margin-bottom: 0;
}
.note-count {
margin-bottom: var(--space-4);
}
.cancel-reason-input:focus {
outline: none;
border-color: var(--when-color-primary);
@@ -1466,6 +1616,10 @@
color: inherit;
}
.state-note-text {
white-space: pre-wrap;
}
:global(.questions-section[open] + .log-section) {
border-top: none;
}
@@ -7,7 +7,7 @@ import { cancelAppointment } from '$lib/server/appointment/cancel';
import { purgeAppointment } from '$lib/server/appointment/purge';
import { editAppointment } from '$lib/server/appointment/edit';
import { changeAppointmentDuration } from '$lib/server/appointment/change-duration';
import { validateReason } from '$lib/server/appointment/form.server';
import { validateDeclineNote, validateReason } from '$lib/server/appointment/form.server';
import { appointmentContext } from '$lib/server/appointment/context';
import { rotateGuestTokenTransition } from '$lib/server/appointment/transitions';
import { requestVideoChat } from '$lib/server/appointment/request-video-chat';
@@ -31,11 +31,17 @@ export const actions: Actions = {
return { success: 'accepted' };
},
decline: async ({ params }) => {
decline: async ({ params, request }) => {
const row = await findAppointment(getDb(), params.id);
if (!row) return fail(404, { error: 'Appointment not found.' });
const form = await request.formData();
const noteResult = validateDeclineNote(form);
if (!noteResult.ok) return fail(400, { error: noteResult.error });
const result = await declineAppointment(appointmentContext(), { appointment: row });
const result = await declineAppointment(appointmentContext(), {
appointment: row,
note: noteResult.note
});
if (!result.ok) {
return fail(409, { error: 'This appointment can no longer be declined.' });
}
@@ -2,7 +2,8 @@ import { describe, expect, test, vi, beforeEach } from 'vitest';
import { actions, load } from './+page.server';
import { requestVideoChat } from '$lib/server/appointment/request-video-chat';
import { changeAppointmentDuration } from '$lib/server/appointment/change-duration';
import type { Appointment } from '@when/db';
import { declineAppointment } from '$lib/server/appointment/decline';
import { findAppointment, type Appointment } from '@when/db';
import type { RequestEvent } from './$types';
vi.mock('$lib/server/appointment/context', () => ({ appointmentContext: () => context }));
@@ -12,12 +13,30 @@ vi.mock('$lib/server/appointment/change-duration', async (importOriginal) => {
return { ...original, changeAppointmentDuration: vi.fn() };
});
vi.mock('$lib/server/appointment/decline', () => ({ declineAppointment: vi.fn() }));
vi.mock('$lib/server/state', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/state')>();
return { ...original, getDb: () => context.db };
});
vi.mock('@when/db', async (importOriginal) => {
const original = await importOriginal<typeof import('@when/db')>();
return { ...original, findAppointment: vi.fn() };
});
const declineRow = {
id: 'appt-1',
status: 'pending',
note: 'Private preparation note'
} as Appointment;
const context = { db: {}, cfg: {}, clock: {} };
const event = { params: { id: 'appt-1' } } as unknown as RequestEvent;
beforeEach(() => {
vi.mocked(requestVideoChat).mockReset();
vi.mocked(changeAppointmentDuration).mockReset();
vi.mocked(declineAppointment).mockReset();
vi.mocked(findAppointment).mockReset();
vi.mocked(findAppointment).mockResolvedValue(declineRow);
});
describe('admin appointment load', () => {
@@ -80,6 +99,147 @@ describe('generateVideoChat action', () => {
});
});
describe('decline action', () => {
function declineWith(form: FormData) {
return actions.decline({
params: { id: 'appt-1' },
request: new Request('http://localhost', { method: 'POST', body: form })
} as unknown as RequestEvent);
}
test('finds the route row before reading or validating the form', async () => {
vi.mocked(findAppointment).mockResolvedValue(undefined);
const formData = vi.fn();
const result = await actions.decline({
params: { id: 'appt-1' },
request: { formData }
} as unknown as RequestEvent);
expect(result).toEqual({ status: 404, data: { error: 'Appointment not found.' } });
expect(findAppointment).toHaveBeenCalledExactlyOnceWith(context.db, 'appt-1');
expect(formData).not.toHaveBeenCalled();
expect(declineAppointment).not.toHaveBeenCalled();
});
test.each([
['missing', null],
['empty', ''],
['whitespace', ' \t\r\n '],
['controls only', '\u0000\u0007\u007f']
] as const)(
'allows a %s optional note and ignores forged roles and other note fields',
async (_label, value) => {
vi.mocked(declineAppointment).mockResolvedValue({
ok: true,
appointment: { ...declineRow, status: 'declined' }
});
const form = new FormData();
if (value !== null) form.set('decline_note', value);
for (const [key, forged] of Object.entries({
id: 'forged-id',
appointmentId: 'forged-id',
role: 'guest',
viewer: 'guest',
actor: 'guest',
isAdmin: 'false',
note: 'Forged general note',
reason: 'Forged cancellation reason',
reschedule_reason: 'Forged move reason',
status: 'confirmed'
})) {
form.set(key, forged);
}
const result = await declineWith(form);
expect(findAppointment).toHaveBeenCalledExactlyOnceWith(context.db, 'appt-1');
expect(declineAppointment).toHaveBeenCalledExactlyOnceWith(context, {
appointment: declineRow,
note: undefined
});
expect(result).toEqual({ success: 'declined' });
}
);
test.each([
[' \u0000Sorry\r\nTry Friday — またね 👋\u0007\u007f ', 'Sorry\nTry Friday — またね 👋'],
['x'.repeat(500), 'x'.repeat(500)],
['界'.repeat(500), '界'.repeat(500)],
[` ${'x'.repeat(498)}\r\ny\u0007 `, 'x'.repeat(498) + '\ny']
])('passes only the cleaned decline note and fetched row to the service', async (raw, note) => {
vi.mocked(declineAppointment).mockResolvedValue({
ok: true,
appointment: { ...declineRow, status: 'declined' }
});
const form = new FormData();
form.set('decline_note', raw);
form.set('note', 'Do not replace the general appointment note');
form.set('reason', 'Not a cancellation');
form.set('role', 'guest');
form.set('appointmentId', 'forged-id');
const result = await declineWith(form);
expect(findAppointment).toHaveBeenCalledExactlyOnceWith(context.db, 'appt-1');
expect(declineAppointment).toHaveBeenCalledExactlyOnceWith(context, {
appointment: declineRow,
note
});
expect(result).toEqual({ success: 'declined' });
});
test.each([
['501 characters', 'x'.repeat(501), 'Decline note must be 500 characters or fewer.'],
['501 Unicode characters', '界'.repeat(501), 'Decline note must be 500 characters or fewer.'],
['file', new File(['Sorry'], 'note.txt'), 'Please enter a text note.']
] as const)('rejects a %s note without calling decline', async (_label, value, error) => {
const form = new FormData();
form.set('decline_note', value);
const result = await declineWith(form);
expect(findAppointment).toHaveBeenCalledExactlyOnceWith(context.db, 'appt-1');
expect(result).toEqual({ status: 400, data: { error } });
expect(result).not.toHaveProperty('success');
expect(declineAppointment).not.toHaveBeenCalled();
});
test('does not call decline when reading the submitted form fails', async () => {
const error = new Error('Invalid form body');
const formData = vi.fn().mockRejectedValue(error);
const declining = actions.decline({
params: { id: 'appt-1' },
request: { formData }
} as unknown as RequestEvent);
await expect(declining).rejects.toBe(error);
expect(declineAppointment).not.toHaveBeenCalled();
});
test.each(['gated', 'conflict'] as const)(
'maps a %s service result without claiming success',
async (reason) => {
vi.mocked(declineAppointment).mockResolvedValue({ ok: false, reason });
const form = new FormData();
form.set('decline_note', 'Sorry, another time.');
const result = await declineWith(form);
expect(declineAppointment).toHaveBeenCalledExactlyOnceWith(context, {
appointment: declineRow,
note: 'Sorry, another time.'
});
expect(result).toEqual({
status: 409,
data: { error: 'This appointment can no longer be declined.' }
});
expect(result).not.toHaveProperty('success');
}
);
});
describe('changeDuration action', () => {
function changeDurationWith(form: FormData) {
return actions.changeDuration({
@@ -1,20 +1,175 @@
import { describe, expect, test } from 'vitest';
import { appointmentDeclined } from './appointment-declined.js';
import { sampleInput } from '../__fixtures__/appointment.js';
import { renderHtmlBody, renderTextBody } from '../render.js';
import { emailTheme } from '../theme.js';
describe('appointmentDeclined', () => {
test('guest + host messages, no ics', () => {
const [guest, host] = appointmentDeclined(sampleInput);
test('guest + host messages, no note or ics', () => {
const result = appointmentDeclined(sampleInput);
const [guest, host] = result;
expect(result).toHaveLength(2);
expect(guest.role).toBe('guest');
expect(guest.to).toBe('jane@example.com');
expect(guest.content.subject).toBe('Declined: 30-min with Acme Scheduling');
expect(guest.content.heading).toBe('Your appointment request was declined.');
expect(guest.content.paragraphs).toEqual([]);
expect(guest.content.actions).toEqual([]);
expect(guest.ics).toBeUndefined();
expect(host.role).toBe('host');
expect(host.to).toBe('owner@acme.test');
expect(host.content.subject).toBe('Declined: 30-min from Jane Doe');
expect(host.content.paragraphs).toContain(
expect(host.content.paragraphs).toEqual([
'You declined the request from Jane Doe <jane@example.com>.'
);
]);
expect(host.content.actions).toEqual([]);
expect(host.ics).toBeUndefined();
});
test('includes the multiline decline note as escaped plain text in both copies', () => {
const note = 'Sorry, <b>not available</b> & busy.\nPlease try next week.';
const input = {
...sampleInput,
appointment: {
...sampleInput.appointment,
note: 'General appointment note',
action_log: JSON.stringify([
{
action: 'decline',
actor: 'host',
at: '2026-01-02T12:00:00Z',
payload: { note }
},
{
action: 'cancel',
actor: 'host',
at: '2026-01-03T12:00:00Z',
payload: { note: 'Cancellation note' }
}
])
}
};
const result = appointmentDeclined(input);
const [guest, host] = result;
expect(guest.content.paragraphs).toEqual([`Note: ${note}`]);
expect(host.content.paragraphs).toEqual([
'You declined the request from Jane Doe <jane@example.com>.',
`Note: ${note}`
]);
for (const message of result) {
const html = renderHtmlBody(message.content, emailTheme(input.cfg.user.appearance));
const text = renderTextBody(message.content);
expect(html).toContain(
'Note: Sorry, &lt;b&gt;not available&lt;/b&gt; &amp; busy.\nPlease try next week.'
);
expect(html).toContain('<span style="white-space: pre-line">Note: Sorry,');
expect(html).not.toContain('<b>not available</b>');
expect(text).toContain(`Note: ${note}`);
for (const body of [html, text]) {
expect(body).not.toContain('General appointment note');
expect(body).not.toContain('Cancellation note');
}
}
});
test('uses only the latest decline entry', () => {
const [guest, host] = appointmentDeclined({
...sampleInput,
appointment: {
...sampleInput.appointment,
action_log: JSON.stringify([
{
action: 'decline',
actor: 'host',
at: '2026-01-02T12:00:00Z',
payload: { note: 'Earlier decline note' }
},
{
action: 'decline',
actor: 'host',
at: '2026-01-03T12:00:00Z',
payload: { note: 'Latest decline note' }
}
])
}
});
expect(guest.content.paragraphs).toEqual(['Note: Latest decline note']);
expect(host.content.paragraphs).toEqual([
'You declined the request from Jane Doe <jane@example.com>.',
'Note: Latest decline note'
]);
});
test.each([
{ name: 'no action log', actionLog: null },
{
name: 'only a cancellation note',
actionLog: JSON.stringify([
{
action: 'cancel',
actor: 'host',
at: '2026-01-02T12:00:00Z',
payload: { note: 'Cancellation note' }
}
])
},
...[undefined, {}, { note: '' }].map((payload) => ({
name: `latest decline has no note (${JSON.stringify(payload)})`,
actionLog: JSON.stringify([
{
action: 'decline',
actor: 'host',
at: '2026-01-02T12:00:00Z',
payload: { note: 'Earlier decline note' }
},
{ action: 'decline', actor: 'host', at: '2026-01-03T12:00:00Z', payload }
])
}))
])('preserves old paragraphs without falling back when $name', ({ actionLog }) => {
const [guest, host] = appointmentDeclined({
...sampleInput,
appointment: {
...sampleInput.appointment,
note: 'General appointment note',
action_log: actionLog
}
});
expect(guest.content.paragraphs).toEqual([]);
expect(host.content.paragraphs).toEqual([
'You declined the request from Jane Doe <jane@example.com>.'
]);
});
test('still sends only the host copy when the guest has no email', () => {
const result = appointmentDeclined({
...sampleInput,
appointment: {
...sampleInput.appointment,
guest_email: null,
action_log: JSON.stringify([
{
action: 'decline',
actor: 'host',
at: '2026-01-02T12:00:00Z',
payload: { note: 'Please try next week.' }
}
])
}
});
expect(result).toHaveLength(1);
expect(result[0].role).toBe('host');
expect(result[0].to).toBe('owner@acme.test');
expect(result[0].content.paragraphs).toEqual([
'You declined the request from Jane Doe.',
'Note: Please try next week.'
]);
expect(result[0].ics).toBeUndefined();
});
});
@@ -14,6 +14,7 @@ import {
} from '../recipients.js';
import type { EmailContent } from '../content.js';
import type { AppointmentEmailInput } from '../types.js';
import { parseActionLog } from '@when/db';
export function appointmentDeclined(i: AppointmentEmailInput): AppointmentEmailMessage[] {
const a = i.appointment;
@@ -21,12 +22,14 @@ export function appointmentDeclined(i: AppointmentEmailInput): AppointmentEmailM
const eventName = eventTypeName(i.eventType, a);
const guestWhen = whenForGuest(i);
const hostWhen = whenForHost(i);
const decline = parseActionLog(a.action_log).findLast((e) => e.action === 'decline');
const noteParagraphs = decline?.payload?.note ? [`Note: ${decline.payload.note}`] : [];
const guest: EmailContent = {
brand,
subject: `Declined: ${eventName} with ${brand.name}`,
heading: 'Your appointment request was declined.',
paragraphs: [],
paragraphs: noteParagraphs,
rows: [
{ label: 'What', value: eventName },
{ label: 'When', value: guestWhen }
@@ -38,7 +41,7 @@ export function appointmentDeclined(i: AppointmentEmailInput): AppointmentEmailM
brand,
subject: `Declined: ${eventName} from ${a.guest_name}`,
heading: 'Appointment declined',
paragraphs: [`You declined the request from ${guestLabel(a)}.`],
paragraphs: [`You declined the request from ${guestLabel(a)}.`, ...noteParagraphs],
rows: [
{ label: 'What', value: eventName },
{ label: 'When', value: hostWhen },
@@ -110,7 +110,7 @@
</h1>
<% it.paragraphs.forEach(function (p) { %>
<p style="margin: 0 0 16px; font-size: 16px; line-height: 1.6; color: <%= it.theme.bodyText %>; word-break: break-word;">
<%= p %>
<span style="white-space: pre-line"><%= p %></span>
</p>
<% }) %> <% const rows = it.rows.filter(function (r) { return r.value; }); %> <% if
(rows.length) { %>