diff --git a/.changeset/host-duration-editor.md b/.changeset/host-duration-editor.md new file mode 100644 index 00000000..32b0a337 --- /dev/null +++ b/.changeset/host-duration-editor.md @@ -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. diff --git a/apps/web/e2e/duration-editor.e2e.ts b/apps/web/e2e/duration-editor.e2e.ts new file mode 100644 index 00000000..6d0928f9 --- /dev/null +++ b/apps/web/e2e/duration-editor.e2e.ts @@ -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: 'Reload unavailable

Appointment reload unavailable

' + }); + } 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); +}); diff --git a/apps/web/src/lib/components/AppointmentActions.svelte b/apps/web/src/lib/components/AppointmentActions.svelte index 3ad8e76c..7d592644 100644 --- a/apps/web/src/lib/components/AppointmentActions.svelte +++ b/apps/web/src/lib/components/AppointmentActions.svelte @@ -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 @@
{#if isHost && isEditable} + {#if onEditDuration} + { + menuOpen = false; + onEditDuration(); + }} + > + {#snippet child({ props: itemProps })} + + {/snippet} + + {/if} {#if onEditNote && !hasNote} { diff --git a/apps/web/src/lib/components/AppointmentLog.svelte b/apps/web/src/lib/components/AppointmentLog.svelte index 48c8a7a4..de44a794 100644 --- a/apps/web/src/lib/components/AppointmentLog.svelte +++ b/apps/web/src/lib/components/AppointmentLog.svelte @@ -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} diff --git a/apps/web/src/lib/components/DurationEditor.svelte b/apps/web/src/lib/components/DurationEditor.svelte new file mode 100644 index 00000000..e3714b77 --- /dev/null +++ b/apps/web/src/lib/components/DurationEditor.svelte @@ -0,0 +1,280 @@ + + + open, + (value) => { + if (!saving && outcome === 'editing') open = value; + } + } +> + + + {#snippet child({ props })} +
+ {/snippet} +
+ + {#snippet child({ props })} +
+ + {#snippet child({ props: titleProps })} +

Change duration

+ {/snippet} +
+ + {#snippet child({ props: descriptionProps })} +

+ The start time stays the same. Calendar and email updates will be requested when the + duration changes. +

+ {/snippet} +
+ +
+ + + + {#if error} + + {/if} + {#if outcome === 'duration_changed'} +

Duration saved.

+ {:else if outcome === 'no_change'} +

Duration unchanged.

+ {:else if outcome === 'updates_unconfirmed'} + + {:else if outcome === 'uncertain'} + + {/if} + +
+ {#if outcome === 'editing'} + + + {#snippet child({ props: closeProps })} + + {/snippet} + + {:else} + + {/if} +
+
+
+ {/snippet} +
+
+
+ + diff --git a/apps/web/src/lib/server/appointment/access.ts b/apps/web/src/lib/server/appointment/access.ts index 546280d0..962dbc34 100644 --- a/apps/web/src/lib/server/appointment/access.ts +++ b/apps/web/src/lib/server/appointment/access.ts @@ -51,6 +51,10 @@ export function isRescheduleAllowed( return now.getTime() + minimumNoticeMinutes * 60 * 1000 <= startMs; } +export function isEditAllowed(row: Pick, now: Date): boolean { + return ACTIVE_STATUSES.has(row.status) && now.getTime() < Date.parse(row.end_time); +} + export type ViewableRow = WindowedRow & Pick; export async function isViewAllowed( diff --git a/apps/web/src/lib/server/appointment/change-duration.test.ts b/apps/web/src/lib/server/appointment/change-duration.test.ts new file mode 100644 index 00000000..afcdb2f2 --- /dev/null +++ b/apps/web/src/lib/server/appointment/change-duration.test.ts @@ -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; +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 = {}) { + 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' + ); + }); +}); diff --git a/apps/web/src/lib/server/appointment/change-duration.ts b/apps/web/src/lib/server/appointment/change-duration.ts new file mode 100644 index 00000000..3ac5929b --- /dev/null +++ b/apps/web/src/lib/server/appointment/change-duration.ts @@ -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 { + 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; +} diff --git a/apps/web/src/lib/server/appointment/duration-reschedule-race.test.ts b/apps/web/src/lib/server/appointment/duration-reschedule-race.test.ts new file mode 100644 index 00000000..aba03630 --- /dev/null +++ b/apps/web/src/lib/server/appointment/duration-reschedule-race.test.ts @@ -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; +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}` + ); + } + ); +}); diff --git a/apps/web/src/lib/server/appointment/reschedule.ts b/apps/web/src/lib/server/appointment/reschedule.ts index fcdd045e..2bc97266 100644 --- a/apps/web/src/lib/server/appointment/reschedule.ts +++ b/apps/web/src/lib/server/appointment/reschedule.ts @@ -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( diff --git a/apps/web/src/lib/server/availability/conflicts.test.ts b/apps/web/src/lib/server/availability/conflicts.test.ts new file mode 100644 index 00000000..a82916b5 --- /dev/null +++ b/apps/web/src/lib/server/availability/conflicts.test.ts @@ -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; + +async function seed(changes: Partial = {}) { + 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); +}); diff --git a/apps/web/src/lib/server/availability/conflicts.ts b/apps/web/src/lib/server/availability/conflicts.ts new file mode 100644 index 00000000..70211bba --- /dev/null +++ b/apps/web/src/lib/server/availability/conflicts.ts @@ -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, + meeting: Meeting, + interval: Interval, + excludeAppointmentId: string +): Promise { + // 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); +} diff --git a/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts b/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts index 22f9690e..9c77de84 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts +++ b/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts @@ -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, diff --git a/apps/web/src/routes/(app)/appointment/[id]/+page.svelte b/apps/web/src/routes/(app)/appointment/[id]/+page.svelte index 75a8d94a..ffa59fa0 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/appointment/[id]/+page.svelte @@ -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} +{#if data.canEditDuration} + +{/if} + diff --git a/apps/web/src/routes/(app)/appointment/[id]/server.test.ts b/apps/web/src/routes/(app)/appointment/[id]/server.test.ts index b4d181b4..59e913b0 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/server.test.ts +++ b/apps/web/src/routes/(app)/appointment/[id]/server.test.ts @@ -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) diff --git a/apps/web/src/routes/(auth)/admin/appointment/[id]/+page.server.ts b/apps/web/src/routes/(auth)/admin/appointment/[id]/+page.server.ts index 6b832693..822ce616 100644 --- a/apps/web/src/routes/(auth)/admin/appointment/[id]/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/appointment/[id]/+page.server.ts @@ -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) { diff --git a/apps/web/src/routes/(auth)/admin/appointment/[id]/server.test.ts b/apps/web/src/routes/(auth)/admin/appointment/[id]/server.test.ts index 12f8d96e..4db3d5d2 100644 --- a/apps/web/src/routes/(auth)/admin/appointment/[id]/server.test.ts +++ b/apps/web/src/routes/(auth)/admin/appointment/[id]/server.test.ts @@ -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(); + 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'); + }); +}); diff --git a/apps/worker/src/email/builders/appointment-edited-by-host.test.ts b/apps/worker/src/email/builders/appointment-edited-by-host.test.ts index 967bf3fa..f1b84b8e 100644 --- a/apps/worker/src/email/builders/appointment-edited-by-host.test.ts +++ b/apps/worker/src/email/builders/appointment-edited-by-host.test.ts @@ -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 .', + '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([ { diff --git a/apps/worker/src/email/builders/appointment-edited-by-host.ts b/apps/worker/src/email/builders/appointment-edited-by-host.ts index 8b251995..44544646 100644 --- a/apps/worker/src/email/builders/appointment-edited-by-host.ts +++ b/apps/worker/src/email/builders/appointment-edited-by-host.ts @@ -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: '',