diff --git a/.changeset/modern-sveltekit-three.md b/.changeset/modern-sveltekit-three.md new file mode 100644 index 00000000..c3fbec1b --- /dev/null +++ b/.changeset/modern-sveltekit-three.md @@ -0,0 +1,5 @@ +--- +'@when/web': patch +--- + +Upgrade to SvelteKit 3 with native subpath imports, current framework APIs, and trusted reverse-proxy origin handling. diff --git a/README.md b/README.md index 1e1a94d9..8eadc783 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,15 @@ openssl rand -base64 32 ``` Edit `.env` and `config/when.yaml` to make it all cozy and comfortable. +Set `ORIGIN` to your public HTTPS URL and put a trusted reverse proxy in front of +When; see the [proxy requirements](docs/deployment.md#reverse-proxy). Make `data/` writable by container UID 1000; see [permissions](docs/deployment.md#start-up). ```sh docker compose up -d ``` -Open http://localhost:3000 and `/admin` to get a lay of the land. +Open your public proxy URL and its `/admin` page to get a lay of the land. There is a lot more going on under the hood, read more documentation if you want to learn more. diff --git a/apps/web/docker-compose.dev.yml b/apps/web/docker-compose.dev.yml index 854141a0..4a96885c 100644 --- a/apps/web/docker-compose.dev.yml +++ b/apps/web/docker-compose.dev.yml @@ -12,7 +12,6 @@ services: - ./src:/m/apps/web/src - ./static:/m/apps/web/static - ./config:/m/apps/web/config - - ./svelte.config.js:/m/apps/web/svelte.config.js - ./vite.config.ts:/m/apps/web/vite.config.ts - ./tsconfig.json:/m/apps/web/tsconfig.json - ./eslint.config.js:/m/apps/web/eslint.config.js diff --git a/apps/web/e2e/auth.e2e.ts b/apps/web/e2e/auth.e2e.ts index cedb23d7..90cfc14f 100644 --- a/apps/web/e2e/auth.e2e.ts +++ b/apps/web/e2e/auth.e2e.ts @@ -1,5 +1,5 @@ import { expect, test } from './support/test.ts'; -import { ADMIN, signInAsAdminViaForm } from './support/auth.ts'; +import { ADMIN, signInAsAdmin, signInAsAdminViaForm } from './support/auth.ts'; test('an admin page sends a signed-out visitor to sign in', async ({ page }) => { await page.goto('/admin'); @@ -13,6 +13,55 @@ test('an admin page refuses a signed-out non-browser request', async ({ request expect(response.status()).toBe(403); }); +test('Google connect refuses a signed-out same-origin POST', async ({ baseURL, request }) => { + const response = await request.post('/admin/services/google/connect', { + headers: { origin: baseURL!, accept: 'application/json' }, + form: { provider: 'fixture-google' }, + maxRedirects: 0 + }); + + expect(response.status()).toBe(403); + expect(await response.json()).toMatchObject({ message: 'Not authorized.' }); + expect(response.headers()['set-cookie'] ?? '').not.toContain('when_google_oauth='); +}); + +test('Google connect redirects to consent with the callback and nonce cookie', async ({ + baseURL, + page +}) => { + await signInAsAdmin(page); + // Inspect only the redirect: never follow it to Google or exchange a code. + const response = await page.request.post('/admin/services/google/connect', { + headers: { origin: baseURL! }, + form: { provider: 'fixture-google' }, + maxRedirects: 0 + }); + + expect(response.status()).toBe(303); + const consent = new URL(response.headers().location); + expect(consent.origin).toBe('https://accounts.google.com'); + expect(consent.searchParams.get('client_id')).toBe('e2e-google-client'); + expect(consent.searchParams.get('redirect_uri')).toBe( + `${baseURL}/admin/services/google/callback` + ); + const state = consent.searchParams.get('state'); + expect(state).toBeTruthy(); + + const stateCookies = response + .headersArray() + .filter( + ({ name, value }) => + name.toLowerCase() === 'set-cookie' && value.startsWith('when_google_oauth=') + ); + expect(stateCookies).toHaveLength(1); + const cookie = stateCookies[0].value; + const value = cookie.split(';')[0].slice('when_google_oauth='.length); + expect(JSON.parse(decodeURIComponent(value))).toEqual({ state, service: 'fixture-google' }); + expect(cookie).toContain('Path=/admin'); + expect(cookie).toContain('HttpOnly'); + expect(cookie).toContain('SameSite=Lax'); +}); + test('the wrong password is rejected', async ({ page }) => { await page.goto('/signin'); await page.getByLabel('Username').fill(ADMIN.username); @@ -24,6 +73,22 @@ test('the wrong password is rejected', async ({ page }) => { await expect(page).not.toHaveURL('/admin'); }); +test('a retired sign-in callback does not turn successful authentication into a network error', async ({ + page +}) => { + // Keep this extra real-form login independent of the production per-IP limiter. + await page.setExtraHTTPHeaders({ 'x-real-ip': '192.0.2.200' }); + await page.goto('/signin?callbackUrl=%2Fretired-page'); + await page.getByLabel('Username').fill(ADMIN.username); + await page.getByLabel('Password').fill(ADMIN.password); + await page.getByRole('button', { name: 'Sign in', exact: true }).click(); + + await expect(page).toHaveURL('/retired-page'); + await expect(page.locator('.signin-form')).toHaveCount(0); + const admin = await page.request.get('/admin', { maxRedirects: 0 }); + expect(admin.status()).toBe(200); +}); + test('the fixture credentials reach the admin dashboard without a pending banner flash', async ({ page }) => { diff --git a/apps/web/e2e/booking.e2e.ts b/apps/web/e2e/booking.e2e.ts index 552e94df..1c531a3c 100644 --- a/apps/web/e2e/booking.e2e.ts +++ b/apps/web/e2e/booking.e2e.ts @@ -11,11 +11,13 @@ test('a guest books a meeting from the home page', async ({ page }) => { await pickFirstBookableDay(page); await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible(); + await expect(page).toHaveURL(/\?date=\d{4}-\d{2}-\d{2}$/); await pickSlotFromEndOfDay(page); await page.getByRole('button', { name: 'Confirm' }).click(); await expect(page.getByRole('heading', { name: 'Step 3 of 3: Enter your info' })).toBeVisible(); const slot = await page.locator('input[name="slot"]').inputValue(); + await expect(page).toHaveURL(`/schedule/${CHAT_MEETING.slug}?slot=${encodeURIComponent(slot)}`); await page.getByLabel('What is your name?').fill('E2E Guest'); await page.getByLabel('What is your email?').fill('e2e-guest@example.test'); await page.getByRole('button', { name: 'Request' }).click(); diff --git a/apps/web/e2e/deeplink.e2e.ts b/apps/web/e2e/deeplink.e2e.ts index 4fc98bc7..e5a348ff 100644 --- a/apps/web/e2e/deeplink.e2e.ts +++ b/apps/web/e2e/deeplink.e2e.ts @@ -40,5 +40,8 @@ test('a day link with no availability explains itself without breaking the route await expect(page.getByText('has no availability')).toBeVisible(); await expect(page.getByRole('heading', { name: 'Step 1 of 3: Pick a day' })).toBeVisible(); + await expect(page).toHaveURL('/schedule/chat'); + await pickLastBookableDay(page); + await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible(); expect(errors).toEqual([]); }); diff --git a/apps/web/e2e/fixture/config/when.yaml b/apps/web/e2e/fixture/config/when.yaml index a7974d62..5d45b7f4 100644 --- a/apps/web/e2e/fixture/config/when.yaml +++ b/apps/web/e2e/fixture/config/when.yaml @@ -35,6 +35,12 @@ providers: password: 'e2e-video-password' calendars: {} + fixture-google: + type: 'google' + client_id: 'e2e-google-client' + client_secret: 'e2e-google-secret' + calendars: {} + schedules: standard: weekly: diff --git a/apps/web/e2e/headers.e2e.ts b/apps/web/e2e/headers.e2e.ts index f524398e..aa12e111 100644 --- a/apps/web/e2e/headers.e2e.ts +++ b/apps/web/e2e/headers.e2e.ts @@ -20,6 +20,41 @@ test('the booking page sends the security headers', async ({ request }) => { expect(headers['content-security-policy']).toContain("frame-ancestors 'none'"); }); +test('booking forms check browser Origin against the same trusted scheme and Host', async ({ + baseURL, + request +}) => { + const form = { slot: 'not-a-slot' }; + const headers = { origin: baseURL!, accept: 'application/json' }; + const sameOrigin = await request.post('/schedule/chat', { + form, + headers, + maxRedirects: 0 + }); + expect(sameOrigin.status()).toBe(400); + expect(await sameOrigin.json()).toMatchObject({ + type: 'failure', + status: 400, + data: expect.stringContaining('Please pick a time slot.') + }); + + // Change only browser Origin, not the trusted request scheme or authority. + const foreignHeaders = { ...headers, origin: 'https://foreign.example.test' }; + const foreignOrigin = await request.post('/schedule/chat', { + form, + headers: foreignHeaders, + maxRedirects: 0 + }); + expect(foreignOrigin.status()).toBe(403); + + // No body/form option: Playwright must not add a Content-Type header. + const missingContentType = await request.post('/schedule/chat', { + headers: foreignHeaders, + maxRedirects: 0 + }); + expect(missingContentType.status()).toBe(403); +}); + test('the booking page renders and hydrates under the policy', async ({ page }) => { const violations = collectCspViolations(page); diff --git a/apps/web/e2e/host-durations.e2e.ts b/apps/web/e2e/host-durations.e2e.ts index a8c6abbc..6cedfa87 100644 --- a/apps/web/e2e/host-durations.e2e.ts +++ b/apps/web/e2e/host-durations.e2e.ts @@ -364,9 +364,10 @@ test('an empty weekend timeline keeps its duration toolbar usable without fetchi 0 ); await page.getByRole('button', { name: 'Back', exact: true }).click(); - await expect( - page.locator('.cal-day:not([data-unavailable]):not([data-disabled])').first() - ).toBeVisible(); + // Remaining bookable days may be in the next month. + await pickFirstBookableDay(page); + await expect(page.getByRole('heading', { name: 'Step 2 of 3: Pick a time' })).toBeVisible(); + await expect(page.getByRole('radio').first()).toBeVisible(); page.off('request', trackDurationRequest); expect(durationRequests).toEqual([]); }); @@ -387,7 +388,7 @@ test('client navigation between meeting slugs replaces duration defaults, choice anchor.id = 'e2e-switch-meeting'; anchor.href = href; anchor.textContent = 'Switch to chat fixture'; - anchor.setAttribute('data-sveltekit-preload-data', 'off'); + anchor.setAttribute('data-sveltekit-preload-data', 'false'); document.body.append(anchor); }, `/schedule/chat?date=${day}`); const documentRequests: string[] = []; @@ -457,7 +458,7 @@ test('an enhanced host booking conflict preserves entered fields and the chosen anchor.id = 'e2e-refresh-meeting'; anchor.href = href; anchor.textContent = 'Refresh this meeting fixture'; - anchor.setAttribute('data-sveltekit-preload-data', 'off'); + anchor.setAttribute('data-sveltekit-preload-data', 'false'); document.body.append(anchor); }, `/schedule/${slug}?duration=90&date=${day}`); const historyLength = await page.evaluate(() => history.length); diff --git a/apps/web/e2e/server.mjs b/apps/web/e2e/server.mjs index 604ce58d..974ce4c0 100644 --- a/apps/web/e2e/server.mjs +++ b/apps/web/e2e/server.mjs @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { parse, stringify } from 'yaml'; -// Bind first so config, Auth.js, Kit's origin check and the browser all use the +// Bind first so config, Better Auth, Kit's origin check and the browser all use the // same OS-assigned port. Nothing can reach the app until the handler is installed. const server = createServer(); // No background worker runs: jobs remain queued for assertions. Closing the web @@ -23,10 +23,11 @@ await new Promise((resolve, reject) => { server.once('listening', resolve); server.once('error', reject); }); -// localhost also lets Playwright's API client send Auth.js's secure session cookie +// localhost also lets Playwright's API client send Better Auth's secure session cookie // over HTTP, matching the browser's loopback secure-context exception. const origin = `http://localhost:${server.address().port}`; process.env.ORIGIN = origin; +process.env.PROTOCOL_HEADER = 'x-forwarded-proto'; const configPath = process.env.CONFIG_PATH; const directory = dirname(dirname(configPath)); const config = parse( @@ -41,6 +42,10 @@ await mkdir(dirname(configPath), { recursive: true }); await writeFile(configPath, stringify(config)); const handlerPath = join(process.env.WHEN_E2E_RUN_DIR, 'project/build/handler.js'); const { handler } = await import(pathToFileURL(handlerPath).href); -server.on('request', handler); +server.on('request', (req, res) => { + // This loopback boundary supplies the trusted scheme; Host/port stay native. + req.headers['x-forwarded-proto'] = 'http'; + handler(req, res); +}); process.send({ origin }); diff --git a/apps/web/e2e/setup.ts b/apps/web/e2e/setup.ts index 7b5b6914..c252ebae 100644 --- a/apps/web/e2e/setup.ts +++ b/apps/web/e2e/setup.ts @@ -18,14 +18,7 @@ export default async function setup() { await mkdir(join(projectDirectory, 'config'), { recursive: true }); // Kit's post-build workers rediscover config from cwd. Build a fresh copy // with the normal configs, rather than overriding paths in one build phase. - for (const path of [ - 'src', - 'static', - 'package.json', - 'svelte.config.js', - 'vite.config.ts', - 'tsconfig.json' - ]) { + for (const path of ['src', 'static', 'package.json', 'vite.config.ts', 'tsconfig.json']) { await cp(join(webDirectory, path), join(projectDirectory, path), { recursive: true }); } await symlink( diff --git a/apps/web/eslint.config.js b/apps/web/eslint.config.js index 46ee86e0..ddf47855 100644 --- a/apps/web/eslint.config.js +++ b/apps/web/eslint.config.js @@ -6,7 +6,12 @@ import { defineConfig } from 'eslint/config'; import globals from 'globals'; import prettier from 'eslint-config-prettier'; import ts from 'typescript-eslint'; -import svelteConfig from './svelte.config.js'; +import { loadConfig } from '@sveltejs/load-config'; + +const loadedConfig = await loadConfig(import.meta.dirname, { traverse: false }); +if (!loadedConfig) throw new Error('Svelte configuration not found'); +if ('error' in loadedConfig) throw loadedConfig.error; +const svelteConfig = loadedConfig.config; const gitignorePath = path.resolve(import.meta.dirname, '../../.gitignore'); diff --git a/apps/web/package.json b/apps/web/package.json index 71ae5cbb..34da34dd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,8 +29,9 @@ "@eslint/js": "10.0.1", "@iconify-json/ph": "1.2.2", "@playwright/test": "1.61.1", - "@sveltejs/adapter-node": "5.5.4", - "@sveltejs/kit": "2.70.1", + "@sveltejs/adapter-node": "6.0.1", + "@sveltejs/kit": "3.1.1", + "@sveltejs/load-config": "0.2.2", "@sveltejs/vite-plugin-svelte": "7.1.2", "@types/node": "24.12.4", "@vitest/coverage-v8": "4.1.7", @@ -41,8 +42,8 @@ "pino-pretty": "13.1.3", "prettier": "3.8.3", "prettier-plugin-svelte": "3.5.2", - "svelte": "5.56.0", - "svelte-check": "4.4.8", + "svelte": "5.57.3", + "svelte-check": "4.7.7", "typescript": "6.0.3", "typescript-eslint": "8.60.0", "unplugin-icons": "23.0.1", @@ -68,5 +69,8 @@ "temporal-polyfill": "1.0.1", "ts-ics": "2.4.4", "yaml": "2.9.0" + }, + "imports": { + "#lib/*": "./src/lib/*" } } diff --git a/apps/web/src/app.d.ts b/apps/web/src/app.d.ts index 5e81e5ca..a9fe704b 100644 --- a/apps/web/src/app.d.ts +++ b/apps/web/src/app.d.ts @@ -1,4 +1,4 @@ -import type { WhenSession } from '$lib/server/auth'; +import type { WhenSession } from '#lib/server/auth/index.js'; // See https://svelte.dev/docs/kit/types#app.d.ts // for information about these interfaces diff --git a/apps/web/src/hooks.server.test.ts b/apps/web/src/hooks.server.test.ts index b643b90a..46154200 100644 --- a/apps/web/src/hooks.server.test.ts +++ b/apps/web/src/hooks.server.test.ts @@ -1,11 +1,11 @@ +import type { Handle } from '@sveltejs/kit/hooks'; import { describe, expect, test, vi } from 'vitest'; -import type { Handle } from '@sveltejs/kit'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; -vi.mock('$lib/server/boot', () => ({ bootApp: async () => {} })); -vi.mock('$lib/server/appearance', () => ({ themeStyleTag: () => '' })); -vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig })); -vi.mock('$lib/server/auth', () => ({ +vi.mock('#lib/server/boot.js', () => ({ bootApp: async () => {} })); +vi.mock('#lib/server/appearance.js', () => ({ themeStyleTag: () => '' })); +vi.mock('#lib/server/state.js', () => ({ getConfig: () => validConfig })); +vi.mock('#lib/server/auth/index.js', () => ({ authHandle: (({ event, resolve }) => resolve(event)) as Handle })); diff --git a/apps/web/src/hooks.server.ts b/apps/web/src/hooks.server.ts index 31bdf535..cf60af71 100644 --- a/apps/web/src/hooks.server.ts +++ b/apps/web/src/hooks.server.ts @@ -1,11 +1,11 @@ -import { sequence } from '@sveltejs/kit/hooks'; -import { redirect, error, type Handle } from '@sveltejs/kit'; -import { authHandle } from '$lib/server/auth'; -import { localRedirect } from '$lib/server/redirect'; -import { bootApp } from '$lib/server/boot'; -import { logger } from '$lib/server/logger'; -import { getConfig } from '$lib/server/state'; -import { themeStyleTag } from '$lib/server/appearance'; +import { redirect, error } from '@sveltejs/kit'; +import { sequence, type Handle } from '@sveltejs/kit/hooks'; +import { authHandle } from '#lib/server/auth/index.js'; +import { localRedirect } from '#lib/server/redirect.js'; +import { bootApp } from '#lib/server/boot.js'; +import { logger } from '#lib/server/logger.js'; +import { getConfig } from '#lib/server/state.js'; +import { themeStyleTag } from '#lib/server/appearance.js'; try { await bootApp(); diff --git a/apps/web/src/lib/appointment.test.ts b/apps/web/src/lib/appointment.test.ts index e505a06a..006d4113 100644 --- a/apps/web/src/lib/appointment.test.ts +++ b/apps/web/src/lib/appointment.test.ts @@ -369,8 +369,9 @@ describe('desiredUrlState', () => { }); describe('urlStateMatches', () => { - test('is true only when every param matches', () => { - const params = new URLSearchParams('duration=60&date=2025-06-15'); + test('reads matching values without requiring mutable params', () => { + const search = new URLSearchParams('duration=60&date=2025-06-15'); + const params = { get: search.get.bind(search) }; expect(urlStateMatches(params, { duration: '60', slot: null, date: '2025-06-15' })).toBe(true); expect(urlStateMatches(params, { duration: null, slot: null, date: '2025-06-15' })).toBe(false); }); diff --git a/apps/web/src/lib/appointment.ts b/apps/web/src/lib/appointment.ts index 47c5a836..01180728 100644 --- a/apps/web/src/lib/appointment.ts +++ b/apps/web/src/lib/appointment.ts @@ -160,7 +160,10 @@ export function desiredUrlState(p: { } /** True when the URL already encodes this state — the guard against a re-encode loop. */ -export function urlStateMatches(params: URLSearchParams, state: AppointmentUrlState): boolean { +export function urlStateMatches( + params: Pick, + state: AppointmentUrlState +): boolean { return ( params.get('duration') === state.duration && params.get('slot') === state.slot && diff --git a/apps/web/src/lib/appointmentUrlSync.svelte.test.ts b/apps/web/src/lib/appointmentUrlSync.svelte.test.ts deleted file mode 100644 index da39e991..00000000 --- a/apps/web/src/lib/appointmentUrlSync.svelte.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { flushSync } from 'svelte'; -import { createAppointmentFlow } from './appointmentFlow.svelte'; - -const m = vi.hoisted(() => ({ - page: { url: new URL('http://localhost/schedule/x'), state: {} }, - replaceState: vi.fn(), - afterCb: { current: null as null | (() => void) } -})); - -vi.mock('$app/state', () => ({ page: m.page })); -vi.mock('$app/navigation', () => ({ - afterNavigate: (cb: () => void) => { - m.afterCb.current = cb; - }, - replaceState: (...a: unknown[]) => m.replaceState(...a) -})); - -import { createAppointmentUrlSync } from './appointmentUrlSync.svelte'; - -const SLOT = '2026-07-20T10:00:00Z'; - -function mkFlow() { - const flow = createAppointmentFlow({ - slotsByDuration: { 30: { '2026-07-20': [SLOT] } }, - durations: [30], - initialDuration: 30 - }); - flow.setTz('UTC'); - return flow; -} - -function sync(flow: ReturnType) { - return createAppointmentUrlSync(flow, { deepLink: true, tz: 'UTC', defaultDuration: 30 }); -} - -beforeEach(() => { - vi.clearAllMocks(); - m.page.url = new URL('http://localhost/schedule/x'); - m.afterCb.current = null; -}); - -describe('createAppointmentUrlSync deep-link application', () => { - it('selects an available deep-linked slot and jumps to the details step', () => { - m.page.url = new URL(`http://localhost/schedule/x?slot=${SLOT}`); - $effect.root(() => { - const flow = mkFlow(); - sync(flow); - expect(flow.selectedSlot).toBe(SLOT); - expect(flow.step).toBe(3); - })(); - }); - - it('opens the day and notices an unavailable slot on an available day', () => { - m.page.url = new URL('http://localhost/schedule/x?slot=2026-07-20T11:00:00Z'); - $effect.root(() => { - const flow = mkFlow(); - const s = sync(flow); - expect(flow.selectedSlot).toBeNull(); - expect(flow.viewDate).toBe('2026-07-20'); - expect(s.notice?.kind).toBe('slot'); - })(); - }); - - it('notices an unavailable date without opening anything', () => { - m.page.url = new URL('http://localhost/schedule/x?date=2030-01-01'); - $effect.root(() => { - const flow = mkFlow(); - const s = sync(flow); - expect(flow.viewDate).toBeNull(); - expect(s.notice?.kind).toBe('date'); - })(); - }); -}); - -describe('createAppointmentUrlSync effects', () => { - it('mirrors the flow position into the url once the router is ready', () => { - $effect.root(() => { - const flow = mkFlow(); - sync(flow); - m.afterCb.current?.(); - flow.selectSlot(SLOT); - flow.goToStep(3); - flushSync(); - expect(m.replaceState).toHaveBeenCalledWith( - expect.stringContaining(`slot=${encodeURIComponent(SLOT)}`), - expect.anything() - ); - })(); - }); - - it('clears a slot notice once a slot is picked', () => { - m.page.url = new URL('http://localhost/schedule/x?slot=2026-07-20T11:00:00Z'); - $effect.root(() => { - const flow = mkFlow(); - const s = sync(flow); - expect(s.notice?.kind).toBe('slot'); - flow.selectSlot(SLOT); - flushSync(); - expect(s.notice).toBeNull(); - })(); - }); -}); diff --git a/apps/web/src/lib/appointmentUrlSync.svelte.ts b/apps/web/src/lib/appointmentUrlSync.svelte.ts index 524ec2d9..54bcfc91 100644 --- a/apps/web/src/lib/appointmentUrlSync.svelte.ts +++ b/apps/web/src/lib/appointmentUrlSync.svelte.ts @@ -1,5 +1,5 @@ import { page } from '$app/state'; -import { replaceState, afterNavigate } from '$app/navigation'; +import { goto, afterNavigate } from '$app/navigation'; import { resolveDeepLink, desiredUrlState, @@ -66,8 +66,14 @@ export function createAppointmentUrlSync( selectedSlot: flow.selectedSlot, viewDate: flow.viewDate }); - if (urlStateMatches(page.url.searchParams, desired)) return; - replaceState(`${page.url.pathname}${buildAppointmentSearch(desired)}`, page.state); + // Shallow navigation changes page.shallow, not the last fully loaded page.url. + const url = page.shallow?.url ?? page.url; + if (urlStateMatches(url.searchParams, desired)) return; + void goto(`${url.pathname}${buildAppointmentSearch(desired)}`, { + shallow: true, + replace: true, + state: page.state + }).catch((error) => console.error('Failed to synchronize booking URL', error)); }); return { diff --git a/apps/web/src/lib/components/AdminNav.svelte b/apps/web/src/lib/components/AdminNav.svelte index afd7780b..5e8fb471 100644 --- a/apps/web/src/lib/components/AdminNav.svelte +++ b/apps/web/src/lib/components/AdminNav.svelte @@ -2,7 +2,7 @@ import type { Snippet } from 'svelte'; import { page } from '$app/state'; import { goto } from '$app/navigation'; - import { authClient } from '$lib/auth-client'; + import { authClient } from '#lib/auth-client.js'; import IconCalendarBlank from 'virtual:icons/ph/calendar-blank'; import IconArrowUpLeft from 'virtual:icons/ph/arrow-up-left'; import IconSignOut from 'virtual:icons/ph/sign-out'; @@ -32,7 +32,7 @@ signoutError = error.status === 429 ? 'rate' : 'auth'; return; } - await goto(redirectTo, { invalidateAll: true }); + await goto(redirectTo, { refreshAll: true }); } catch { signoutError = 'network'; } finally { diff --git a/apps/web/src/lib/components/AdminPage.svelte b/apps/web/src/lib/components/AdminPage.svelte index fc74eb95..1d41cd7a 100644 --- a/apps/web/src/lib/components/AdminPage.svelte +++ b/apps/web/src/lib/components/AdminPage.svelte @@ -1,6 +1,6 @@ diff --git a/apps/web/src/lib/components/LinkNotice.svelte b/apps/web/src/lib/components/LinkNotice.svelte index 08105973..7a844e79 100644 --- a/apps/web/src/lib/components/LinkNotice.svelte +++ b/apps/web/src/lib/components/LinkNotice.svelte @@ -1,8 +1,8 @@ diff --git a/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts b/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts index 8b42ab01..7e45ab3c 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts +++ b/apps/web/src/routes/(app)/appointment/[id]/+page.server.ts @@ -1,14 +1,14 @@ import { error, fail, redirect } from '@sveltejs/kit'; import { shouldAttachVideoChat } from '@when/video-chat'; -import { isEditAllowed, isViewAllowed } from '$lib/server/appointment/access'; +import { isEditAllowed, isViewAllowed } from '#lib/server/appointment/access.js'; import { isActiveStatus, resolveAppointmentActions, type AppointmentRole -} from '$lib/server/appointment/actions'; -import { buildAddToCalendarLinks } from '$lib/server/calendar-links'; -import { systemClock } from '$lib/server/clock'; -import { getConfig, getDb } from '$lib/server/state'; +} from '#lib/server/appointment/actions.js'; +import { buildAddToCalendarLinks } from '#lib/server/calendar-links/index.js'; +import { systemClock } from '#lib/server/clock.js'; +import { getConfig, getDb } from '#lib/server/state.js'; import { findAppointment, findChainTip, @@ -17,13 +17,13 @@ import { parseActionLog, type Appointment } from '@when/db'; -import { toPublicAppointment } from '$lib/server/appointment/sanitize'; -import { readMeetingSnapshot } from '$lib/server/appointment/snapshot'; -import { resolveBookingDurations } from '$lib/server/appointment/duration'; +import { toPublicAppointment } from '#lib/server/appointment/sanitize.js'; +import { readMeetingSnapshot } from '#lib/server/appointment/snapshot.js'; +import { resolveBookingDurations } from '#lib/server/appointment/duration.js'; import type { Actions, PageServerLoad } from './$types'; -import { cancelAppointment } from '$lib/server/appointment/cancel'; -import { validateReason } from '$lib/server/appointment/form.server'; -import { appointmentContext } from '$lib/server/appointment/context'; +import { cancelAppointment } from '#lib/server/appointment/cancel.js'; +import { validateReason } from '#lib/server/appointment/form.server.js'; +import { appointmentContext } from '#lib/server/appointment/context.js'; type ClockStatus = 'upcoming' | 'in_progress' | 'concluded'; diff --git a/apps/web/src/routes/(app)/appointment/[id]/+page.svelte b/apps/web/src/routes/(app)/appointment/[id]/+page.svelte index 30aeebf8..a053ab49 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/+page.svelte +++ b/apps/web/src/routes/(app)/appointment/[id]/+page.svelte @@ -1,8 +1,7 @@ diff --git a/apps/web/src/routes/(app)/appointment/[id]/reschedule/server.test.ts b/apps/web/src/routes/(app)/appointment/[id]/reschedule/server.test.ts index 8bb5ab55..b6671440 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/reschedule/server.test.ts +++ b/apps/web/src/routes/(app)/appointment/[id]/reschedule/server.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; import type { Appointment } from '@when/db'; import type { Meeting } from '@when/config'; -import type { AppointmentRole } from '$lib/server/appointment/actions'; -import { resolveBookingDurations } from '$lib/server/appointment/duration'; +import type { AppointmentRole } from '#lib/server/appointment/actions.js'; +import { resolveBookingDurations } from '#lib/server/appointment/duration.js'; const h = vi.hoisted(() => ({ db: {}, @@ -21,7 +21,7 @@ const h = vi.hoisted(() => ({ toPublicEventType: vi.fn() })); -vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig, getDb: () => h.db })); +vi.mock('#lib/server/state.js', () => ({ getConfig: () => validConfig, getDb: () => h.db })); vi.mock('@when/db', async (io) => { const original = await io(); return { @@ -29,19 +29,19 @@ vi.mock('@when/db', async (io) => { findAppointment: async () => h.row.current }; }); -vi.mock('$lib/server/appointment/access', () => ({ +vi.mock('#lib/server/appointment/access.js', () => ({ isViewAllowed: h.viewAllowed })); -vi.mock('$lib/server/appointment/reschedule', () => ({ +vi.mock('#lib/server/appointment/reschedule.js', () => ({ classifyReschedule: h.classify, rescheduleAppointment: h.reschedule })); -vi.mock('$lib/server/availability/booking', () => ({ +vi.mock('#lib/server/availability/booking.js', () => ({ loadAvailability: h.loadAvailability, isRescheduleSlotBookable: h.isRescheduleSlotBookable })); -vi.mock('$lib/server/appointment/form.server', async (io) => { - const original = await io(); +vi.mock('#lib/server/appointment/form.server.js', async (io) => { + const original = await io(); return { ...original, parseAndValidateAppointmentForm: h.parseForm, @@ -49,12 +49,12 @@ vi.mock('$lib/server/appointment/form.server', async (io) => { validateReason: h.validateReason }; }); -vi.mock('$lib/server/appointment/duration', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('#lib/server/appointment/duration.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, resolveDuration: h.resolveDuration }; }); -vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('#lib/server/appointment/sanitize.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, toPublicAppointment: h.toPublicAppointment, diff --git a/apps/web/src/routes/(app)/appointment/[id]/server.test.ts b/apps/web/src/routes/(app)/appointment/[id]/server.test.ts index 16edb040..83229c79 100644 --- a/apps/web/src/routes/(app)/appointment/[id]/server.test.ts +++ b/apps/web/src/routes/(app)/appointment/[id]/server.test.ts @@ -1,15 +1,15 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { actions, load } from './+page.server'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; import type { Appointment } from '@when/db'; -import type { AppointmentRole } from '$lib/server/appointment/actions'; -import { resolveBookingDurations } from '$lib/server/appointment/duration'; +import type { AppointmentRole } from '#lib/server/appointment/actions.js'; +import { resolveBookingDurations } from '#lib/server/appointment/duration.js'; const h = vi.hoisted(() => ({ cancelAppointment: vi.fn() })); -vi.mock('$lib/server/appointment/cancel', () => ({ cancelAppointment: h.cancelAppointment })); -vi.mock('$lib/server/appointment/duration', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('#lib/server/appointment/cancel.js', () => ({ cancelAppointment: h.cancelAppointment })); +vi.mock('#lib/server/appointment/duration.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, resolveBookingDurations: vi.fn(original.resolveBookingDurations) }; }); @@ -130,7 +130,7 @@ const automaticVideoAppointment: Appointment = { ]) }; -vi.mock('$lib/server/state', () => ({ +vi.mock('#lib/server/state.js', () => ({ getConfig: () => loadConfig, getDb: () => mockDb })); diff --git a/apps/web/src/routes/(app)/schedule/[slug]/+page.server.ts b/apps/web/src/routes/(app)/schedule/[slug]/+page.server.ts index cff1e65b..819551c7 100644 --- a/apps/web/src/routes/(app)/schedule/[slug]/+page.server.ts +++ b/apps/web/src/routes/(app)/schedule/[slug]/+page.server.ts @@ -1,20 +1,20 @@ import { error, fail, redirect } from '@sveltejs/kit'; -import { isNewSlotBookable, loadAvailability } from '$lib/server/availability/booking'; -import { logger } from '$lib/server/logger'; -import { getConfig, getDb } from '$lib/server/state'; +import { isNewSlotBookable, loadAvailability } from '#lib/server/availability/booking.js'; +import { logger } from '#lib/server/logger.js'; +import { getConfig, getDb } from '#lib/server/state.js'; import { resolveFormFields } from '@when/config'; -import { createAppointment } from '$lib/server/appointment/create'; -import type { AppointmentRole } from '$lib/server/appointment/actions'; -import { resolveBookingDurations, resolveDuration } from '$lib/server/appointment/duration'; +import { createAppointment } from '#lib/server/appointment/create.js'; +import type { AppointmentRole } from '#lib/server/appointment/actions.js'; +import { resolveBookingDurations, resolveDuration } from '#lib/server/appointment/duration.js'; import { parseAndValidateAppointmentForm, parseSlot, resolveTimezone -} from '$lib/server/appointment/form.server'; -import { appointmentContext } from '$lib/server/appointment/context'; -import { normalizeDeepLinkParams } from '$lib/appointment'; -import { toPublicEventType } from '$lib/server/appointment/sanitize'; -import { bookingAttemptsTotal } from '$lib/server/metrics'; +} from '#lib/server/appointment/form.server.js'; +import { appointmentContext } from '#lib/server/appointment/context.js'; +import { normalizeDeepLinkParams } from '#lib/appointment.js'; +import { toPublicEventType } from '#lib/server/appointment/sanitize.js'; +import { bookingAttemptsTotal } from '#lib/server/metrics.js'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ params, url, locals }) => { diff --git a/apps/web/src/routes/(app)/schedule/[slug]/+page.svelte b/apps/web/src/routes/(app)/schedule/[slug]/+page.svelte index 59f63990..e0692578 100644 --- a/apps/web/src/routes/(app)/schedule/[slug]/+page.svelte +++ b/apps/web/src/routes/(app)/schedule/[slug]/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/(app)/schedule/[slug]/server.test.ts b/apps/web/src/routes/(app)/schedule/[slug]/server.test.ts index 7aeb407a..289d93ba 100644 --- a/apps/web/src/routes/(app)/schedule/[slug]/server.test.ts +++ b/apps/web/src/routes/(app)/schedule/[slug]/server.test.ts @@ -1,9 +1,9 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; import type { Meeting, WhenConfiguration } from '@when/config'; -import type { AppointmentRole } from '$lib/server/appointment/actions'; -import { resolveBookingDurations } from '$lib/server/appointment/duration'; -import { toPublicEventType } from '$lib/server/appointment/sanitize'; +import type { AppointmentRole } from '#lib/server/appointment/actions.js'; +import { resolveBookingDurations } from '#lib/server/appointment/duration.js'; +import { toPublicEventType } from '#lib/server/appointment/sanitize.js'; const h = vi.hoisted(() => ({ cfg: { current: null as unknown as WhenConfiguration }, @@ -17,26 +17,26 @@ const h = vi.hoisted(() => ({ resolveDuration: vi.fn() })); -vi.mock('$lib/server/state', () => ({ getConfig: () => h.cfg.current, getDb: h.getDb })); -vi.mock('$lib/server/availability/booking', () => ({ +vi.mock('#lib/server/state.js', () => ({ getConfig: () => h.cfg.current, getDb: h.getDb })); +vi.mock('#lib/server/availability/booking.js', () => ({ loadAvailability: h.loadAvailability, isNewSlotBookable: h.isNewSlotBookable })); -vi.mock('$lib/server/appointment/create', () => ({ createAppointment: h.createAppointment })); -vi.mock('$lib/server/appointment/form.server', async (io) => { - const original = await io(); +vi.mock('#lib/server/appointment/create.js', () => ({ createAppointment: h.createAppointment })); +vi.mock('#lib/server/appointment/form.server.js', async (io) => { + const original = await io(); return { ...original, parseAndValidateAppointmentForm: h.parseForm, resolveTimezone: h.resolveTimezone }; }); -vi.mock('$lib/server/appointment/duration', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('#lib/server/appointment/duration.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, resolveDuration: h.resolveDuration }; }); -vi.mock('$lib/server/appointment/sanitize', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('#lib/server/appointment/sanitize.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, toPublicEventType: vi.fn(original.toPublicEventType) }; }); diff --git a/apps/web/src/routes/(app)/server.test.ts b/apps/web/src/routes/(app)/server.test.ts index e1fece92..b5c05376 100644 --- a/apps/web/src/routes/(app)/server.test.ts +++ b/apps/web/src/routes/(app)/server.test.ts @@ -1,12 +1,12 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; import type { Meeting, WhenConfiguration } from '@when/config'; const h = vi.hoisted(() => ({ cfg: { current: null as unknown as WhenConfiguration } })); -vi.mock('$lib/server/state', () => ({ getConfig: () => h.cfg.current })); +vi.mock('#lib/server/state.js', () => ({ getConfig: () => h.cfg.current })); import { load } from './+page.server'; diff --git a/apps/web/src/routes/(auth)/admin/+page.server.ts b/apps/web/src/routes/(auth)/admin/+page.server.ts index 3ecc5007..2955e861 100644 --- a/apps/web/src/routes/(auth)/admin/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/+page.server.ts @@ -1,7 +1,7 @@ -import { systemClock } from '$lib/server/clock'; -import { getConfig, getDb } from '$lib/server/state'; -import { toAppointmentView } from '$lib/server/appointments'; -import { evaluateCalendarStatuses } from '$lib/server/calendar/health'; +import { systemClock } from '#lib/server/clock.js'; +import { getConfig, getDb } from '#lib/server/state.js'; +import { toAppointmentView } from '#lib/server/appointments.js'; +import { evaluateCalendarStatuses } from '#lib/server/calendar/health.js'; import { countAppointments, listAppointmentsPage, listServiceStatus } from '@when/db'; import { allCalendars } from '@when/config'; import { sql } from 'kysely'; diff --git a/apps/web/src/routes/(auth)/admin/+page.svelte b/apps/web/src/routes/(auth)/admin/+page.svelte index 5ba49de9..f33fdb6c 100644 --- a/apps/web/src/routes/(auth)/admin/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/+page.svelte @@ -1,7 +1,7 @@ diff --git a/apps/web/src/routes/(auth)/admin/appointments/pending/+page.server.ts b/apps/web/src/routes/(auth)/admin/appointments/pending/+page.server.ts index b0b4aa9e..3ce24124 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/pending/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/appointments/pending/+page.server.ts @@ -1,6 +1,6 @@ -import { systemClock } from '$lib/server/clock'; -import { getConfig, getDb } from '$lib/server/state'; -import { toAppointmentView } from '$lib/server/appointments'; +import { systemClock } from '#lib/server/clock.js'; +import { getConfig, getDb } from '#lib/server/state.js'; +import { toAppointmentView } from '#lib/server/appointments.js'; import { countAppointments, listAppointmentsPage } from '@when/db'; import type { PageServerLoad } from './$types'; diff --git a/apps/web/src/routes/(auth)/admin/appointments/pending/+page.svelte b/apps/web/src/routes/(auth)/admin/appointments/pending/+page.svelte index 1c793f93..76a46bcb 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/pending/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/appointments/pending/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/(auth)/admin/appointments/purged/+page.server.ts b/apps/web/src/routes/(auth)/admin/appointments/purged/+page.server.ts index 5758e9a6..1293498f 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/purged/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/appointments/purged/+page.server.ts @@ -1,6 +1,6 @@ -import { systemClock } from '$lib/server/clock'; -import { getConfig, getDb } from '$lib/server/state'; -import { toAppointmentView } from '$lib/server/appointments'; +import { systemClock } from '#lib/server/clock.js'; +import { getConfig, getDb } from '#lib/server/state.js'; +import { toAppointmentView } from '#lib/server/appointments.js'; import { countAppointments, listAppointmentsPage } from '@when/db'; import type { PageServerLoad } from './$types'; diff --git a/apps/web/src/routes/(auth)/admin/appointments/purged/+page.svelte b/apps/web/src/routes/(auth)/admin/appointments/purged/+page.svelte index d410c996..b1ea1c2d 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/purged/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/appointments/purged/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/(auth)/admin/appointments/server.test.ts b/apps/web/src/routes/(auth)/admin/appointments/server.test.ts index 359f93a6..759ba315 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/server.test.ts +++ b/apps/web/src/routes/(auth)/admin/appointments/server.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test, vi, beforeEach } from 'vitest'; import type { RequestEvent } from './$types'; import { POST } from './+server'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; import type { Appointment } from '@when/db'; const mockDb = { @@ -66,7 +66,7 @@ const mockAppts: Record = { } }; -vi.mock('$lib/server/state', () => ({ +vi.mock('#lib/server/state.js', () => ({ getConfig: () => validConfig, getDb: () => mockDb })); @@ -88,7 +88,7 @@ vi.mock('@when/db', async (importOriginal) => { }); const mockPurgeAppointment = vi.fn(); -vi.mock('$lib/server/appointment/purge', () => ({ +vi.mock('#lib/server/appointment/purge.js', () => ({ purgeAppointment: async (_ctx: unknown, input: { appointment: Appointment }) => { mockPurgeAppointment(input.appointment.id); return { ok: true }; @@ -96,7 +96,7 @@ vi.mock('$lib/server/appointment/purge', () => ({ })); const mockCancelAppointment = vi.fn(); -vi.mock('$lib/server/appointment/cancel', () => ({ +vi.mock('#lib/server/appointment/cancel.js', () => ({ cancelAppointment: async ( _ctx: unknown, input: { appointment: Appointment; initiator: string; reason: string } @@ -107,7 +107,7 @@ vi.mock('$lib/server/appointment/cancel', () => ({ })); const mockAcceptAppointment = vi.fn(); -vi.mock('$lib/server/appointment/accept', () => ({ +vi.mock('#lib/server/appointment/accept.js', () => ({ acceptAppointment: async (_ctx: unknown, input: { appointment: Appointment }) => { mockAcceptAppointment(input.appointment.id); return { ok: true }; @@ -115,7 +115,7 @@ vi.mock('$lib/server/appointment/accept', () => ({ })); const mockDeclineAppointment = vi.fn(); -vi.mock('$lib/server/appointment/decline', () => ({ +vi.mock('#lib/server/appointment/decline.js', () => ({ declineAppointment: async (_ctx: unknown, input: { appointment: Appointment }) => { mockDeclineAppointment(input.appointment.id); return { ok: true }; diff --git a/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.server.ts b/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.server.ts index 029e0709..e423853f 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.server.ts @@ -1,6 +1,6 @@ -import { systemClock } from '$lib/server/clock'; -import { getConfig, getDb } from '$lib/server/state'; -import { toAppointmentView } from '$lib/server/appointments'; +import { systemClock } from '#lib/server/clock.js'; +import { getConfig, getDb } from '#lib/server/state.js'; +import { toAppointmentView } from '#lib/server/appointments.js'; import { countAppointments, listAppointmentsPage } from '@when/db'; import type { PageServerLoad } from './$types'; diff --git a/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.svelte b/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.svelte index a94f97d8..5429371a 100644 --- a/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/appointments/upcoming/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/(auth)/admin/health/+page.server.ts b/apps/web/src/routes/(auth)/admin/health/+page.server.ts index 7b5a4d1e..2da12a44 100644 --- a/apps/web/src/routes/(auth)/admin/health/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/health/+page.server.ts @@ -1,11 +1,11 @@ import { sql, type Kysely } from 'kysely'; import { durationsOf } from '@when/config'; import type { Database } from '@when/db'; -import { getConfig, getDb } from '$lib/server/state'; -import { discoverCalendars, listProviders, probeProvider } from '$lib/server/providers/status'; -import { listCalendars, probeCalendar } from '$lib/server/calendar/status'; -import { sendTestEmail, smtpSummary } from '$lib/server/email/status'; -import { workerReachable } from '$lib/server/worker'; +import { getConfig, getDb } from '#lib/server/state.js'; +import { discoverCalendars, listProviders, probeProvider } from '#lib/server/providers/status.js'; +import { listCalendars, probeCalendar } from '#lib/server/calendar/status.js'; +import { sendTestEmail, smtpSummary } from '#lib/server/email/status.js'; +import { workerReachable } from '#lib/server/worker.js'; import type { Actions, PageServerLoad } from './$types'; async function listBookingCounts(db: Kysely): Promise> { diff --git a/apps/web/src/routes/(auth)/admin/health/+page.svelte b/apps/web/src/routes/(auth)/admin/health/+page.svelte index d7905aff..a004bead 100644 --- a/apps/web/src/routes/(auth)/admin/health/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/health/+page.svelte @@ -3,8 +3,8 @@ import IconWarningCircle from 'virtual:icons/ph/warning-circle'; import IconSpinner from 'virtual:icons/ph/spinner'; import { enhance } from '$app/forms'; - import AdminPage from '$lib/components/AdminPage.svelte'; - import { timeAgo } from '$lib/datetime'; + import AdminPage from '#lib/components/AdminPage.svelte'; + import { timeAgo } from '#lib/datetime.js'; import type { SubmitFunction } from './$types'; let { data, form } = $props(); diff --git a/apps/web/src/routes/(auth)/admin/health/page.test.ts b/apps/web/src/routes/(auth)/admin/health/page.test.ts index 7ed29ed8..6f6e4fb9 100644 --- a/apps/web/src/routes/(auth)/admin/health/page.test.ts +++ b/apps/web/src/routes/(auth)/admin/health/page.test.ts @@ -2,7 +2,7 @@ import { expect, test, vi } from 'vitest'; import { render } from 'svelte/server'; import HealthPage from './+page.svelte'; import type { ActionData, PageData } from './$types'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; vi.mock('$app/state', () => ({ page: { url: new URL('https://book.example.com/admin/health') } diff --git a/apps/web/src/routes/(auth)/admin/health/server.test.ts b/apps/web/src/routes/(auth)/admin/health/server.test.ts index 8b4aaa20..e633781e 100644 --- a/apps/web/src/routes/(auth)/admin/health/server.test.ts +++ b/apps/web/src/routes/(auth)/admin/health/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from 'vitest'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; const mockDb = { selectFrom: vi.fn().mockReturnValue({ @@ -13,23 +13,23 @@ const mockDb = { }) }; -vi.mock('$lib/server/state', () => ({ +vi.mock('#lib/server/state.js', () => ({ getDb: () => mockDb, getConfig: () => validConfig })); -vi.mock('$lib/server/providers/status', () => ({ +vi.mock('#lib/server/providers/status.js', () => ({ listProviders: vi.fn().mockResolvedValue([]), discoverCalendars: vi.fn(), probeProvider: vi.fn() })); -vi.mock('$lib/server/calendar/status', () => ({ +vi.mock('#lib/server/calendar/status.js', () => ({ listCalendars: vi.fn().mockResolvedValue([]), probeCalendar: vi.fn() })); -vi.mock('$lib/server/email/status', () => ({ +vi.mock('#lib/server/email/status.js', () => ({ smtpSummary: vi.fn().mockResolvedValue({ host: 'smtp.example.com', port: 587, @@ -41,18 +41,18 @@ vi.mock('$lib/server/email/status', () => ({ sendTestEmail: vi.fn() })); -vi.mock('$lib/server/worker', () => ({ +vi.mock('#lib/server/worker.js', () => ({ workerReachable: vi.fn().mockResolvedValue(true) })); -import { sendTestEmail, type SendResult } from '$lib/server/email/status'; +import { sendTestEmail, type SendResult } from '#lib/server/email/status.js'; import { discoverCalendars, probeProvider, type DiscoveryResult, type ProbeResult -} from '$lib/server/providers/status'; -import { probeCalendar, type CalendarProbeResult } from '$lib/server/calendar/status'; +} from '#lib/server/providers/status.js'; +import { probeCalendar, type CalendarProbeResult } from '#lib/server/calendar/status.js'; import { actions, load } from './+page.server'; type LoadResult = Exclude>, void>; diff --git a/apps/web/src/routes/(auth)/admin/page.server.test.ts b/apps/web/src/routes/(auth)/admin/page.server.test.ts index 182d3d20..8c1944ab 100644 --- a/apps/web/src/routes/(auth)/admin/page.server.test.ts +++ b/apps/web/src/routes/(auth)/admin/page.server.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { openDb, runMigrations } from '@when/db'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; -import { systemClock } from '$lib/server/clock'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; +import { systemClock } from '#lib/server/clock.js'; let mockDb: Awaited> | null = null; -vi.mock('$lib/server/state', () => ({ +vi.mock('#lib/server/state.js', () => ({ getDb: () => { if (!mockDb) throw new Error('mockDb not initialized'); return mockDb; diff --git a/apps/web/src/routes/(auth)/admin/schedule/[slug]/+page.server.ts b/apps/web/src/routes/(auth)/admin/schedule/[slug]/+page.server.ts index 71a8de49..aac57db6 100644 --- a/apps/web/src/routes/(auth)/admin/schedule/[slug]/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/schedule/[slug]/+page.server.ts @@ -1,14 +1,14 @@ import { error, fail, redirect } from '@sveltejs/kit'; -import { isNewSlotBookable } from '$lib/server/availability/booking'; -import { getConfig, getDb } from '$lib/server/state'; -import { createAppointment } from '$lib/server/appointment/create'; -import { resolveDuration } from '$lib/server/appointment/duration'; +import { isNewSlotBookable } from '#lib/server/availability/booking.js'; +import { getConfig, getDb } from '#lib/server/state.js'; +import { createAppointment } from '#lib/server/appointment/create.js'; +import { resolveDuration } from '#lib/server/appointment/duration.js'; import { parseAndValidateAppointmentForm, parseSlot, resolveTimezone -} from '$lib/server/appointment/form.server'; -import { appointmentContext } from '$lib/server/appointment/context'; +} from '#lib/server/appointment/form.server.js'; +import { appointmentContext } from '#lib/server/appointment/context.js'; import type { Actions } from './$types'; export const actions: Actions = { diff --git a/apps/web/src/routes/(auth)/admin/schedule/[slug]/server.test.ts b/apps/web/src/routes/(auth)/admin/schedule/[slug]/server.test.ts index 6d54dcdf..efeb749e 100644 --- a/apps/web/src/routes/(auth)/admin/schedule/[slug]/server.test.ts +++ b/apps/web/src/routes/(auth)/admin/schedule/[slug]/server.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { validConfig } from '$lib/server/__fixtures__/valid-config'; +import { validConfig } from '#lib/server/__fixtures__/valid-config.js'; const h = vi.hoisted(() => ({ db: {}, @@ -9,15 +9,15 @@ const h = vi.hoisted(() => ({ resolveDuration: vi.fn() })); -vi.mock('$lib/server/state', () => ({ getConfig: () => validConfig, getDb: () => h.db })); -vi.mock('$lib/server/availability/booking', () => ({ isNewSlotBookable: h.isNewSlotBookable })); -vi.mock('$lib/server/appointment/create', () => ({ createAppointment: h.createAppointment })); -vi.mock('$lib/server/appointment/duration', async (importOriginal) => { - const original = await importOriginal(); +vi.mock('#lib/server/state.js', () => ({ getConfig: () => validConfig, getDb: () => h.db })); +vi.mock('#lib/server/availability/booking.js', () => ({ isNewSlotBookable: h.isNewSlotBookable })); +vi.mock('#lib/server/appointment/create.js', () => ({ createAppointment: h.createAppointment })); +vi.mock('#lib/server/appointment/duration.js', async (importOriginal) => { + const original = await importOriginal(); return { ...original, resolveDuration: h.resolveDuration }; }); -vi.mock('$lib/server/appointment/form.server', async (io) => { - const original = await io(); +vi.mock('#lib/server/appointment/form.server.js', async (io) => { + const original = await io(); return { ...original, parseAndValidateAppointmentForm: h.parseForm }; }); diff --git a/apps/web/src/routes/(auth)/admin/services/google/callback/+page.server.ts b/apps/web/src/routes/(auth)/admin/services/google/callback/+page.server.ts index d0351dd3..35c0f377 100644 --- a/apps/web/src/routes/(auth)/admin/services/google/callback/+page.server.ts +++ b/apps/web/src/routes/(auth)/admin/services/google/callback/+page.server.ts @@ -1,15 +1,15 @@ -import { dev } from '$app/environment'; -import { getConfig } from '$lib/server/state'; +import { dev } from '$app/env'; +import { getConfig } from '#lib/server/state.js'; import { exchangeGoogleConnect, findGoogleProvider, refreshTokenEnvVar -} from '$lib/server/providers/google-connect'; +} from '#lib/server/providers/google-connect.js'; import { STATE_COOKIE, parseOAuthState, stateCookieOptions -} from '$lib/server/providers/state-cookie'; +} from '#lib/server/providers/state-cookie.js'; import type { PageServerLoad } from './$types'; function failed(service: string | null, error: string) { diff --git a/apps/web/src/routes/(auth)/admin/services/google/callback/+page.svelte b/apps/web/src/routes/(auth)/admin/services/google/callback/+page.svelte index 3f6ab79f..6759b4ed 100644 --- a/apps/web/src/routes/(auth)/admin/services/google/callback/+page.svelte +++ b/apps/web/src/routes/(auth)/admin/services/google/callback/+page.svelte @@ -1,5 +1,5 @@ diff --git a/apps/web/src/routes/+layout.server.ts b/apps/web/src/routes/+layout.server.ts index ddccf1f4..ddad6844 100644 --- a/apps/web/src/routes/+layout.server.ts +++ b/apps/web/src/routes/+layout.server.ts @@ -1,4 +1,4 @@ -import { getConfig } from '$lib/server/state'; +import { getConfig } from '#lib/server/state.js'; import type { LayoutServerLoad } from './$types'; export const load: LayoutServerLoad = ({ cookies }) => { diff --git a/apps/web/src/routes/+layout.svelte b/apps/web/src/routes/+layout.svelte index 53d2e9d9..607a3b17 100644 --- a/apps/web/src/routes/+layout.svelte +++ b/apps/web/src/routes/+layout.svelte @@ -1,14 +1,14 @@