feat(web): let hosts edit appointment duration in place #88
No files matched your search
@@ -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.
|
||||
@@ -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: '',
|
||||
|
||||
Reference in new issue
Block a user