@ -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 Dating ApiErrorPayload
type Dating User
} 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 < Record < string , unknown > > ;
} ;
}
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 < DatingLoginResponse > ;
@ -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 < T > (
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 < DatingApiErrorPayload > = { } ;
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 < DatingLoginResponse > ( '/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 < DatingLoginResponse > ( '/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 < void > ( '/api/auth/logout' , jsonInit ( 'POST' ) , 'void' ) ;
} ,
resetRequest ( input ) {
return request < void > ( '/api/auth/reset' , jsonInit ( 'POST' , input ) , 'void' ) ;
} ,
mfaVerify ( input ) {
return request < DatingSessionResponse > (
'/api/auth/mfa/verify' ,
jsonInit ( 'POST' , input )
) ;
} ,
session() {
return request < DatingSessionResponse > ( '/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 < DatingProfile | null > ( '/api/profile/me' ) ;
async getMyProfile() {
const payload = await request < { profile : DatingProfile | null } > ( '/api/profile/me' ) ;
return payload . profile ;
} ,
updateMyProfile ( input ) {
return request < DatingProfile > ( '/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 < DatingProfile > ( '/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 < DatingProfile > (
async deletePhoto ( filename ) {
const payload = await request < { profile : DatingProfile } > (
` /api/profile/photos/ ${ encodeURIComponent ( filename ) } ` ,
{ method : 'DELETE' }
) ;
return payload . profile ;
} ,
reorderPhotos ( order ) {
return request < DatingProfile > (
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 < DatingProfile > (
async setPrimaryPhoto ( filename ) {
const payload = await request < { profile : DatingProfile } > (
'/api/profile/photos/main' ,
jsonInit ( 'PATCH' , { filename } )
) ;
return payload . profile ;
} ,
discover ( filters ) {
return request < DatingDiscoverResponse > ( ` /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 < DatingLikeResponse > ( '/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 < readonly DatingMatch [ ] > ( '/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 < DatingMessagesResponse > ( 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 < DatingMessage > (
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 < void > ( '/api/safety/block' , jsonInit ( 'POST' , input ) , 'void' ) ;
} ,
report ( input ) {
return request < DatingReport > ( '/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 < DatingReportsResponse > ( '/api/admin/reports' ) ;
async adminReports() {
const payload = await request < { reports : readonly DatingReport [ ] } > (
'/api/admin/reports'
) ;
return { reports : payload.reports } ;
} ,
adminResolveReport ( reportId , input ) {
return request < DatingReport > (
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 < Readonly < Record < string , unknown > >> ( '/api/devtools/snapshot' ) ;
}
} ;
return client ;
}