Een dating-app op basis van brieven, niet van gezichten!
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 <sessietoken> (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/<id>, en geeft de bytes alleen aan de afzender en de ontvanger. Omdat een <img src>
* 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: <aantal> }. 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),
};