Een dating-app op basis van brieven, niet van gezichten!

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

9.8 kB Ruw bestand
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),
};