diff --git a/src/web/routes/dating/_lib/api.ts b/src/web/routes/dating/_lib/api.ts index 100aec6..6049cd4 100644 --- a/src/web/routes/dating/_lib/api.ts +++ b/src/web/routes/dating/_lib/api.ts @@ -6,9 +6,18 @@ * `dating_session` HTTP-only cookie — every request goes out with * `credentials: 'include'`. * - * Errors are normalised into `DatingApiError`. Network / abort - * failures bubble out as the same class with `code: 'network_error'` - * so call sites only need one catch branch. + * Response shape conventions, derived from `servers/dating/routes.mjs`: + * + * - **Errors** are always `{ ok: false, error: { code, message, details? } }` + * with the appropriate HTTP status. The client maps them onto + * `DatingApiError` so call sites only need a single catch branch + * keyed on `error.code`. + * - **Successes** are not wrapped consistently — some endpoints return + * the raw payload (`{ profile }`, `{ matches }`, `{ message }`, + * `{ like, match }`), others piggy-back an `ok: true` flag onto the + * response (auth endpoints). Each method below knows exactly which + * field it needs to pluck so the typed return matches what call + * sites actually use, no envelope detail leaks out of this module. */ import { @@ -33,7 +42,7 @@ import { type DatingReportsResponse, type DatingResetInput, type DatingSessionResponse, - type DatingApiErrorPayload + type DatingUser } from './types.ts'; export const DATING_API_DEFAULT_BASE = 'http://127.0.0.1:8787'; @@ -84,6 +93,25 @@ export class DatingApiError extends Error { } } +interface ServerErrorEnvelope { + readonly ok: false; + readonly error: { + readonly code?: string; + readonly message?: string; + readonly details?: Readonly>; + }; +} + +function isErrorEnvelope(value: unknown): value is ServerErrorEnvelope { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as { ok?: unknown; error?: unknown }; + return ( + candidate.ok === false && + typeof candidate.error === 'object' && + candidate.error !== null + ); +} + export interface DatingApiClient { // auth register(input: DatingRegisterInput): Promise; @@ -135,6 +163,14 @@ export function createDatingApiClient( const base = options.base ?? DATING_API_DEFAULT_BASE; const doFetch = options.fetch ?? globalThis.fetch.bind(globalThis); + function safeJson(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } + } + async function request( path: string, init: RequestInit = {}, @@ -152,25 +188,32 @@ export function createDatingApiClient( throw DatingApiError.network(`Request to ${path} failed`, cause); } + // The dating server returns JSON on success AND on error. Read + // the body once and decide based on `response.ok` — that's the + // only place the structured `error.code` is available. + const text = await response.text(); + const payload: unknown = text === '' ? null : safeJson(text); + if (!response.ok) { - let payload: Partial = {}; - try { - payload = (await response.json()) as DatingApiErrorPayload; - } catch { - // non-JSON body; fall back to status text below + if (isErrorEnvelope(payload)) { + throw new DatingApiError( + payload.error.code ?? 'http_error', + payload.error.message ?? response.statusText ?? 'Request failed', + response.status, + payload.error.details ?? {} + ); } throw new DatingApiError( - payload.code ?? 'http_error', - payload.message ?? response.statusText ?? 'Request failed', - response.status, - payload.details ?? {} + 'http_error', + response.statusText || `Request failed with ${response.status}`, + response.status ); } if (expect === 'void' || response.status === 204) { return undefined as T; } - return (await response.json()) as T; + return payload as T; } function jsonInit(method: string, body?: unknown): RequestInit { @@ -185,8 +228,12 @@ export function createDatingApiClient( if (filters === undefined) return ''; const params = new URLSearchParams(); if (filters.intent !== undefined) params.set('intent', filters.intent); - if (filters.minAge !== undefined) params.set('minAge', String(filters.minAge)); - if (filters.maxAge !== undefined) params.set('maxAge', String(filters.maxAge)); + // The server query parameter names are `ageMin` / `ageMax` (see + // `servers/dating/routes.mjs:discoverGet`). The TypeScript + // fields stay `minAge` / `maxAge` because that's natural English + // reading order; the translation lives here. + if (filters.minAge !== undefined) params.set('ageMin', String(filters.minAge)); + if (filters.maxAge !== undefined) params.set('ageMax', String(filters.maxAge)); if (filters.q !== undefined && filters.q !== '') params.set('q', filters.q); if (filters.cursor !== undefined && filters.cursor !== '') params.set('cursor', filters.cursor); @@ -194,100 +241,197 @@ export function createDatingApiClient( return query === '' ? '' : `?${query}`; } - return { - register(input) { - return request('/api/auth/register', jsonInit('POST', input)); + const client: DatingApiClient = { + async register(input) { + const payload = await request<{ user: DatingUser }>( + '/api/auth/register', + jsonInit('POST', input) + ); + // Auth endpoints return only the `user`. The profile is + // loaded separately on the next session probe. + return { user: payload.user, profile: null }; }, - login(input) { - return request('/api/auth/login', jsonInit('POST', input)); + + async login(input) { + // Server normalises identity / email under the hood. Send + // both names so older deployments that only accept + // `identity` keep working. + const payload = await request<{ user: DatingUser }>( + '/api/auth/login', + jsonInit('POST', { identity: input.email, ...input }) + ); + return { user: payload.user, profile: null }; }, + logout() { return request('/api/auth/logout', jsonInit('POST'), 'void'); }, + resetRequest(input) { return request('/api/auth/reset', jsonInit('POST', input), 'void'); }, + mfaVerify(input) { return request( '/api/auth/mfa/verify', jsonInit('POST', input) ); }, - session() { - return request('/api/session'); + + async session() { + // Session endpoint returns either `{ authenticated: false }` + // or `{ authenticated: true, user }`. The profile lives at + // `/api/profile/me`; we fetch it inline so callers see a + // fully populated `DatingSessionResponse`. + const payload = await request< + { authenticated: false } | { authenticated: true; user: DatingUser } + >('/api/session'); + if (payload.authenticated === false) return { authenticated: false }; + + let profile: DatingProfile | null = null; + try { + profile = await client.getMyProfile(); + } catch (error) { + // `profile_required` is the server's "not yet onboarded" + // signal; everything else is a real transport problem + // the caller should see, but for a session-restore + // probe we degrade silently and let the subsequent + // page-level guard surface it. + if ( + !(error instanceof DatingApiError) || + (error.code !== 'profile_required' && error.code !== 'http_error') + ) { + throw error; + } + } + return { authenticated: true, user: payload.user, profile }; }, - getMyProfile() { - return request('/api/profile/me'); + async getMyProfile() { + const payload = await request<{ profile: DatingProfile | null }>('/api/profile/me'); + return payload.profile; }, - updateMyProfile(input) { - return request('/api/profile/me', jsonInit('PUT', input)); + + async updateMyProfile(input) { + const payload = await request<{ profile: DatingProfile }>( + '/api/profile/me', + jsonInit('PUT', input) + ); + return payload.profile; }, - uploadPhoto(file) { + + async uploadPhoto(file) { const form = new FormData(); - form.append('file', file); - return request('/api/profile/photos', { method: 'POST', body: form }); + // Server reads `form.getAll('photos')` — see + // `servers/dating/routes.mjs:profilePhotosPost`. Keep the + // field name aligned even when the client only sends one + // file at a time. + form.append('photos', file); + const payload = await request<{ profile: DatingProfile }>( + '/api/profile/photos', + { method: 'POST', body: form } + ); + return payload.profile; }, - deletePhoto(filename) { - return request( + + async deletePhoto(filename) { + const payload = await request<{ profile: DatingProfile }>( `/api/profile/photos/${encodeURIComponent(filename)}`, { method: 'DELETE' } ); + return payload.profile; }, - reorderPhotos(order) { - return request( + + async reorderPhotos(order) { + const payload = await request<{ profile: DatingProfile }>( '/api/profile/photos/order', - jsonInit('PATCH', { order: [...order] }) + jsonInit('PATCH', { photos: [...order] }) ); + return payload.profile; }, - setPrimaryPhoto(filename) { - return request( + + async setPrimaryPhoto(filename) { + const payload = await request<{ profile: DatingProfile }>( '/api/profile/photos/main', jsonInit('PATCH', { filename }) ); + return payload.profile; }, - discover(filters) { - return request(`/api/discover${discoverQuery(filters)}`); + async discover(filters) { + const payload = await request<{ + profiles: readonly DatingProfile[]; + totalItems?: number; + }>(`/api/discover${discoverQuery(filters)}`); + // `totalItems` is exposed by the server but the public + // surface keeps the shape pagination-ready. The cursor + // stays `null` until the server lands real cursoring. + return { profiles: payload.profiles, nextCursor: null }; }, - like(input) { - return request('/api/likes', jsonInit('POST', input)); + + async like(input) { + const payload = await request<{ + like: { state: 'like' | 'pass' }; + match: DatingMatch | null; + }>('/api/likes', jsonInit('POST', input)); + return { liked: payload.like.state === 'like', match: payload.match }; }, - matches() { - return request('/api/matches'); + + async matches() { + const payload = await request<{ matches: readonly DatingMatch[] }>('/api/matches'); + return payload.matches; }, - messages(matchId, cursor) { + + async messages(matchId, cursor) { const path = `/api/matches/${encodeURIComponent(matchId)}/messages${ cursor === undefined || cursor === '' ? '' : `?cursor=${encodeURIComponent(cursor)}` }`; - return request(path); + const payload = await request<{ + messages: readonly DatingMessage[]; + cursor?: string | null; + }>(path); + return { messages: payload.messages, cursor: payload.cursor ?? null }; }, - sendMessage(matchId, input) { - return request( + + async sendMessage(matchId, input) { + const payload = await request<{ message: DatingMessage }>( `/api/matches/${encodeURIComponent(matchId)}/messages`, jsonInit('POST', input) ); + return payload.message; }, block(input) { return request('/api/safety/block', jsonInit('POST', input), 'void'); }, - report(input) { - return request('/api/safety/report', jsonInit('POST', input)); + + async report(input) { + const payload = await request<{ report: DatingReport }>( + '/api/safety/report', + jsonInit('POST', input) + ); + return payload.report; }, - adminReports() { - return request('/api/admin/reports'); + async adminReports() { + const payload = await request<{ reports: readonly DatingReport[] }>( + '/api/admin/reports' + ); + return { reports: payload.reports }; }, - adminResolveReport(reportId, input) { - return request( + + async adminResolveReport(reportId, input) { + const payload = await request<{ report: DatingReport }>( `/api/admin/reports/${encodeURIComponent(reportId)}/resolve`, jsonInit('POST', input) ); + return payload.report; }, devtoolsSnapshot() { return request>>('/api/devtools/snapshot'); } }; + + return client; } diff --git a/src/web/routes/dating/login/+page.svelte b/src/web/routes/dating/login/+page.svelte index d9e1686..bed2bec 100644 --- a/src/web/routes/dating/login/+page.svelte +++ b/src/web/routes/dating/login/+page.svelte @@ -50,15 +50,17 @@ submitting = true; error = null; try { - const response = await nexo.api.login({ email: email.trim(), password }); - nexo.setSession({ - authenticated: true, - user: response.user, - profile: response.profile, - token: response.token - }); + await nexo.api.login({ email: email.trim(), password }); + // Re-issue the session probe so the cell sees the freshly + // authenticated user AND their profile (which the + // `/api/auth/login` response intentionally does not + // include — it's loaded by `getMyProfile()` inside + // `api.session()`). + const session = await nexo.refreshSession(); const next = - response.profile === null || !response.profile.completed + session?.authenticated !== true || + session.profile === null || + !session.profile.completed ? '/dating/onboarding' : '/dating/discover'; await goto(next, { replaceState: true }); diff --git a/src/web/routes/dating/register/+page.svelte b/src/web/routes/dating/register/+page.svelte index 78497a9..1cecfed 100644 --- a/src/web/routes/dating/register/+page.svelte +++ b/src/web/routes/dating/register/+page.svelte @@ -78,19 +78,18 @@ submitting = true; topError = null; try { - const response = await nexo.api.register({ + await nexo.api.register({ displayName: displayName.trim(), email: email.trim(), password, passwordConfirm, adultConfirmed }); - nexo.setSession({ - authenticated: true, - user: response.user, - profile: response.profile, - token: response.token - }); + // Refresh the session cell so the layout sees the freshly + // created user (the register endpoint sets the session + // cookie but doesn't send the profile, which doesn't exist + // yet anyway). + await nexo.refreshSession(); await goto('/dating/onboarding', { replaceState: true }); } catch (caught) { if (caught instanceof DatingApiError) {