import { getToken, setToken, clearToken } from "../lib/session"; /* * API-client. Basis-URL komt uit VITE_API_URL: * - leeg (dev in de browser): zelfde origin, Vite proxyt /api naar :3000 * - native app (Capacitor): absolute URL, bijv. http://10.0.2.2:3000 * Authenticatie: Authorization: Bearer (zie lib/session.js). * Een 401 betekent dat de sessie weg is → token wissen → de app toont het inlogscherm. */ const BASE = (import.meta.env.VITE_API_URL ?? "").replace(/\/$/, ""); export class ApiError extends Error { /* * `code` is de machineleesbare variant van dezelfde fout, en bestaat omdat één statuscode soms * twee dingen betekent: 409 is zowel "dit e-mailadres is al bezet" als "op deze aankoop staat al * een account", en het verschil bepaalt of de app een betaald bonnetje weggooit of bewaart. */ constructor(message, status, code = null) { super(message); this.status = status; this.code = code; } } async function request(path, { method = "GET", body, signal, auth = true } = {}) { const token = auth ? getToken() : null; let res; try { res = await fetch(`${BASE}/api${path}`, { method, signal, headers: { ...(token && { Authorization: `Bearer ${token}` }), ...(body !== undefined && { "Content-Type": "application/json" }), }, body: body === undefined ? undefined : JSON.stringify(body), }); } catch (e) { if (e.name === "AbortError") throw e; throw new ApiError("geen verbinding met de server", 0); } const data = await res.json().catch(() => ({})); if (res.status === 401 && auth) clearToken(); if (!res.ok) throw new ApiError(data.error ?? `HTTP ${res.status}`, res.status, data.code ?? null); return data; } /* * De foto bij een brief, opgehaald bij de mediaserver: die zit níet onder /api maar op zijn eigen * sub-URL /media/, en geeft de bytes alleen aan de afzender en de ontvanger. Omdat een * geen Authorization-header kan meesturen, halen we hem hier op met fetch; de aanroeper maakt er een * blob-URL van (zie hooks/usePhoto.js). Zo staat er nooit een toegangsbewijs in een URL. */ export async function fetchPhoto(id, { signal } = {}) { const token = getToken(); let res; try { res = await fetch(`${BASE}/media/${id}`, { signal, headers: token ? { Authorization: `Bearer ${token}` } : {} }); } catch (e) { if (e.name === "AbortError") throw e; throw new ApiError("geen verbinding met de server", 0); } if (res.status === 401) clearToken(); if (!res.ok) throw new ApiError("de foto kon niet geladen worden", res.status); return res.blob(); } /* ---------- Revivers: JSON → de vormen die de componenten verwachten ---------- */ /* 'YYYY-MM-DD' als lokale datum (niet UTC), ISO-tijdstempels gewoon parsen */ const parseDate = (s) => { if (s == null) return null; const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s); return m ? new Date(+m[1], +m[2] - 1, +m[3]) : new Date(s); }; const reviveUser = (u) => ({ ...u, birth: parseDate(u.birth), lastActive: parseDate(u.lastActive), ...(u.consentAt !== undefined && { consentAt: parseDate(u.consentAt) }), // alleen bij mijn eigen profiel filter: { ...u.filter, minBirth: parseDate(u.filter.minBirth), maxBirth: parseDate(u.filter.maxBirth) }, }); const reviveLetter = (l) => ({ ...l, date: parseDate(l.date), readAt: parseDate(l.readAt) }); /* Alleen GET /me: hetzelfde profiel plus de openstaande waarschuwing van de beheerder (of null) */ const reviveMe = (u) => ({ ...reviveUser(u), warning: u.warning ? { ...u.warning, at: parseDate(u.warning.at) } : null, }); /* Login/registratie: token bewaren, gebruiker teruggeven */ const startSession = ({ token, user }) => { setToken(token); return reviveUser(user); }; /* ---------- Endpoints ---------- */ export const api = { /* * Kost een account op dit moment geld? → { free, productId }. `free: true` zolang er gratis * plekken zijn; daarna is `productId` het artikel dat in de store gekocht moet worden. */ signupStatus: (o) => request("/auth/signup", { ...o, auth: false }), /* * Het registratieformulier nakijken zonder iets aan te maken — de stap vóór betalen, zodat * niemand geld kwijt is aan een e-mailadres dat al bezet blijkt. Gooit ApiError bij een fout. */ checkAccount: (account) => request("/auth/check", { method: "POST", body: account, auth: false }), /* * account: { email, password, name, gender, trans, birthdate: 'YYYY-MM-DD', consent: true } → me. * Was de gratis bak op, dan hoort er `purchase: { provider, token, productId }` bij (zie * src/lib/billing.js); de server trekt die bon na bij de store. 402 = er moet (alsnog) betaald * worden, 409 = op deze bon staat al een account. */ register: (account) => request("/auth/register", { method: "POST", body: account, auth: false }).then(startSession), login: (email, password) => request("/auth/login", { method: "POST", body: { email, password }, auth: false }).then(startSession), /* wachtwoord vergeten: de server mailt een herstel-link — het antwoord zegt niets over of het adres bestaat */ forgot: (email) => request("/auth/forgot", { method: "POST", body: { email }, auth: false }), /* sessie op de server weggooien; lokaal altijd wissen, ook als de server niet bereikbaar is */ logout: () => request("/auth/logout", { method: "POST" }).catch(() => {}).finally(clearToken), /* mijn profiel, met `warning` = de openstaande waarschuwing van de beheerder (of null) */ getMe: (o) => request("/me", o).then(reviveMe), /* waarschuwing gelezen; daarna kan de app verder */ ackWarning: (id) => request(`/warnings/${id}/ack`, { method: "POST" }), /* profile: { text, hidden, relationship, genders, seekRelatie, seekTrans, ageMin, ageMax, location, radiusKm } */ updateMe: (profile) => request("/me", { method: "PUT", body: profile }).then(reviveUser), /* AVG: bestaand account geeft alsnog toestemming voor de gevoelige gegevens → me (met consentAt) */ giveConsent: () => request("/me/consent", { method: "POST", body: { consent: true } }).then(reviveUser), /* AVG (rectificatie): { currentPassword, name?, email?, birthdate?, gender?, trans?, newPassword? } → me */ updateAccount: (changes) => request("/account", { method: "PUT", body: changes }).then(reviveUser), /* * Overal elders uitloggen; dit toestel blijft ingelogd → { loggedOut: }. Met het huidige * wachtwoord ter bevestiging, want anders zou een gestolen token genoeg zijn om jou eruit te zetten. */ logoutOthers: (currentPassword) => request("/me/logout-others", { method: "POST", body: { currentPassword } }), /* AVG: alles wat de server van mij bewaart, als één object (ongewijzigd doorgegeven — het is om te bewaren, niet om te tonen) */ exportMe: () => request("/me/export"), /* * AVG: account definitief wissen (met wachtwoord ter bevestiging); daarna is de sessie weg → inlogscherm. * `farewell` is de brief die je bij je vertrek achterlaat voor de beheerder: optioneel, en hij wordt * bewaard zonder wie hem schreef — er is dus geen antwoord op mogelijk (zie `farewells` in db/schema.sql). */ deleteMe: (password, farewell = "") => request("/me", { method: "DELETE", body: { password, ...(farewell.trim() && { farewell: farewell.trim() }) } }).then(() => clearToken()), /* `truncated`: er waren meer passende profielen dan de server er in één keer geeft (SEARCH_RESULTS_MAX) */ search: (q, o) => request(`/search?q=${encodeURIComponent(q)}`, o).then((r) => ({ truncated: r.truncated ?? false, results: r.results.map(reviveUser), })), listThreads: (o) => request("/threads", o).then((ts) => ts.map((t) => ({ ...t, user: reviveUser(t.user), last: reviveLetter(t.last) }))), /* één pagina brieven (oudste eerst); { before: letterId } voor oudere brieven; hasMore erbij */ getThread: (userId, { before } = {}, o) => request(`/threads/${userId}${before ? `?before=${before}` : ""}`, o) .then((t) => ({ ...t, user: reviveUser(t.user), letters: t.letters.map(reviveLetter) })), /* * Zoeken door één correspondentie: de passende brieven, nieuwste eerst. Elke brief heeft `marked` * — dezelfde tekst met de gevonden woorden gemarkeerd (zie src/lib/highlight.jsx). `truncated`: * er waren er meer dan de server in één keer geeft (THREAD_HITS_MAX). */ searchThread: (userId, q, o) => request(`/threads/${userId}/search?q=${encodeURIComponent(q)}`, o).then((r) => ({ truncated: r.truncated ?? false, letters: r.letters.map(reviveLetter), })), markThreadRead: (userId) => request(`/threads/${userId}/read`, { method: "POST" }), /* correspondent als favoriet markeren (of niet meer); favorieten staan bovenaan in het postvak */ setFavorite: (userId, favorite) => request(`/threads/${userId}/favorite`, { method: "POST", body: { favorite } }), /* blokkeren (stil; verbergt de correspondentie, niemand kan de ander nog schrijven) of deblokkeren */ setBlock: (userId, block) => request(`/threads/${userId}/block`, { method: "POST", body: { block } }), /* wie ik geblokkeerd heb: [{ user, since }] */ listBlocked: (o) => request("/blocks", o).then((bs) => bs.map((b) => ({ user: reviveUser(b.user), since: parseDate(b.since) }))), /* * misbruik melden bij de beheerder: { about, letterId?, reason, details?, block? } → { id, blocked }. * Mag ook zonder correspondentie (een profiel uit Zoeken); `block` werkt alleen als er post ligt. */ report: (report) => request("/reports", { method: "POST", body: report }), /* letter: { to, text, photo }, waarbij photo = { data (base64), mime, width, height, alt } of null */ sendLetter: ({ to, text, photo }) => request("/letters", { method: "POST", body: { to, text, photo: photo && { data: photo.data, mime: photo.mime, width: photo.width, height: photo.height, alt: photo.alt.trim() } }, }).then(reviveLetter), };