feat(web): let hosts edit appointment duration in place #88

Merged
eleith merged 1 commits from feat/host-duration-editor into main 2026-10-24 20:06:15 +00:00
19 changed files with 2239 additions and 1 deletions

No files matched your search

+7
View File
@@ -0,0 +1,7 @@
---
'@when/web': patch
'@when/worker': patch
'@when/cli': patch
---
Let hosts change an existing appointment's duration without moving its start time or replacing the booking. Check actual busy-time conflicts while ignoring buffers, daily caps and working hours. Keep calendar/invite updates, duration history and existing edit notifications consistent, and distinguish saved changes from unconfirmed background-update submission.
+473
View File
@@ -0,0 +1,473 @@
import { expect, test, type Page } from '@playwright/test';
import { parseActionLog } from '@when/db';
import { signInAsAdmin } from './support/auth.ts';
import { readAppointment, readQueuedWorkflows } from './support/database.ts';
import { seedAppointment } from './support/seed.ts';
const postOptions = {
headers: { origin: 'http://localhost:4183', accept: 'application/json' },
maxRedirects: 0
};
const savedWarning =
'The duration was saved, but calendar and email update submission could not be confirmed.';
function futureWeekday(offset: number, retry: number): string {
const date = new Date();
date.setUTCDate(date.getUTCDate() + offset);
for (let attempt = 0; attempt <= retry; attempt++) {
if (attempt > 0) date.setUTCDate(date.getUTCDate() + 1);
while (date.getUTCDay() === 0 || date.getUTCDay() === 6) {
date.setUTCDate(date.getUTCDate() + 1);
}
}
return date.toISOString().slice(0, 10);
}
function durationAction(id: string): string {
return `/admin/appointment/${id}?/changeDuration`;
}
async function openDurationEditor(page: Page) {
await page.getByRole('button', { name: 'Appointment actions', exact: true }).click();
await page.getByRole('menuitem', { name: 'Change duration', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Change duration', exact: true });
await expect(dialog).toBeVisible();
return dialog;
}
// Evening intervals keep these edits disjoint from the older daytime scheduling cases.
test('host extends in place outside working hours, touching a neighbor despite buffers and cap', async ({
page,
request
}, testInfo) => {
const day = futureWeekday(17, testInfo.retry);
const original = await seedAppointment({
eventTypeId: 'host-rules',
status: 'confirmed',
start: new Date(`${day}T18:00:00Z`)
});
const neighbor = await seedAppointment({
eventTypeId: 'host-rules',
status: 'confirmed',
start: new Date(`${day}T19:30:00Z`)
});
const actionPath = durationAction(original.id);
await page.goto(`/appointment/${original.id}?token=${original.cancel_token}`);
await page.getByRole('button', { name: 'Appointment actions', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Change duration', exact: true })).toHaveCount(0);
await expect(page.getByRole('dialog', { name: 'Change duration', exact: true })).toHaveCount(0);
await page.keyboard.press('Escape');
const forbidden = await request.post(actionPath, {
...postOptions,
form: { duration: '90', token: original.cancel_token, role: 'host', isAdmin: 'true' }
});
expect(forbidden.status()).toBe(403);
const beforeEdit = await readAppointment(original.id);
expect(beforeEdit).toEqual(original);
await signInAsAdmin(page);
await page.goto(`/appointment/${original.id}`);
await page.emulateMedia({ colorScheme: 'light' });
const dialog = await openDurationEditor(page);
await expect(dialog.getByLabel('Duration', { exact: true })).toHaveValue('30');
await dialog.getByLabel('Duration', { exact: true }).selectOption('90');
const desktopScreenshot = await page.screenshot({
path: testInfo.outputPath('duration-editor-desktop-light.png')
});
await testInfo.attach('duration-editor-desktop-light', {
body: desktopScreenshot,
contentType: 'image/png'
});
const responsePromise = page.waitForResponse(
(response) => response.request().method() === 'POST' && response.url().endsWith(actionPath)
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const response = await responsePromise;
const result = await response.json();
expect(result).toMatchObject({ type: 'success', status: 200 });
expect(result.data).toContain('duration_changed');
await expect(dialog.getByRole('status')).toHaveText('Duration saved.');
await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled();
await expect(dialog.getByLabel('Duration', { exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toHaveCount(0);
const saved = await readAppointment(original.id);
expect(saved).toMatchObject({
id: original.id,
start_time: original.start_time,
status: original.status,
cancel_token: original.cancel_token,
origin_id: original.origin_id,
video_chat: original.video_chat,
video_chat_request: original.video_chat_request,
calendar_revision: original.calendar_revision + 1,
ics_sequence: original.ics_sequence + 1
});
expect(Date.parse(saved.end_time) - Date.parse(saved.start_time)).toBe(90 * 60_000);
expect(Date.parse(saved.end_time)).toBe(Date.parse(neighbor.start_time));
const log = parseActionLog(saved.action_log);
expect(log).toHaveLength(parseActionLog(original.action_log).length + 1);
expect(log.at(-1)).toMatchObject({
action: 'edit',
actor: 'host',
payload: {
metadata: { changes: ['duration_updated'], previous_duration: 30, duration: 90 }
}
});
expect(readQueuedWorkflows(`${original.id}:edited-by-host:${saved.ics_sequence}`)).toEqual([
{ workflow_name: 'reconcile-appointment' }
]);
const unchangedNeighbor = await readAppointment(neighbor.id);
expect(unchangedNeighbor).toEqual(neighbor);
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.locator('.detail-primary').filter({ hasText: /6:00.*7:30/ })).toBeVisible();
await page.getByText('Activity log', { exact: true }).click();
await expect(
page.getByText('Duration changed from 30 to 90 minutes', { exact: true })
).toBeVisible();
});
test('actual overlap stays inline and the same dialog can shorten to configured 25, not invented 75', async ({
page
}, testInfo) => {
const day = futureWeekday(28, testInfo.retry);
const original = await seedAppointment({
eventTypeId: 'host-durations',
status: 'confirmed',
start: new Date(`${day}T18:00:00Z`),
durationMinutes: 60
});
const neighbor = await seedAppointment({
eventTypeId: 'host-durations',
status: 'confirmed',
start: new Date(`${day}T19:15:00Z`)
});
const actionPath = durationAction(original.id);
await signInAsAdmin(page);
const unsupported = await page.request.post(actionPath, {
...postOptions,
form: {
duration: '75',
currentDuration: '75',
id: neighbor.id,
start_time: `${day}T09:00:00Z`
}
});
const unsupportedResult = await unsupported.json();
expect(unsupportedResult).toMatchObject({ type: 'failure', status: 400 });
expect(unsupportedResult.data).toContain('Please pick a valid meeting length.');
await page.goto(`/appointment/${original.id}`);
const dialog = await openDurationEditor(page);
await expect(dialog.getByRole('option', { name: '25 minutes', exact: true })).toHaveCount(1);
await expect(dialog.getByRole('option', { name: '75 minutes', exact: true })).toHaveCount(0);
await dialog.getByLabel('Duration', { exact: true }).selectOption('90');
const rejectedPromise = page.waitForResponse(
(response) => response.request().method() === 'POST' && response.url().endsWith(actionPath)
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const rejected = await rejectedPromise;
const rejectedResult = await rejected.json();
expect(rejectedResult).toMatchObject({ type: 'failure', status: 409 });
await expect(dialog.getByRole('alert')).toHaveText(
'That length overlaps another appointment or busy event.'
);
await expect(dialog.getByLabel('Duration', { exact: true })).toHaveValue('90');
await expect(dialog.getByLabel('Duration', { exact: true })).toBeEnabled();
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toBeEnabled();
await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toBeEnabled();
const unchanged = await readAppointment(original.id);
expect(unchanged).toEqual(original);
await dialog.getByLabel('Duration', { exact: true }).selectOption('25');
const savedPromise = page.waitForResponse(
(response) => response.request().method() === 'POST' && response.url().endsWith(actionPath)
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const savedResponse = await savedPromise;
const savedResult = await savedResponse.json();
expect(savedResult).toMatchObject({ type: 'success', status: 200 });
expect(savedResult.data).toContain('duration_changed');
// The browser sends an absolute duration only; identity/start are read by the server.
const submittedBody = savedResponse.request().postData();
expect(submittedBody).toContain('name="duration"');
expect(submittedBody).not.toMatch(/name="(?:id|start|start_time|slot)"/);
await expect(dialog.getByRole('status')).toHaveText('Duration saved.');
await expect(dialog.getByRole('alert')).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled();
const saved = await readAppointment(original.id);
expect(saved.start_time).toBe(original.start_time);
expect(Date.parse(saved.end_time) - Date.parse(original.start_time)).toBe(25 * 60_000);
const unchangedNeighbor = await readAppointment(neighbor.id);
expect(unchangedNeighbor).toEqual(neighbor);
const noOp = await page.request.post(actionPath, {
...postOptions,
form: { duration: '25', id: neighbor.id, start_time: `${day}T09:00:00Z` }
});
const noOpResult = await noOp.json();
expect(noOpResult).toMatchObject({ type: 'success', status: 200 });
expect(noOpResult.data).toContain('no_change');
const afterNoOp = await readAppointment(original.id);
expect(afterNoOp).toEqual(saved);
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.locator('.detail-primary').filter({ hasText: /6:00.*6:25/ })).toBeVisible();
});
test('in-progress edits require a future end; ended pending appointments have no editor', async ({
page
}) => {
// Only this case uses a live interval: 15 ends five minutes ago, 30 ten minutes ahead.
const start = new Date(Date.now() - 20 * 60_000);
const original = await seedAppointment({
eventTypeId: 'host-rules',
status: 'confirmed',
start,
durationMinutes: 60
});
const actionPath = durationAction(original.id);
await signInAsAdmin(page);
await page.goto(`/appointment/${original.id}`);
await expect(page.getByRole('heading', { name: 'In progress', exact: true })).toBeVisible();
const dialog = await openDurationEditor(page);
await dialog.getByLabel('Duration', { exact: true }).selectOption('15');
const rejectedPromise = page.waitForResponse(
(response) => response.request().method() === 'POST' && response.url().endsWith(actionPath)
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const rejected = await rejectedPromise;
const rejectedResult = await rejected.json();
expect(rejectedResult).toMatchObject({ type: 'failure', status: 400 });
await expect(dialog.getByRole('alert')).toHaveText('Choose a length that ends in the future.');
const unchanged = await readAppointment(original.id);
expect(unchanged).toEqual(original);
await dialog.getByLabel('Duration', { exact: true }).selectOption('30');
const savedPromise = page.waitForResponse(
(response) => response.request().method() === 'POST' && response.url().endsWith(actionPath)
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const savedResponse = await savedPromise;
const savedResult = await savedResponse.json();
expect(savedResult).toMatchObject({ type: 'success', status: 200 });
expect(savedResult.data).toContain('duration_changed');
await expect(dialog.getByRole('status')).toHaveText('Duration saved.');
await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled();
const saved = await readAppointment(original.id);
expect(saved.start_time).toBe(original.start_time);
expect(Date.parse(saved.end_time) - start.getTime()).toBe(30 * 60_000);
expect(saved.status).toBe('confirmed');
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'In progress', exact: true })).toBeVisible();
const endedPending = await seedAppointment({
eventTypeId: 'host-rules',
status: 'pending',
start: new Date(start.getTime() - 2 * 60 * 60_000)
});
await page.goto(`/appointment/${endedPending.id}`);
await expect(page.getByRole('heading', { name: 'Pending', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Appointment actions', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Change duration', exact: true })).toHaveCount(0);
await expect(page.getByRole('dialog', { name: 'Change duration', exact: true })).toHaveCount(0);
const gated = await page.request.post(durationAction(endedPending.id), {
...postOptions,
form: { duration: '90' }
});
const gatedResult = await gated.json();
expect(gatedResult).toMatchObject({ type: 'failure', status: 400 });
expect(gatedResult.data).toContain('This appointment cannot be edited.');
const unchangedPending = await readAppointment(endedPending.id);
expect(unchangedPending).toEqual(endedPending);
});
test('a saved-warning receipt waits for explicit reload and cannot blindly resubmit', async ({
page
}, testInfo) => {
const day = futureWeekday(38, testInfo.retry);
const original = await seedAppointment({
eventTypeId: 'host-rules',
status: 'confirmed',
start: new Date(`${day}T18:00:00Z`)
});
const actionPath = durationAction(original.id);
await signInAsAdmin(page);
await page.setViewportSize({ width: 320, height: 720 });
await page.emulateMedia({ colorScheme: 'dark' });
await page.goto(`/appointment/${original.id}`);
let submissions = 0;
const refreshRequests: string[] = [];
page.on('request', (request) => {
const path = new URL(request.url()).pathname;
if (
request.method() === 'GET' &&
(path === `/appointment/${original.id}` || path === `/appointment/${original.id}/__data.json`)
) {
refreshRequests.push(request.url());
}
});
await page.route(`**${actionPath}`, async (route) => {
submissions++;
const realResponse = await route.fetch();
const realResult = await realResponse.json();
expect(realResult).toMatchObject({ type: 'success', status: 200 });
expect(realResult.data).toContain('duration_changed');
// Public SvelteKit ActionResult wire format: data is a devalue flat table.
// This simulates a saved-warning RECEIPT after a real commit, not a queue failure.
await route.fulfill({
status: 500,
contentType: 'application/json',
body: JSON.stringify({
type: 'failure',
status: 500,
data: JSON.stringify([{ error: 1, durationSaved: 2 }, savedWarning, true])
})
});
});
const dialog = await openDurationEditor(page);
await dialog.getByLabel('Duration', { exact: true }).selectOption('90');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog.getByRole('alert')).toHaveText(savedWarning);
await expect(dialog.getByLabel('Duration', { exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toHaveCount(0);
await expect(
dialog.getByRole('button', { name: 'Reload appointment', exact: true })
).toBeEnabled();
const saved = await readAppointment(original.id);
expect(Date.parse(saved.end_time) - Date.parse(original.start_time)).toBe(90 * 60_000);
expect(saved.ics_sequence).toBe(original.ics_sequence + 1);
const mobileScreenshot = await page.screenshot({
path: testInfo.outputPath('duration-editor-saved-warning-mobile-320-dark.png')
});
await testInfo.attach('duration-editor-saved-warning-mobile-320-dark', {
body: mobileScreenshot,
contentType: 'image/png'
});
await page.keyboard.press('Escape');
await expect(dialog.getByRole('alert')).toHaveText(savedWarning);
expect(refreshRequests).toEqual([]);
const reload = page.waitForNavigation({ waitUntil: 'load' });
await dialog.getByRole('button', { name: 'Reload appointment', exact: true }).click();
await reload;
await expect(dialog).not.toBeVisible();
await expect(page.locator('.detail-primary').filter({ hasText: /6:00.*7:30/ })).toBeVisible();
await expect(page.getByRole('alert')).toHaveCount(0);
expect(refreshRequests).toHaveLength(1);
const reopened = await openDurationEditor(page);
await expect(reopened.getByLabel('Duration', { exact: true })).toHaveValue('90');
await expect(reopened.getByRole('alert')).toHaveCount(0);
expect(submissions).toBe(1);
});
test('an aborted action reports uncertainty, not rollback, and never automatically repeats', async ({
page
}, testInfo) => {
const day = futureWeekday(8, testInfo.retry);
const original = await seedAppointment({
eventTypeId: 'host-rules',
status: 'confirmed',
start: new Date(`${day}T18:00:00Z`)
});
const actionPath = durationAction(original.id);
await signInAsAdmin(page);
await page.goto(`/appointment/${original.id}`);
let submissions = 0;
await page.route(`**${actionPath}`, async (route) => {
submissions++;
// Controlled transport loss before forwarding; a real lost response need not roll back.
await route.abort('failed');
});
const dialog = await openDurationEditor(page);
await dialog.getByLabel('Duration', { exact: true }).selectOption('90');
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
await expect(dialog.getByRole('alert')).toHaveText(
'Could not confirm whether the duration was saved. Reload the page to check before making another change.'
);
await expect(dialog).not.toContainText(/save failed|rolled back|not saved/i);
await expect(dialog.getByRole('status')).toHaveCount(0);
await expect(dialog.getByLabel('Duration', { exact: true })).toBeDisabled();
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Reload to check', exact: true })).toBeEnabled();
const unchanged = await readAppointment(original.id);
expect(unchanged).toEqual(original);
await page.keyboard.press('Escape');
await expect(dialog.getByRole('alert')).toContainText('Could not confirm whether');
await dialog.getByRole('button', { name: 'Reload to check', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.locator('.detail-primary').filter({ hasText: /6:00.*6:30/ })).toBeVisible();
expect(submissions).toBe(1);
});
test('known saved survives unavailable refresh until the user explicitly acknowledges it', async ({
page
}, testInfo) => {
const day = futureWeekday(55, testInfo.retry);
const original = await seedAppointment({
eventTypeId: 'host-rules',
status: 'confirmed',
start: new Date(`${day}T18:00:00Z`)
});
const actionPath = durationAction(original.id);
const detailPath = `/appointment/${original.id}`;
await signInAsAdmin(page);
await page.goto(detailPath);
let submissions = 0;
page.on('request', (request) => {
if (request.method() === 'POST' && request.url().endsWith(actionPath)) submissions++;
});
const refreshRequests: string[] = [];
// Public HTTP failures only: an automatic data request would abort and could destroy
// the receipt. An explicitly acknowledged document reload gets a bounded error page.
await page.route(
(url) => url.pathname === detailPath || url.pathname === `${detailPath}/__data.json`,
async (route) => {
const request = route.request();
refreshRequests.push(request.url());
if (request.isNavigationRequest()) {
await route.fulfill({
status: 503,
contentType: 'text/html',
body: '<!doctype html><title>Reload unavailable</title><h1>Appointment reload unavailable</h1>'
});
} else {
await route.abort('failed');
}
}
);
const dialog = await openDurationEditor(page);
await dialog.getByLabel('Duration', { exact: true }).selectOption('90');
const responsePromise = page.waitForResponse(
(response) => response.request().method() === 'POST' && response.url().endsWith(actionPath)
);
await dialog.getByRole('button', { name: 'Save', exact: true }).click();
const response = await responsePromise;
const result = await response.json();
expect(result).toMatchObject({ type: 'success', status: 200 });
expect(result.data).toContain('duration_changed');
await expect(dialog.getByRole('status')).toHaveText('Duration saved.');
const saved = await readAppointment(original.id);
expect(Date.parse(saved.end_time) - Date.parse(original.start_time)).toBe(90 * 60_000);
expect(saved.calendar_revision).toBe(original.calendar_revision + 1);
await expect(dialog.getByRole('alert')).toHaveCount(0);
await expect(dialog).not.toContainText(/could not confirm whether|save failed|not saved/i);
await expect(dialog.getByRole('button', { name: 'Save', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Close', exact: true })).toHaveCount(0);
await expect(dialog.getByRole('button', { name: 'Done', exact: true })).toBeEnabled();
await page.keyboard.press('Escape');
await expect(dialog.getByRole('status')).toHaveText('Duration saved.');
expect(refreshRequests).toEqual([]);
expect(submissions).toBe(1);
await dialog.getByRole('button', { name: 'Done', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Appointment reload unavailable' })).toBeVisible();
await expect(dialog).not.toBeVisible();
expect(refreshRequests).toHaveLength(1);
await expect(page.locator('body')).not.toContainText(/save failed|not saved|rolled back/i);
const afterAcknowledgement = await readAppointment(original.id);
expect(afterAcknowledgement).toEqual(saved);
expect(submissions).toBe(1);
});
@@ -24,6 +24,7 @@
onGenerateVideoChat?: () => void;
onRemoveVideoChat?: () => void;
onEditVideoChat?: () => void;
onEditDuration?: () => void;
isEditable?: boolean;
}
@@ -43,6 +44,7 @@
onGenerateVideoChat,
onRemoveVideoChat,
onEditVideoChat,
onEditDuration,
isEditable = false
}: Props = $props();
@@ -122,6 +124,20 @@
<div {...wrapperProps}>
<div {...props} class="ba-menu">
{#if isHost && isEditable}
{#if onEditDuration}
<DropdownMenu.Item
onSelect={() => {
menuOpen = false;
onEditDuration();
}}
>
{#snippet child({ props: itemProps })}
<button {...itemProps} type="button" class="action-item"
>Change duration</button
>
{/snippet}
</DropdownMenu.Item>
{/if}
{#if onEditNote && !hasNote}
<DropdownMenu.Item
onSelect={() => {
@@ -124,6 +124,14 @@
Note updated
{:else if changes.includes('note_removed')}
Note removed
{:else if changes.includes('duration_updated')}
{@const previousDuration = entry.payload?.metadata?.previous_duration}
{@const duration = entry.payload?.metadata?.duration}
{#if typeof previousDuration === 'number' && Number.isFinite(previousDuration) && previousDuration > 0 && typeof duration === 'number' && Number.isFinite(duration) && duration > 0}
Duration changed from {previousDuration} to {duration} minutes
{:else}
Duration changed
{/if}
{:else}
Details updated
{/if}
@@ -0,0 +1,280 @@
<script lang="ts">
import { Dialog } from 'bits-ui';
import { deserialize } from '$app/forms';
import { untrack } from 'svelte';
interface Props {
open?: boolean;
appointmentId: string;
currentDuration: number;
durations: number[];
}
let { open = $bindable(false), appointmentId, currentDuration, durations }: Props = $props();
const durationId = $props.id();
type Outcome = 'editing' | 'duration_changed' | 'no_change' | 'updates_unconfirmed' | 'uncertain';
let draft = $state(0);
let choices = $state<number[]>([]);
let saving = $state(false);
let error = $state<string | null>(null);
let outcome = $state<Outcome>('editing');
$effect(() => {
if (open) {
// Read projected choices only on opening, not when props change.
untrack(() => {
draft = currentDuration;
choices = [...durations];
error = null;
outcome = 'editing';
});
}
});
async function save(event: SubmitEvent) {
event.preventDefault();
if (saving || outcome !== 'editing') return;
saving = true;
error = null;
const form = new FormData();
form.set('duration', String(draft));
try {
let result: ReturnType<typeof deserialize>;
try {
const response = await fetch(`/admin/appointment/${appointmentId}?/changeDuration`, {
method: 'POST',
headers: { accept: 'application/json', 'x-sveltekit-action': 'true' },
body: form
});
const body = await response.text();
result = deserialize(body);
} catch {
outcome = 'uncertain';
return;
}
if (
result?.type === 'success' &&
(result.data?.success === 'duration_changed' || result.data?.success === 'no_change')
) {
outcome = result.data.success;
} else if (result?.type === 'failure' && result.data?.durationSaved === true) {
outcome = 'updates_unconfirmed';
} else if (result?.type === 'failure' && typeof result.data?.error === 'string') {
error = result.data.error;
return;
} else {
// Redirects, error envelopes and unrecognized data do not establish whether a write happened.
outcome = 'uncertain';
return;
}
} finally {
saving = false;
}
}
</script>
<Dialog.Root
bind:open={
() => open,
(value) => {
if (!saving && outcome === 'editing') open = value;
}
}
>
<Dialog.Portal>
<Dialog.Overlay>
{#snippet child({ props })}
<div {...props} class="dialog-overlay"></div>
{/snippet}
</Dialog.Overlay>
<Dialog.Content>
{#snippet child({ props })}
<div {...props} class="duration-editor">
<Dialog.Title>
{#snippet child({ props: titleProps })}
<h2 {...titleProps}>Change duration</h2>
{/snippet}
</Dialog.Title>
<Dialog.Description>
{#snippet child({ props: descriptionProps })}
<p {...descriptionProps}>
The start time stays the same. Calendar and email updates will be requested when the
duration changes.
</p>
{/snippet}
</Dialog.Description>
<form onsubmit={save} aria-busy={saving}>
<label for={durationId}>Duration</label>
<select
id={durationId}
name="duration"
bind:value={draft}
disabled={saving || outcome !== 'editing'}
>
{#each choices as minutes (minutes)}
<option value={minutes}>{minutes} minutes</option>
{/each}
</select>
{#if error}
<p class="error" role="alert">{error}</p>
{/if}
{#if outcome === 'duration_changed'}
<p role="status">Duration saved.</p>
{:else if outcome === 'no_change'}
<p role="status">Duration unchanged.</p>
{:else if outcome === 'updates_unconfirmed'}
<p class="warning" role="alert">
The duration was saved, but calendar and email update submission could not be
confirmed.
</p>
{:else if outcome === 'uncertain'}
<p class="warning" role="alert">
Could not confirm whether the duration was saved. Reload the page to check before
making another change.
</p>
{/if}
<div class="dialog-actions">
{#if outcome === 'editing'}
<button type="submit" class="save" disabled={saving}>
{#if saving}Saving…{:else}Save{/if}
</button>
<Dialog.Close>
{#snippet child({ props: closeProps })}
<button {...closeProps} type="button" disabled={saving}>Close</button>
{/snippet}
</Dialog.Close>
{:else}
<button type="button" disabled={saving} onclick={() => window.location.reload()}>
{#if outcome === 'uncertain'}Reload to check{:else if outcome === 'updates_unconfirmed'}Reload
appointment{:else}Done{/if}
</button>
{/if}
</div>
</form>
</div>
{/snippet}
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
<style>
.dialog-overlay {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--when-color-text) 40%, transparent);
z-index: 200;
}
.duration-editor {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 201;
box-sizing: border-box;
width: min(calc(100vw - var(--space-7) * 2), calc(var(--space-10) * 7));
max-height: calc(100dvh - var(--space-7) * 2);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--space-5);
padding: var(--space-6);
border: var(--space-1) solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--when-color-text);
box-shadow: var(--shadow-card);
}
h2 {
margin: 0;
font-size: var(--font-size-xl);
}
p {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--font-size-md);
line-height: 1.5;
}
form,
label {
display: flex;
flex-direction: column;
gap: var(--space-4);
font-size: var(--font-size-md);
}
select,
button {
min-height: calc(var(--space-8) + var(--space-4));
padding: var(--space-3) var(--space-5);
border: var(--space-1) solid var(--color-border-strong);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--when-color-text);
font: inherit;
}
button {
cursor: pointer;
font-weight: 600;
}
button:hover:not(:disabled) {
background: var(--color-surface-muted);
}
button.save {
background: var(--when-color-primary);
border-color: var(--when-color-primary);
color: var(--when-color-text-on-primary);
}
button.save:hover:not(:disabled) {
background: var(--when-color-primary);
}
button:disabled,
select:disabled {
cursor: not-allowed;
color: var(--color-text-disabled);
}
button:focus-visible,
select:focus-visible {
outline: var(--space-1) solid var(--when-color-primary);
outline-offset: var(--space-1);
box-shadow: var(--shadow-focus);
}
.dialog-actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.error,
.warning {
padding: var(--space-4);
border: var(--space-1) solid var(--color-danger-border);
border-radius: var(--radius);
background: var(--color-danger-bg);
color: var(--color-danger-strong);
}
.warning {
border-color: var(--color-warning-border);
background: var(--color-warning-bg);
color: var(--color-warning-strong);
}
</style>
@@ -51,6 +51,10 @@ export function isRescheduleAllowed(
return now.getTime() + minimumNoticeMinutes * 60 * 1000 <= startMs;
}
export function isEditAllowed(row: Pick<Appointment, 'end_time' | 'status'>, now: Date): boolean {
return ACTIVE_STATUSES.has(row.status) && now.getTime() < Date.parse(row.end_time);
}
export type ViewableRow = WindowedRow & Pick<Appointment, 'origin_id' | 'cancel_token'>;
export async function isViewAllowed(
@@ -0,0 +1,578 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { sql } from 'kysely';
import {
openDb,
parseActionLog,
replaceCalendarBusy,
runMigrations,
withWriteTransaction,
type Appointment,
type NewAppointment
} from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import { changeAppointmentDuration } from './change-duration';
import { editAppointment } from './edit';
import type { AppointmentContext } from './context';
vi.mock('../workflow', () => ({ enqueueAppointmentReconciliation: vi.fn() }));
import { enqueueAppointmentReconciliation } from '../workflow';
const start = '2099-01-04T15:07:00Z';
const initialHistory = [{ action: 'create', actor: 'guest', at: '2098-12-01T12:00:00Z' }];
const baseRow: NewAppointment = {
id: 'duration',
event_type_id: '30-min-chat',
start_time: start,
end_time: '2099-01-04T15:37:00Z',
status: 'confirmed',
cancel_token: 'unchanged-token',
origin_id: 'original-appointment',
guest_name: 'Guest',
guest_email: 'guest@example.com',
guest_timezone: 'Pacific/Auckland',
guest_answers: JSON.stringify({ goals: 'Keep these answers' }),
location: 'Existing location',
note: 'Existing note',
video_chat: 'https://video.example/keep',
video_chat_request: 'generate',
external_event_id: 'published-event',
external_calendar_id: 'my-google-cal',
calendar_revision: 7,
calendar_synced_revision: 7,
ics_sequence: 4,
has_possible_conflict: 1,
meeting_snapshot: JSON.stringify(validConfig.meetings['30-min-chat']),
action_log: JSON.stringify(initialHistory),
created_at: '2098-12-01 12:00:00',
updated_at: '2098-12-01 12:00:00'
};
let db: ReturnType<typeof openDb>;
let cfg: typeof validConfig;
let now: string;
let statements: string[];
function context(): AppointmentContext {
return { db, cfg, clock: { now: () => new Date(now), nowMs: () => Date.parse(now) } };
}
async function seed(changes: Partial<NewAppointment> = {}) {
await db
.insertInto('appointments')
.values({ ...baseRow, ...changes })
.execute();
}
async function row(id = baseRow.id) {
const saved = await db
.selectFrom('appointments')
.selectAll()
.where('id', '=', id)
.executeTakeFirstOrThrow();
return saved;
}
async function change(durationMinutes: number) {
const result = await changeAppointmentDuration(context(), {
appointmentId: baseRow.id,
durationMinutes
});
return result;
}
async function expectUnwritten(before: Appointment) {
const saved = await row();
expect(saved).toEqual(before);
expect(enqueueAppointmentReconciliation).not.toHaveBeenCalled();
}
beforeEach(async () => {
const root = openDb(':memory:');
await runMigrations(root);
statements = [];
db = root.withPlugin({
transformQuery({ node, queryId }) {
statements.push(root.getExecutor().compileQuery(node, queryId).sql);
return node;
},
async transformResult({ result }) {
return result;
}
});
cfg = structuredClone(validConfig);
now = '2099-01-04T14:00:00Z';
vi.mocked(enqueueAppointmentReconciliation).mockReset();
vi.mocked(enqueueAppointmentReconciliation).mockResolvedValue(undefined);
});
afterEach(async () => {
vi.restoreAllMocks();
await db.destroy();
});
describe('changeAppointmentDuration: one in-place host edit', () => {
test.each([
{ status: 'pending', video_chat_request: null },
{ status: 'pending', video_chat_request: 'generate' },
{ status: 'pending', video_chat_request: 'suppress' },
{ status: 'confirmed', video_chat_request: null },
{ status: 'confirmed', video_chat_request: 'generate' },
{ status: 'confirmed', video_chat_request: 'suppress' }
] as const)(
'preserves every non-duration field for $status / $video_chat_request',
async (fields) => {
await seed(fields);
const before = await row();
const result = await change(60);
const saved = await row();
expect(result).toEqual({ ok: true, outcome: 'updated', appointment: saved });
expect(saved).toEqual({
...before,
end_time: '2099-01-04T16:07:00Z',
calendar_revision: before.calendar_revision + 1,
ics_sequence: before.ics_sequence + 1,
updated_at: saved.updated_at,
action_log: saved.action_log
});
expect(saved.updated_at).not.toBe(before.updated_at);
expect(parseActionLog(saved.action_log)).toEqual([
...initialHistory,
{
action: 'edit',
actor: 'host',
at: new Date(now).toISOString(),
payload: {
metadata: { changes: ['duration_updated'], previous_duration: 30, duration: 60 }
}
}
]);
const appointments = await db.selectFrom('appointments').selectAll().execute();
expect(appointments).toEqual([saved]);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
saved,
'edited-by-host'
);
}
);
test.each([15, 45, 60, 90, 120, 180, 240, 300])(
'accepts application host choice %i as total minutes from the original start',
async (duration) => {
await seed();
const result = await change(duration);
const saved = await row();
expect(result).toEqual({ ok: true, outcome: 'updated', appointment: saved });
expect(saved.start_time).toBe(start);
expect(saved.end_time).toBe(
Temporal.Instant.from(start).add({ minutes: duration }).toString()
);
}
);
test.each([20, 40, 75, 360])(
'accepts configured extra/default %i outside the application choices',
async (duration) => {
cfg.meetings['30-min-chat'].duration_minutes = 40;
cfg.meetings['30-min-chat'].additional_duration_minutes = [20, 75, 360];
await seed();
const result = await change(duration);
expect(result).toMatchObject({ ok: true, outcome: 'updated' });
const saved = await row();
expect(saved.end_time).toBe(
Temporal.Instant.from(start).add({ minutes: duration }).toString()
);
}
);
test.each([30, 85])(
'keeping current %i minutes performs no write, conflict read, history append or enqueue',
async (duration) => {
await seed({ end_time: Temporal.Instant.from(start).add({ minutes: duration }).toString() });
await replaceCalendarBusy(db, 'my-google-cal', [{ start, end: '2099-01-04T20:00:00Z' }]);
const before = await row();
statements.length = 0;
const result = await change(duration);
expect(result).toEqual({ ok: true, outcome: 'unchanged', appointment: before });
expect(statements).toEqual([
'BEGIN IMMEDIATE',
'select * from "appointments" where "id" = ?',
'COMMIT'
]);
await expectUnwritten(before);
}
);
test('rereads the current row instead of overwriting newer omitted fields or using stale duration', async () => {
await seed();
const page = await row();
await db
.updateTable('appointments')
.set({
end_time: '2099-01-04T16:32:00Z',
note: 'Newer note',
location: 'Newer location',
video_chat: 'https://video.example/newer',
video_chat_request: 'suppress',
guest_answers: '{"new":"answer"}',
external_event_id: 'newer-reference',
calendar_revision: 9,
ics_sequence: 6
})
.where('id', '=', page.id)
.execute();
const fresh = await row();
const result = await change(60);
const saved = await row();
expect(result).toEqual({ ok: true, outcome: 'updated', appointment: saved });
expect(saved).toEqual({
...fresh,
end_time: '2099-01-04T16:07:00Z',
calendar_revision: 10,
ics_sequence: 7,
action_log: saved.action_log,
updated_at: saved.updated_at
});
expect(parseActionLog(saved.action_log).at(-1)?.payload?.metadata).toEqual({
changes: ['duration_updated'],
previous_duration: 85,
duration: 60
});
});
test('concurrent identical saves serialize to one update and one unchanged result, not two revisions', async () => {
await seed();
const results = await Promise.all([change(60), change(60)]);
expect(results.map((result) => result.ok && result.outcome).sort()).toEqual([
'unchanged',
'updated'
]);
const saved = await row();
expect(saved).toMatchObject({
end_time: '2099-01-04T16:07:00Z',
calendar_revision: 8,
ics_sequence: 5
});
expect(parseActionLog(saved.action_log)).toHaveLength(2);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
saved,
'edited-by-host'
);
});
test('concurrent duration and existing partial content edit both survive with one revision each', async () => {
await seed();
const ctx = context();
const results = await Promise.all([
changeAppointmentDuration(ctx, { appointmentId: baseRow.id, durationMinutes: 60 }),
editAppointment(ctx, {
appointmentId: baseRow.id,
note: 'Concurrent note',
location: 'Concurrent location'
})
]);
expect(results.every((result) => result.ok)).toBe(true);
const saved = await row();
expect(saved).toMatchObject({
note: 'Concurrent note',
location: 'Concurrent location',
end_time: '2099-01-04T16:07:00Z',
calendar_revision: 9,
ics_sequence: 6,
video_chat: baseRow.video_chat,
video_chat_request: 'generate'
});
const history = parseActionLog(saved.action_log);
expect(history).toHaveLength(3);
expect(history.slice(1).map((entry) => entry.payload?.metadata?.changes)).toEqual(
expect.arrayContaining([['duration_updated'], ['note_updated', 'location_updated']])
);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledTimes(2);
});
});
describe('changeAppointmentDuration: bounded refusals', () => {
test('missing ID returns not_found', async () => {
const result = await change(60);
expect(result).toEqual({ ok: false, reason: 'not_found' });
expect(enqueueAppointmentReconciliation).not.toHaveBeenCalled();
});
test.each(['declined', 'cancelled', 'expired', 'rescheduled', 'purged'] as const)(
'gates a freshly changed %s row',
async (status) => {
await seed();
const page = await row();
await db.updateTable('appointments').set({ status }).where('id', '=', page.id).execute();
const before = await row();
const result = await change(60);
expect(result).toEqual({ ok: false, reason: 'gated' });
await expectUnwritten(before);
}
);
test.each(['2099-01-04T15:37:00Z', '2099-01-04T15:38:00Z'])(
'gates at/after current end (%s), even when extending',
async (time) => {
await seed();
now = time;
const before = await row();
const result = await change(60);
expect(result).toEqual({ ok: false, reason: 'gated' });
await expectUnwritten(before);
}
);
test('requires live meeting config, not the stored meeting snapshot, even for unchanged duration', async () => {
await seed();
delete cfg.meetings['30-min-chat'];
const before = await row();
const result = await change(30);
expect(result).toEqual({ ok: false, reason: 'not_configured' });
await expectUnwritten(before);
});
test.each([0, -15, 16, 85, 15.5, NaN, Infinity, -Infinity, Number.MAX_SAFE_INTEGER + 1])(
'rejects invalid/unoffered %s without changing the row',
async (duration) => {
await seed();
const before = await row();
const result = await change(duration);
expect(result).toEqual({ ok: false, reason: 'invalid_duration' });
await expectUnwritten(before);
}
);
test('still requires a safe integer if a malformed config happens to offer a fraction', async () => {
cfg.meetings['30-min-chat'].additional_duration_minutes = [22.5];
await seed();
const before = await row();
const result = await change(22.5);
expect(result).toEqual({ ok: false, reason: 'invalid_duration' });
await expectUnwritten(before);
});
test.each(['2099-01-04T15:22:00Z', '2099-01-04T15:27:00Z'])(
'rejects shortening to an end at/before now %s',
async (time) => {
await seed();
now = time;
const before = await row();
const result = await change(15);
expect(result).toEqual({ ok: false, reason: 'past_end' });
await expectUnwritten(before);
}
);
});
describe('changeAppointmentDuration: active until current end; only actual conflicts constrain host', () => {
test.each([
['pending', 30, 60],
['confirmed', 30, 60],
['pending', 60, 30],
['confirmed', 60, 30]
] as const)(
'allows in-progress %s, %i to %i minutes with a future new end',
async (status, current, duration) => {
await seed({
status,
end_time: Temporal.Instant.from(start).add({ minutes: current }).toString()
});
now = '2099-01-04T15:27:00Z';
const result = await change(duration);
expect(result).toMatchObject({ ok: true, outcome: 'updated' });
const saved = await row();
expect(saved).toMatchObject({
status,
start_time: start,
end_time: Temporal.Instant.from(start).add({ minutes: duration }).toString()
});
}
);
test('allows an edit exactly at the appointment start', async () => {
await seed();
now = start;
const result = await change(60);
expect(result).toMatchObject({ ok: true, outcome: 'updated' });
});
test.each(['2099-01-04T15:06:00Z', '2098-01-01T00:00:00Z'])(
'ignores working hours, off-grid start, notice, horizon, cap and padding when actually free (%s)',
async (time) => {
cfg.schedules.standard.weekly = [];
Object.assign(cfg.meetings['30-min-chat'], {
start_times_every_minutes: 60,
notice_minutes: 10080,
booking_window_days: 1,
daily_booking_limit: 1,
padding_before_minutes: 180,
padding_after_minutes: 180
});
now = time;
await seed();
await seed({
id: 'neighbour',
cancel_token: 'neighbour-token',
start_time: '2099-01-04T16:07:00Z',
end_time: '2099-01-04T16:37:00Z'
});
await replaceCalendarBusy(db, 'my-google-cal', [
{ start: '2099-01-04T14:07:00Z', end: start }
]);
const result = await change(60);
expect(result).toMatchObject({ ok: true, outcome: 'updated' });
}
);
test.each([
{
status: 'pending',
event_type_id: '30-min-chat',
start_time: '2099-01-04T15:50:00Z',
end_time: '2099-01-04T16:30:00Z'
},
{
status: 'confirmed',
event_type_id: 'different-type',
start_time: '2099-01-04T15:50:00Z',
end_time: '2099-01-04T16:30:00Z'
},
{
status: 'confirmed',
event_type_id: 'removed-type',
start_time: '2099-01-01T16:00:00Z',
end_time: '2099-01-04T16:00:00Z'
}
] as const)('refuses hard local conflict $status / $event_type_id', async (fields) => {
await seed();
await seed({ ...fields, id: 'blocker', cancel_token: 'blocker-token' });
const before = await row();
const result = await change(60);
expect(result).toEqual({ ok: false, reason: 'busy' });
await expectUnwritten(before);
});
test.each(['my-google-cal', 'extra'])(
'refuses mirrored busy in selected calendar %s',
async (calendar) => {
cfg.meetings['30-min-chat'].additional_busy_calendars = ['extra'];
await seed();
await replaceCalendarBusy(db, calendar, [
{ start: '2099-01-04T15:50:00Z', end: '2099-01-04T16:30:00Z' }
]);
const before = await row();
const result = await change(60);
expect(result).toEqual({ ok: false, reason: 'busy' });
await expectUnwritten(before);
}
);
test('shortening uses the new interval, not busy time beyond its end or unselected calendars', async () => {
await seed({ end_time: '2099-01-04T16:07:00Z' });
await replaceCalendarBusy(db, 'my-google-cal', [
{ start: '2099-01-04T15:37:00Z', end: '2099-01-04T16:07:00Z' }
]);
await replaceCalendarBusy(db, 'not-selected', [{ start, end: '2099-01-04T16:07:00Z' }]);
const result = await change(30);
expect(result).toMatchObject({ ok: true, outcome: 'updated' });
});
});
describe('changeAppointmentDuration: transaction and enqueue boundary', () => {
test('fresh read, actual conflict reads and update share one BEGIN IMMEDIATE before commit and enqueue', async () => {
await seed();
const rootRead = vi.spyOn(db, 'selectFrom').mockImplementation(() => {
throw new Error('must read on reserved connection');
});
const rootUpdate = vi.spyOn(db, 'updateTable').mockImplementation(() => {
throw new Error('must update on reserved connection');
});
const rootConnection = vi.spyOn(db, 'connection');
statements.length = 0;
let committedAtEnqueue: Appointment | undefined;
vi.mocked(enqueueAppointmentReconciliation).mockImplementationOnce(async () => {
expect(statements.at(-1)).toBe('COMMIT');
expect(rootConnection).toHaveBeenCalledTimes(1);
await withWriteTransaction(db, async (connection) => {
committedAtEnqueue = await connection
.selectFrom('appointments')
.selectAll()
.where('id', '=', baseRow.id)
.executeTakeFirstOrThrow();
});
});
const result = await change(60);
expect(result).toEqual({ ok: true, outcome: 'updated', appointment: committedAtEnqueue });
expect(
statements.slice(0, 6).map((statement) => {
if (statement.startsWith('update')) return 'update';
if (statement.includes('"external_calendar_busy"')) return 'mirror conflict read';
if (statement.startsWith('select "start_time"')) return 'local conflict read';
if (statement.startsWith('select *')) return 'fresh row read';
return statement;
})
).toEqual([
'BEGIN IMMEDIATE',
'fresh row read',
'local conflict read',
'mirror conflict read',
'update',
'COMMIT'
]);
expect(rootRead).not.toHaveBeenCalled();
expect(rootUpdate).not.toHaveBeenCalled();
expect(committedAtEnqueue).toMatchObject({
end_time: '2099-01-04T16:07:00Z',
calendar_revision: 8,
ics_sequence: 5
});
});
test('SQLite update failure escapes, rolls back and is not mislabeled as enqueue failure', async () => {
await seed();
const before = await row();
await sql`CREATE TRIGGER reject_duration BEFORE UPDATE ON appointments BEGIN SELECT RAISE(ABORT, 'injected duration write failure'); END`.execute(
db
);
const write = change(60);
await expect(write).rejects.toThrow('injected duration write failure');
await expectUnwritten(before);
expect(statements).toContain('ROLLBACK');
await sql`DROP TRIGGER reject_duration`.execute(db);
const retried = await change(60);
expect(retried).toMatchObject({ ok: true, outcome: 'updated' });
});
test('enqueue rejection reports the actual committed save; an unchanged retry neither reenqueues nor claims recovery', async () => {
await seed();
vi.mocked(enqueueAppointmentReconciliation).mockImplementationOnce(async () => {
await withWriteTransaction(db, async (connection) => {
const committed = await connection
.selectFrom('appointments')
.selectAll()
.where('id', '=', baseRow.id)
.executeTakeFirstOrThrow();
expect(committed).toMatchObject({
end_time: '2099-01-04T16:07:00Z',
calendar_revision: 8,
ics_sequence: 5
});
});
throw new Error('queue unavailable');
});
const failed = await change(60);
const saved = await row();
expect(failed).toEqual({ ok: false, reason: 'enqueue_failed', durationSaved: true });
expect(parseActionLog(saved.action_log)).toHaveLength(2);
const retry = await change(60);
const afterRetry = await row();
expect(retry).toEqual({ ok: true, outcome: 'unchanged', appointment: saved });
expect(afterRetry).toEqual(saved);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
saved,
'edited-by-host'
);
});
});
@@ -0,0 +1,87 @@
import { sql } from 'kysely';
import {
appendActionLogSql,
findAppointment,
withWriteTransaction,
type Appointment
} from '@when/db';
import { isEditAllowed } from './access';
import { resolveBookingDurations } from './duration';
import type { AppointmentContext } from './context';
import { hasAppointmentConflict } from '../availability/conflicts';
import { enqueueAppointmentReconciliation } from '../workflow';
export type ChangeDurationResult =
| { ok: true; outcome: 'updated' | 'unchanged'; appointment: Appointment }
| {
ok: false;
reason: 'not_found' | 'gated' | 'not_configured' | 'invalid_duration' | 'past_end' | 'busy';
}
| { ok: false; reason: 'enqueue_failed'; durationSaved: true };
export async function changeAppointmentDuration(
ctx: AppointmentContext,
input: { appointmentId: string; durationMinutes: number }
): Promise<ChangeDurationResult> {
const committed = await withWriteTransaction(ctx.db, async (connection) => {
const appointment = await findAppointment(connection, input.appointmentId);
if (!appointment) return { ok: false, reason: 'not_found' } as const;
const now = ctx.clock.now();
if (!isEditAllowed(appointment, now)) {
return { ok: false, reason: 'gated' } as const;
}
const meeting = ctx.cfg.meetings[appointment.event_type_id];
if (!meeting) return { ok: false, reason: 'not_configured' } as const;
const profile = resolveBookingDurations(meeting, 'host', appointment);
if (
!Number.isSafeInteger(input.durationMinutes) ||
!profile.durations.includes(input.durationMinutes)
) {
return { ok: false, reason: 'invalid_duration' } as const;
}
if (input.durationMinutes === profile.defaultDuration) {
return { ok: true, outcome: 'unchanged', appointment } as const;
}
const start = Temporal.Instant.from(appointment.start_time);
const end = start.add({ minutes: input.durationMinutes });
if (end.epochMilliseconds <= now.getTime()) return { ok: false, reason: 'past_end' } as const;
const busy = await hasAppointmentConflict(connection, meeting, { start, end }, appointment.id);
if (busy) return { ok: false, reason: 'busy' } as const;
const updated = await connection
.updateTable('appointments')
.set({
end_time: end.toString(),
calendar_revision: sql`calendar_revision + 1`,
ics_sequence: sql`ics_sequence + 1`,
updated_at: sql`CURRENT_TIMESTAMP`,
action_log: appendActionLogSql({
action: 'edit',
actor: 'host',
at: now.toISOString(),
payload: {
metadata: {
changes: ['duration_updated'],
previous_duration: profile.defaultDuration,
duration: input.durationMinutes
}
}
})
})
.where('id', '=', appointment.id)
.where('status', 'in', ['pending', 'confirmed'])
.returningAll()
.executeTakeFirstOrThrow();
return { ok: true, outcome: 'updated', appointment: updated } as const;
});
if (!committed.ok || committed.outcome === 'unchanged') return committed;
try {
await enqueueAppointmentReconciliation(committed.appointment, 'edited-by-host');
} catch {
return { ok: false, reason: 'enqueue_failed', durationSaved: true };
}
return committed;
}
@@ -0,0 +1,219 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { openDb, parseActionLog, runMigrations, type Appointment } from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import { changeAppointmentDuration } from './change-duration';
import { resolveDuration } from './duration';
import { rescheduleAppointment, type RescheduleAppointmentInput } from './reschedule';
import type { AppointmentContext } from './context';
import type { AppointmentRole } from './actions';
vi.mock('../workflow', () => ({ enqueueAppointmentReconciliation: vi.fn() }));
import { enqueueAppointmentReconciliation } from '../workflow';
const appointmentId = 'duration-race';
const now = new Date('2099-01-01T12:00:00Z');
const newStart = '2099-01-06T10:00:00Z';
const initialHistory = [{ action: 'create', actor: 'guest', at: '2098-12-01T12:00:00Z' }];
let db: ReturnType<typeof openDb>;
let ctx: AppointmentContext;
async function currentRow() {
const row = await db
.selectFrom('appointments')
.selectAll()
.where('id', '=', appointmentId)
.executeTakeFirstOrThrow();
return row;
}
function prevalidate(
row: Appointment,
role: AppointmentRole,
duration: number
): RescheduleAppointmentInput {
const form = new FormData();
form.set('duration', String(duration));
const resolved = resolveDuration(ctx.cfg.meetings[row.event_type_id], role, form, row);
expect(resolved).toBe(duration);
return {
appointmentId: row.id,
initiator: role,
newStart,
newEnd: Temporal.Instant.from(newStart).add({ minutes: duration }).toString()
};
}
beforeEach(async () => {
db = openDb(':memory:');
await runMigrations(db);
const cfg = structuredClone(validConfig);
cfg.meetings['30-min-chat'].duration_minutes = 30;
cfg.meetings['30-min-chat'].additional_duration_minutes = [];
ctx = { db, cfg, clock: { now: () => now, nowMs: () => now.getTime() } };
await db
.insertInto('appointments')
.values({
id: appointmentId,
event_type_id: '30-min-chat',
start_time: '2099-01-05T10:00:00Z',
end_time: '2099-01-05T11:30:00Z',
status: 'confirmed',
origin_id: appointmentId,
cancel_token: 'original-token',
guest_name: 'Guest',
guest_email: 'guest@example.com',
location: 'Existing location',
note: 'Existing host note',
video_chat: 'https://video.example/keep',
video_chat_request: 'suppress',
external_event_id: 'published-event',
external_calendar_id: 'my-google-cal',
action_log: JSON.stringify(initialHistory)
})
.execute();
vi.mocked(enqueueAppointmentReconciliation).mockReset();
vi.mocked(enqueueAppointmentReconciliation).mockResolvedValue(undefined);
});
afterEach(async () => {
await db.destroy();
});
describe('duration change between reschedule prevalidation and commit', () => {
test('gates a guest request retaining stale 90 minutes after the host commits 60 minutes', async () => {
const page = await currentRow();
const request = prevalidate(page, 'guest', 90);
const changed = await changeAppointmentDuration(ctx, { appointmentId, durationMinutes: 60 });
const committed = await currentRow();
expect(changed).toEqual({ ok: true, outcome: 'updated', appointment: committed });
expect(committed).toMatchObject({
status: 'confirmed',
start_time: page.start_time,
end_time: '2099-01-05T11:00:00Z',
calendar_revision: 1,
ics_sequence: 1
});
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
committed,
'edited-by-host'
);
const result = await rescheduleAppointment(ctx, request);
const rows = await db.selectFrom('appointments').selectAll().execute();
expect(result).toEqual({ ok: false, reason: 'gated' });
expect(rows).toEqual([committed]);
expect(parseActionLog(rows[0].action_log)).toEqual([
...initialHistory,
{
action: 'edit',
actor: 'host',
at: now.toISOString(),
payload: {
metadata: { changes: ['duration_updated'], previous_duration: 90, duration: 60 }
}
}
]);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledExactlyOnceWith(
committed,
'edited-by-host'
);
});
test.each([
{
name: 'guest requested configured 30 before the host change',
role: 'guest',
savedDuration: 60,
requestedDuration: 30,
validateFresh: false
},
{
name: 'guest retains fresh 60 after the host change',
role: 'guest',
savedDuration: 60,
requestedDuration: 60,
validateFresh: true
},
{
name: 'guest retains unchanged original 90',
role: 'guest',
savedDuration: 90,
requestedDuration: 90,
validateFresh: false
},
{
name: 'host still chooses host-only 90 after saving 60',
role: 'host',
savedDuration: 60,
requestedDuration: 90,
validateFresh: false
}
] as const)(
'$name remains allowed',
async ({ role, savedDuration, requestedDuration, validateFresh }) => {
const page = await currentRow();
let request = validateFresh ? undefined : prevalidate(page, role, requestedDuration);
const changed = await changeAppointmentDuration(ctx, {
appointmentId,
durationMinutes: savedDuration
});
const committed = await currentRow();
const durationChanged = savedDuration !== 90;
expect(changed).toEqual({
ok: true,
outcome: durationChanged ? 'updated' : 'unchanged',
appointment: committed
});
if (validateFresh) request = prevalidate(committed, role, requestedDuration);
if (!request) throw new Error('control request was not prevalidated');
const result = await rescheduleAppointment(ctx, request);
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(`control reschedule refused: ${result.reason}`);
const rows = await db.selectFrom('appointments').selectAll().execute();
const original = rows.find((row) => row.id === appointmentId);
const successor = rows.find((row) => row.id === result.appointment.id);
expect(rows).toHaveLength(2);
expect(successor).toEqual(result.appointment);
expect(result.appointment.id).not.toBe(appointmentId);
expect(result.appointment).toMatchObject({
status: 'confirmed',
origin_id: appointmentId,
start_time: newStart,
end_time: request.newEnd,
note: committed.note,
video_chat: committed.video_chat,
video_chat_request: committed.video_chat_request,
ics_sequence: committed.ics_sequence + 1
});
expect(original).toMatchObject({
status: 'rescheduled',
start_time: page.start_time,
end_time: committed.end_time,
calendar_revision: committed.calendar_revision,
ics_sequence: committed.ics_sequence
});
const history = parseActionLog(result.appointment.action_log);
expect(history.slice(0, -1)).toEqual(parseActionLog(committed.action_log));
expect(history.at(-1)).toMatchObject({
action: 'reschedule',
actor: role,
payload: { metadata: { previous_id: appointmentId, next_id: result.appointment.id } }
});
expect(original?.action_log).toBe(result.appointment.action_log);
expect(enqueueAppointmentReconciliation).toHaveBeenCalledTimes(durationChanged ? 2 : 1);
if (durationChanged) {
expect(enqueueAppointmentReconciliation).toHaveBeenNthCalledWith(
1,
committed,
'edited-by-host'
);
}
expect(enqueueAppointmentReconciliation).toHaveBeenLastCalledWith(
result.appointment,
`rescheduled-by-${role}`
);
}
);
});
@@ -1,5 +1,6 @@
import { resolveAppointmentActions, isTerminalStatus, type AppointmentRole } from './actions';
import { isRescheduleAllowed, isViewable } from './access';
import { resolveBookingDurations } from './duration';
import { enqueueAppointmentReconciliation } from '../workflow';
import type { AppointmentContext } from './context';
import {
@@ -72,6 +73,12 @@ export async function rescheduleAppointment(
eventType
}).reschedule;
if (!gate.allowed) return { ok: false, reason: 'gated' } as const;
// A concurrent duration edit can revoke the old booked-length exception.
if (eventType) {
const duration = (Date.parse(input.newEnd) - Date.parse(input.newStart)) / 60_000;
const profile = resolveBookingDurations(eventType, input.initiator, appointment);
if (!profile.durations.includes(duration)) return { ok: false, reason: 'gated' } as const;
}
const details = rescheduleDetails(appointment, input, eventType);
const moved = await rescheduleAppointmentTransition(
@@ -0,0 +1,211 @@
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import {
openDb,
replaceCalendarBusy,
runMigrations,
withWriteTransaction,
type NewAppointment
} from '@when/db';
import { validConfig } from '$lib/server/__fixtures__/valid-config';
import { hasAppointmentConflict } from './conflicts';
const meeting = {
...validConfig.meetings['30-min-chat'],
booking_calendar: 'booking',
additional_busy_calendars: ['extra', 'booking', 'extra']
};
const interval = {
start: Temporal.Instant.from('2099-01-04T15:07:00Z'),
end: Temporal.Instant.from('2099-01-04T16:07:00Z')
};
let db: ReturnType<typeof openDb>;
async function seed(changes: Partial<NewAppointment> = {}) {
await db
.insertInto('appointments')
.values({
id: 'other',
event_type_id: '30-min-chat',
start_time: '2099-01-04T15:45:00Z',
end_time: '2099-01-04T16:30:00Z',
guest_name: 'Guest',
status: 'confirmed',
cancel_token: 'other-token',
location: null,
note: null,
video_chat: null,
external_event_id: null,
external_calendar_id: null,
...changes
})
.execute();
}
beforeEach(async () => {
db = openDb(':memory:');
await runMigrations(db);
});
afterEach(async () => {
vi.restoreAllMocks();
await db.destroy();
});
describe('hasAppointmentConflict: local actual intervals', () => {
test.each(['pending', 'confirmed'] as const)('blocks an overlapping %s row', async (status) => {
await seed({ status });
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(true);
});
test('blocks other meeting types, even one no longer configured', async () => {
await seed({ event_type_id: 'removed-meeting' });
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(true);
});
test('includes a three-day appointment starting far before the candidate day', async () => {
await seed({ start_time: '2099-01-01T16:00:00Z', end_time: '2099-01-04T16:00:00Z' });
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(true);
});
test.each(['declined', 'cancelled', 'expired', 'rescheduled', 'purged'] as const)(
'ignores overlapping inactive status %s',
async (status) => {
await seed({ status });
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(false);
}
);
test('excludes only the supplied appointment ID, not all rows of its type', async () => {
await seed({ id: 'self', cancel_token: 'self-token' });
const selfOnly = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(selfOnly).toBe(false);
await seed({ start_time: '2099-01-04T15:50:00Z' });
const otherRow = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(otherRow).toBe(true);
});
test.each([
['ends at candidate start', '2099-01-04T14:00:00Z', '2099-01-04T15:07:00Z'],
['starts at candidate end', '2099-01-04T16:07:00Z', '2099-01-04T17:00:00Z'],
['is earlier', '2099-01-04T13:00:00Z', '2099-01-04T14:00:00Z'],
['is later', '2099-01-04T17:00:00Z', '2099-01-04T18:00:00Z']
])('allows a local row that %s', async (_name, start_time, end_time) => {
await seed({ start_time, end_time });
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(false);
});
});
describe('hasAppointmentConflict: local remote-busy mirror', () => {
test.each(['booking', 'extra'])(
'blocks actual busy time in the %s calendar',
async (calendar) => {
await replaceCalendarBusy(db, calendar, [
{ start: '2099-01-04T16:00:00Z', end: '2099-01-04T17:00:00Z' }
]);
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(true);
}
);
test('ignores a busy calendar not selected by the supplied meeting', async () => {
await replaceCalendarBusy(db, 'unselected', [
{ start: interval.start.toString(), end: interval.end.toString() }
]);
const free = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(free).toBe(false);
const selectedMeeting = { ...meeting, additional_busy_calendars: ['unselected'] };
const busy = await hasAppointmentConflict(db, selectedMeeting, interval, 'self');
expect(busy).toBe(true);
});
test.each([
['ends at start', '2099-01-04T14:00:00Z', '2099-01-04T15:07:00Z'],
['starts at end', '2099-01-04T16:07:00Z', '2099-01-04T17:00:00Z'],
['outside the window', '2099-01-05T15:00:00Z', '2099-01-05T16:00:00Z']
])('allows mirrored busy time that %s', async (_name, start, end) => {
await replaceCalendarBusy(db, 'booking', [{ start, end }]);
const busy = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(busy).toBe(false);
});
test('uses the supplied actual interval, not the configured default duration', async () => {
await replaceCalendarBusy(db, 'booking', [
{ start: '2099-01-04T16:30:00Z', end: '2099-01-04T17:00:00Z' }
]);
const short = await hasAppointmentConflict(db, meeting, interval, 'self');
const extended = await hasAppointmentConflict(
db,
meeting,
{ ...interval, end: interval.start.add({ minutes: 120 }) },
'self'
);
expect(short).toBe(false);
expect(extended).toBe(true);
});
test('reads a replacement mirror without fetching a provider', async () => {
await replaceCalendarBusy(db, 'booking', [
{ start: interval.start.toString(), end: interval.end.toString() }
]);
const before = await hasAppointmentConflict(db, meeting, interval, 'self');
await replaceCalendarBusy(db, 'booking', []);
const after = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(before).toBe(true);
expect(after).toBe(false);
});
});
test('uses the supplied write connection for both local and mirror reads without reacquiring root', async () => {
await replaceCalendarBusy(db, 'booking', [
{ start: interval.start.toString(), end: interval.end.toString() }
]);
const rootRead = vi.spyOn(db, 'selectFrom');
const rootConnection = vi.spyOn(db, 'connection');
await withWriteTransaction(db, async (connection) => {
rootRead.mockImplementation(() => {
throw new Error('root read inside reserved connection');
});
rootConnection.mockImplementation(() => {
throw new Error('root connection reacquired');
});
const busy = await hasAppointmentConflict(connection, meeting, interval, 'self');
expect(busy).toBe(true);
});
expect(rootRead).not.toHaveBeenCalled();
expect(rootConnection).toHaveBeenCalledTimes(1);
});
test('sees an uncommitted local row on the supplied connection', async () => {
const rollback = new Error('discard fixture write');
const write = withWriteTransaction(db, async (connection) => {
await connection
.insertInto('appointments')
.values({
id: 'inside',
event_type_id: 'another-type',
status: 'pending',
cancel_token: 'inside-token',
start_time: interval.start.toString(),
end_time: interval.end.toString(),
guest_name: 'Guest',
location: null,
note: null,
video_chat: null,
external_event_id: null,
external_calendar_id: null
})
.execute();
const busy = await hasAppointmentConflict(connection, meeting, interval, 'self');
expect(busy).toBe(true);
throw rollback;
});
await expect(write).rejects.toBe(rollback);
const afterRollback = await hasAppointmentConflict(db, meeting, interval, 'self');
expect(afterRollback).toBe(false);
});
@@ -0,0 +1,36 @@
import type { Kysely } from 'kysely';
import { getBusyIntervals, type Database } from '@when/db';
import { busyCalendarsFor } from '@when/calendar';
import type { Meeting } from '@when/config';
import { overlapsAny } from './intervals';
import type { Interval } from './types';
export async function hasAppointmentConflict(
db: Kysely<Database>,
meeting: Meeting,
interval: Interval,
excludeAppointmentId: string
): Promise<boolean> {
// A long appointment can overlap this interval even if it started much earlier.
const local = await db
.selectFrom('appointments')
.select(['start_time', 'end_time'])
.where('status', 'in', ['pending', 'confirmed'])
.where('id', '!=', excludeAppointmentId)
.execute();
const localIntervals = local.map((row) => ({
start: Temporal.Instant.from(row.start_time),
end: Temporal.Instant.from(row.end_time)
}));
if (overlapsAny(interval, localIntervals)) return true;
const remote = await getBusyIntervals(db, busyCalendarsFor(meeting), {
start: interval.start.toString(),
end: interval.end.toString()
});
const remoteIntervals = remote.map((row) => ({
start: Temporal.Instant.from(row.start),
end: Temporal.Instant.from(row.end)
}));
return overlapsAny(interval, remoteIntervals);
}
@@ -1,6 +1,6 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { shouldAttachVideoChat } from '@when/video-chat';
import { isViewAllowed } from '$lib/server/appointment/access';
import { isEditAllowed, isViewAllowed } from '$lib/server/appointment/access';
import {
isActiveStatus,
resolveAppointmentActions,
@@ -140,6 +140,7 @@ export const load: PageServerLoad = async ({ params, url, locals, cookies }) =>
appointment,
videoChatRequested,
eventType: toPublicEventType(row.event_type_id, resolvedEventType, role, bookingDurations),
canEditDuration: isHost && Boolean(eventType) && isEditAllowed(row, now),
calendarLinks,
actions,
flash: flash ?? null,
@@ -13,6 +13,7 @@
import IconPencilSimple from 'virtual:icons/ph/pencil-simple';
import IconVideo from 'virtual:icons/ph/video-conference';
import AppointmentActions from '$lib/components/AppointmentActions.svelte';
import DurationEditor from '$lib/components/DurationEditor.svelte';
import AppointmentLog from '$lib/components/AppointmentLog.svelte';
import AppointmentQuestions from '$lib/components/AppointmentQuestions.svelte';
import AddToCalendar from '$lib/components/AddToCalendar.svelte';
@@ -26,6 +27,7 @@
let editNoteDialogOpen = $state(false);
let editLocationDialogOpen = $state(false);
let editVideoChatDialogOpen = $state(false);
let editDurationDialogOpen = $state(false);
let changingVideoChat = $state(false);
let videoChatError = $state<{ message?: string } | null>(null);
let cancelReason = $state('I can no longer attend');
@@ -311,6 +313,7 @@
onGenerateVideoChat={handleGenerateVideoChat}
onRemoveVideoChat={handleRemoveVideoChat}
onEditVideoChat={() => (editVideoChatDialogOpen = true)}
onEditDuration={data.canEditDuration ? () => (editDurationDialogOpen = true) : undefined}
{isEditable}
/>
{/if}
@@ -572,6 +575,15 @@
{/if}
</main>
{#if data.canEditDuration}
<DurationEditor
bind:open={editDurationDialogOpen}
appointmentId={data.appointment.id}
currentDuration={data.eventType.duration_minutes}
durations={data.eventType.durations}
/>
{/if}
<Dialog.Root bind:open={cancelDialogOpen}>
<Dialog.Portal>
<Dialog.Overlay>
@@ -143,6 +143,14 @@ vi.mock('@when/db', async (importOriginal) => {
note: 'Private prep',
video_chat: 'Private video'
};
if (id === 'appt-pending-ended')
return {
...mockAppt,
id,
status: 'pending',
start_time: '2000-01-01T15:00:00Z',
end_time: '2000-01-01T15:30:00Z'
};
if (id === 'appt-auto-video') return { ...automaticVideoAppointment, id };
if (id === 'appt-auto-phone')
return {
@@ -212,6 +220,9 @@ describe('/appointment/[id] server load', () => {
role === 'guest' ? row.cancel_token : null,
role
);
expect(result.canEditDuration).toBe(role === 'host');
expect(result).not.toHaveProperty('meetingConfigured');
expect(result.role).toBe(role);
expect(resolveBookingDurations).toHaveBeenCalledExactlyOnceWith(meeting, role, row);
expect(vi.mocked(resolveBookingDurations).mock.calls[0][2]).toBe(row);
const bookingDurations = vi.mocked(resolveBookingDurations).mock.results[0].value;
@@ -233,6 +244,7 @@ describe('/appointment/[id] server load', () => {
async (role) => {
const result = await loadAppointment('appt-pending', 'tok-abc', role);
expect(result.role).toBe(role);
expect(result.canEditDuration).toBe(role === 'host');
expect(result).not.toHaveProperty('viewer');
expect(result).not.toHaveProperty('isAdmin');
expect(result).not.toHaveProperty('isHost');
@@ -244,6 +256,15 @@ describe('/appointment/[id] server load', () => {
}
);
test('does not allow the host to edit the duration of an ended pending appointment', async () => {
const result = await loadAppointment('appt-pending-ended', null, 'host');
expect(result.role).toBe('host');
expect(result.appointment.status).toBe('pending');
expect(result.clockStatus).toBe('concluded');
expect(result.canEditDuration).toBe(false);
});
test('renders successfully for guest when event type is active', async () => {
const mockLocals = {
auth: vi.fn().mockResolvedValue(null)
@@ -6,6 +6,7 @@ import { declineAppointment } from '$lib/server/appointment/decline';
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 { appointmentContext } from '$lib/server/appointment/context';
import { rotateGuestTokenTransition } from '$lib/server/appointment/transitions';
@@ -142,6 +143,46 @@ export const actions: Actions = {
return { success: 'edited' };
},
changeDuration: async ({ params, request }) => {
const form = await request.formData();
const value = form.get('duration');
if (typeof value !== 'string' || !value.trim()) {
return fail(400, { error: 'Please pick a valid meeting length.' });
}
const durationMinutes = Number(value);
if (!Number.isSafeInteger(durationMinutes) || durationMinutes <= 0) {
return fail(400, { error: 'Please pick a valid meeting length.' });
}
const result = await changeAppointmentDuration(appointmentContext(), {
appointmentId: params.id,
durationMinutes
});
if (!result.ok) {
switch (result.reason) {
case 'not_found':
return fail(404, { error: 'Appointment not found.' });
case 'not_configured':
return fail(409, { error: 'This meeting type no longer exists.' });
case 'invalid_duration':
return fail(400, { error: 'Please pick a valid meeting length.' });
case 'past_end':
return fail(400, { error: 'Choose a length that ends in the future.' });
case 'gated':
return fail(400, { error: 'This appointment cannot be edited.' });
case 'busy':
return fail(409, { error: 'That length overlaps another appointment or busy event.' });
case 'enqueue_failed':
return fail(500, {
error:
'The duration was saved, but calendar and email update submission could not be confirmed.',
durationSaved: true
});
}
}
if (result.outcome === 'unchanged') return { success: 'no_change' };
return { success: 'duration_changed' };
},
generateVideoChat: async ({ params }) => {
const result = await requestVideoChat(appointmentContext(), { appointmentId: params.id });
if (!result.ok) {
@@ -1,16 +1,23 @@
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 type { RequestEvent } from './$types';
vi.mock('$lib/server/appointment/context', () => ({ appointmentContext: () => context }));
vi.mock('$lib/server/appointment/request-video-chat', () => ({ requestVideoChat: vi.fn() }));
vi.mock('$lib/server/appointment/change-duration', async (importOriginal) => {
const original = await importOriginal<typeof import('$lib/server/appointment/change-duration')>();
return { ...original, changeAppointmentDuration: vi.fn() };
});
const context = { db: {}, cfg: {}, clock: {} };
const event = { params: { id: 'appt-1' } } as unknown as RequestEvent;
beforeEach(() => {
vi.mocked(requestVideoChat).mockReset();
vi.mocked(changeAppointmentDuration).mockReset();
});
describe('admin appointment load', () => {
@@ -72,3 +79,131 @@ describe('generateVideoChat action', () => {
});
});
});
describe('changeDuration action', () => {
function changeDurationWith(form: FormData) {
return actions.changeDuration({
params: { id: 'appt-1' },
request: new Request('http://localhost', { method: 'POST', body: form })
} as unknown as RequestEvent);
}
test.each(['updated', 'unchanged'] as const)(
'maps %s while passing only the route id and parsed duration to the fresh-row service',
async (outcome) => {
// The route does not consume the service's returned row.
const appointment = { id: 'fresh-service-row' } as Appointment;
vi.mocked(changeAppointmentDuration).mockResolvedValue({ ok: true, outcome, appointment });
const form = new FormData();
form.set('duration', ' 60 ');
for (const [key, value] of Object.entries({
id: 'forged-id',
appointmentId: 'forged-id',
start: '2000-01-01T00:00:00Z',
start_time: '2000-01-01T00:00:00Z',
end_time: '2099-01-01T00:00:00Z',
role: 'guest',
viewer: 'host',
isAdmin: 'true',
isHost: 'true',
event_type_id: 'forged-meeting',
durationMinutes: '300',
note: 'forged note',
location: 'forged location',
video_chat: 'https://forged.example.test'
})) {
form.set(key, value);
}
const result = await changeDurationWith(form);
expect(changeAppointmentDuration).toHaveBeenCalledExactlyOnceWith(context, {
appointmentId: 'appt-1',
durationMinutes: 60
});
expect(result).toEqual({
success: outcome === 'unchanged' ? 'no_change' : 'duration_changed'
});
}
);
test.each([
['missing', null],
['empty', ''],
['whitespace', ' '],
['file', new File(['60'], 'duration.txt')],
['fractional', '1.5'],
['zero', '0'],
['negative', '-15'],
['NaN', 'NaN'],
['infinity', 'Infinity'],
['negative infinity', '-Infinity'],
['overflow', '1e309'],
['unsafe integer', '9007199254740992'],
['nonnumeric', 'sixty']
] as const)('rejects %s duration before calling the service', async (_label, value) => {
const form = new FormData();
if (value !== null) form.set('duration', value);
const result = await changeDurationWith(form);
expect(result).toEqual({
status: 400,
data: { error: 'Please pick a valid meeting length.' }
});
expect(result).not.toHaveProperty('success');
expect(changeAppointmentDuration).not.toHaveBeenCalled();
});
test.each([
['not_found', 404, 'Appointment not found.'],
['not_configured', 409, 'This meeting type no longer exists.'],
['invalid_duration', 400, 'Please pick a valid meeting length.'],
['past_end', 400, 'Choose a length that ends in the future.'],
['gated', 400, 'This appointment cannot be edited.'],
['busy', 409, 'That length overlaps another appointment or busy event.']
] as const)(
'maps %s without claiming success or a saved duration',
async (reason, status, error) => {
vi.mocked(changeAppointmentDuration).mockResolvedValue({ ok: false, reason });
const form = new FormData();
form.set('duration', '60');
const result = await changeDurationWith(form);
expect(changeAppointmentDuration).toHaveBeenCalledExactlyOnceWith(context, {
appointmentId: 'appt-1',
durationMinutes: 60
});
expect(result).toEqual({ status, data: { error } });
expect(result).not.toHaveProperty('success');
expect(result).not.toHaveProperty('data.durationSaved');
}
);
test('reports the saved duration but unconfirmed update submission on enqueue failure', async () => {
vi.mocked(changeAppointmentDuration).mockResolvedValue({
ok: false,
reason: 'enqueue_failed',
durationSaved: true
});
const form = new FormData();
form.set('duration', '60');
const result = await changeDurationWith(form);
expect(changeAppointmentDuration).toHaveBeenCalledExactlyOnceWith(context, {
appointmentId: 'appt-1',
durationMinutes: 60
});
expect(result).toEqual({
status: 500,
data: {
error:
'The duration was saved, but calendar and email update submission could not be confirmed.',
durationSaved: true
}
});
expect(result).not.toHaveProperty('success');
});
});
@@ -1,8 +1,103 @@
import { describe, expect, test } from 'vitest';
import { appointmentEditedByHost } from './appointment-edited-by-host.js';
import { sampleInput } from '../__fixtures__/appointment.js';
import { fmtWhen } from '../format.js';
describe('appointmentEditedByHost', () => {
test.each(['confirmed', 'pending'] as const)(
'duration edited from 90 to 120 minutes on a %s appointment keeps the existing recipient policy',
(status) => {
const previousAppointment = {
...sampleInput.appointment,
status,
end_time: '2026-01-05T16:30:00Z',
ics_sequence: 3,
note: 'Bring a laptop.',
video_chat: 'https://meet.example.com/room-123'
};
const input = {
...sampleInput,
appointment: {
...previousAppointment,
end_time: '2026-01-05T17:00:00Z',
ics_sequence: 4,
action_log: JSON.stringify([
{
action: 'edit',
actor: 'host',
at: '2026-01-02T10:00:00Z',
payload: {
metadata: {
changes: ['duration_updated'],
previous_duration: 90,
duration: 120
}
}
}
])
}
};
const messages = appointmentEditedByHost(input);
expect(messages.map((message) => message.role)).toEqual(
status === 'confirmed' ? ['guest', 'host'] : ['host']
);
const host = messages[messages.length - 1];
expect(host.to).toBe('owner@acme.test');
expect(host.content.subject).toBe('Updated details: 30-min with Jane Doe');
expect(host.content.paragraphs).toEqual([
'You updated the details for the appointment with Jane Doe <jane@example.com>.',
'You updated the duration of the appointment.'
]);
expect(host.content.rows).toContainEqual({
label: 'When',
value: fmtWhen(
previousAppointment.start_time,
'2026-01-05T17:00:00Z',
input.cfg.user.timezone
)
});
expect(host.content.rows).not.toContainEqual({
label: 'When',
value: fmtWhen(
previousAppointment.start_time,
previousAppointment.end_time,
input.cfg.user.timezone
)
});
expect(host.content.rows).toContainEqual({ label: 'Note', value: previousAppointment.note });
expect(host.content.rows).toContainEqual({
label: 'Video link',
value: previousAppointment.video_chat
});
expect(host.ics).toBeUndefined();
if (status === 'confirmed') {
const [guest] = messages;
expect(guest.to).toBe('jane@example.com');
expect(guest.content.subject).toBe('Updated details: 30-min with Acme Scheduling');
expect(guest.content.heading).toBe('Appointment details updated');
expect(guest.content.paragraphs).toEqual(['The duration of your appointment was updated.']);
expect(guest.content.rows).toContainEqual({
label: 'When',
value: fmtWhen(
previousAppointment.start_time,
'2026-01-05T17:00:00Z',
'America/Los_Angeles'
)
});
const ics = guest.ics?.content;
expect(ics).toContain('METHOD:REQUEST');
expect(ics).toContain('UID:appt-1');
expect(ics).toMatch(/^DTSTART(?:;[^:\r\n]+)?:20260105T150000Z\r?$/m);
expect(ics).toMatch(/^DTEND(?:;[^:\r\n]+)?:20260105T170000Z\r?$/m);
expect(ics).not.toContain('20260105T163000Z');
expect(ics).toContain('SEQUENCE:4');
}
}
);
test('note added on a confirmed appointment (notifies both, has guest ics)', () => {
const action_log = JSON.stringify([
{
@@ -53,6 +53,10 @@ export function appointmentEditedByHost(i: AppointmentEmailInput): AppointmentEm
paragraphs.push('The video link of your appointment was removed.');
}
if (changes.includes('duration_updated')) {
paragraphs.push('The duration of your appointment was updated.');
}
if (paragraphs.length === 0) {
paragraphs.push('The details of your appointment were updated.');
}
@@ -77,6 +81,9 @@ export function appointmentEditedByHost(i: AppointmentEmailInput): AppointmentEm
];
const hostParagraphs = [`You updated the details for the appointment with ${guestLabel(a)}.`];
if (changes.includes('duration_updated')) {
hostParagraphs.push('You updated the duration of the appointment.');
}
const guestEmail = {
subject: '',