Skip to content

Parler à l'API ​

Toutes les requêtes de l'app passent par un seul fichier : src/api/client.ts. C'est le seul endroit où fetch est appelé.

L'adresse de l'API ​

ts
export const API_URL = process.env.EXPO_PUBLIC_API_URL ?? 'https://api.time.jostud.com/api';

Si la variable EXPO_PUBLIC_API_URL existe (dans .env.local), on l'utilise. Sinon, la production. Voir Lancer l'app.

La fonction api() ​

Tous les fichiers de src/api/ l'utilisent :

ts
const { data } = await api<{ data: SignedInUser }>('/auth/me');
await api(`/clocks/${userId}`, { method: 'POST' });
await api('/corrections', { method: 'POST', body: { ... } });

Le <...> entre chevrons dit à TypeScript quelle forme aura la réponse.

Ce qu'elle fait à chaque requête ​

ts
const response = await fetch(`${API_URL}${path}`, {
  method,
  headers: {
    accept: 'application/json',
    'x-csrf-token': csrfToken,          // si on en a un
    'content-type': 'application/json', // si on envoie un corps
  },
  credentials: 'include',               // envoie les cookies de session
  body: JSON.stringify(body),
});
  1. Elle construit l'URL complète.
  2. Elle ajoute le jeton CSRF dans l'en-tête x-csrf-token.
  3. Elle demande d'envoyer les cookies (credentials: 'include') : c'est eux qui prouvent qu'on est connecté.
  4. Elle transforme le corps en JSON.
  5. Elle lit la réponse en JSON (sauf un 204, qui n'a pas de contenu).
  6. Si le code n'est pas un succès, elle lève une ApiError.

Le renouvellement automatique de la session ​

Le jeton d'accès ne dure qu'une heure. Quand il a expiré, l'API répond 401. Au lieu d'afficher une erreur, api() renouvelle la session et rejoue la requête :

ts
export async function api<T>(path: string, options: Options = {}): Promise<T> {
  try {
    return await send<T>(path, options);
  } catch (error) {
    if (errorStatus(error) !== 401 || SESSION_PATHS.includes(path)) throw error;
  }

  try {
    await refreshSession();
  } catch (error) {
    if (errorStatus(error) === 401) onSessionExpired();
    throw error;
  }
  return send<T>(path, options);
}

Trois détails importants :

  • Pas de boucle infinie. Les routes de session (/auth/login, /auth/refresh, /auth/logout) ne déclenchent jamais de renouvellement.
  • Un seul renouvellement à la fois. Si trois requêtes reçoivent un 401 en même temps, une seule demande /auth/refresh part. Les trois attendent la même promesse (refreshing), puis rejouent chacune leur requête.
  • Session vraiment expirée. Si /auth/refresh répond lui aussi 401 (30 jours sans ouvrir l'app, ou mot de passe changé ailleurs), onSessionExpired() est appelé. La session efface tout et l'app revient à la connexion. Une simple coupure réseau, elle, ne déconnecte pas.

Le jeton CSRF ​

ts
const CSRF_KEY = 'timemanager.csrfToken';
let csrfToken: string | null = null;

Le jeton est gardé à deux endroits :

OùPourquoi
En mémoire (csrfToken)Pour l'ajouter vite à chaque requête
Dans SecureStorePour le retrouver après avoir fermé l'app
FonctionQuand
loadCsrfToken()Au démarrage, dans SessionProvider
saveCsrfToken(token)Après un login, un refresh, un changement de mot de passe
clearCsrfToken()À la déconnexion

Les erreurs ​

Quand l'API refuse une requête, api() lève une ApiError :

ts
class ApiError extends Error {
  status: number;    // 401, 403, 404, 409, 422...
  body: {
    error?: string;                               // phrase en anglais
    code?: string;                                // code stable, ex. "conflict"
    details?: Record<string, { code: string }[]>; // code par champ
    errors?: Record<string, unknown>;             // erreurs de validation
  } | null;
}

Des petites fonctions permettent aux écrans de lire l'erreur sans connaître ce format :

FonctionRenvoieExemple
errorStatus(error)Le code HTTP, ou null si ce n'est pas une ApiErrorerrorStatus(e) === 401 : mauvais mot de passe
errorCode(error)Le code stable généralerrorCode(e) === 'conflict' : il gère encore une équipe
errorDetail(error, champ)Le code stable d'un champerrorDetail(e, 'end')
fieldError(error, champ)true si ce champ a été refuséfieldError(e, 'current_password')

On traduit le code, jamais la phrase

L'API renvoie une phrase en anglais (error) et un code stable (code, details). L'app choisit son propre message traduit à partir du code. Si l'équipe backend reformule sa phrase, l'app ne casse pas.

Exemple réel, dans src/app/(tabs)/change-password.tsx :

ts
(error) =>
  fieldError(error, 'current_password')
    ? t('settings.password.wrongCurrent')
    : errorStatus(error) === 422
      ? t('settings.password.refused')
      : t('common.failed')

Les dates : src/api/dates.ts ​

L'API stocke les heures en UTC sans indication de fuseau : "2026-10-13 06:30:00". Le téléphone, lui, vit à l'heure de Paris. Il faut convertir dans les deux sens.

FonctionRôleExemple
fromApiDateTime(texte)Texte de l'API vers Date : ajoute le Z qui veut dire « UTC »"2026-10-13 06:30:00" vers 8 h 30 à Paris
toApiDateTime(date)Date vers texte de l'API, en UTC8 h 30 à Paris vers "2026-10-13 06:30:00"
toDateKey(date)La date locale AAAA-MM-JJ, pour regrouper par jour"2026-10-13"
fromDateKey(clé)L'inverse, à minuit heure locale
startOfDay(date)Minuit du même jour
addDays(date, n)n jours plus tard (ou plus tôt si négatif)
mondayOf(date)Le lundi de la semaine

Les fichiers de src/api/ convertissent toujours les dates avant de les rendre. Le reste de l'app ne manipule que des objets Date.

Un fichier par ressource ​

Exemple complet, src/api/working-times.ts :

ts
type ApiWorkingTime = { id: number; start: string; end: string | null; breaks?: ... };

function toWorkingTime(period: ApiWorkingTime, userId: number): WorkingTime {
  return {
    id: period.id,
    userId,
    start: fromApiDateTime(period.start),
    end: period.end ? fromApiDateTime(period.end) : null,
    breaks: (period.breaks ?? []).map(...),
  };
}

export async function fetchWorkingTimes(userId: number, from?: Date, to?: Date) {
  const query = from && to ? `?start=...&end=...` : '';
  const { data } = await api<{ data: ApiWorkingTime[] }>(`/workingtime/${userId}${query}`);
  return data.map((period) => toWorkingTime(period, userId));
}

Le type ApiWorkingTime décrit ce que renvoie le serveur. La fonction toWorkingTime le transforme en WorkingTime, le type de l'app. L'écran ne verra que des WorkingTime.

Tous les endpoints utilisés ​

FichierMéthode et cheminFonction
auth.tsPOST /auth/loginlogin
GET /auth/mefetchMe
POST /auth/logoutlogout
POST /auth/forgot-passwordrequestPasswordReset
GET /password-tokens/:tokenfetchPasswordLink
POST /password-tokens/:tokenusePasswordLink
client.tsPOST /auth/refresh(automatique)
working-times.tsGET /workingtime/:userId?start&endfetchWorkingTimes
GET /workingtime/:userId/:idfetchWorkingTime
POST /workingtime/:userIdcreateWorkingTime
PUT /workingtime/:idupdateWorkingTime
DELETE /workingtime/:iddeleteWorkingTime
GET /clocks/:userIdfetchLastClockOut
POST /clocks/:userIdtoggleClock
POST /clocks/:userId/breaktoggleBreak
corrections.tsGET /correctionsfetchCorrections
POST /correctionsrequestCorrection
PUT /corrections/:id/approveapproveCorrection
PUT /corrections/:id/rejectrejectCorrection
teams.tsGET /teamsfetchTeams
POST /teamscreateTeam
PUT /teams/:idupdateTeam
DELETE /teams/:iddeleteTeam
POST /teams/:id/membersaddMember
DELETE /teams/:id/members/:userIdremoveMember
GET /teams/:id/averages?start&endfetchTeamReport
users.tsGET /usersfetchUsers
POST /usersinviteUser
PUT /users/:id/rolechangeRole
DELETE /users/:iddeleteUser
POST /users/:id/invitationresendInvitation
PUT /users/:idupdateName
PUT /users/:id/preferencesupdatePreferences
PUT /users/:id/passwordchangePassword
GET /users/:id/datafetchMyData, fetchMyTeamNames
absences.tsGET /users/:id/absencesfetchAbsences
POST /users/:id/absencescreateAbsences
DELETE /absences/:iddeleteAbsence
weeks.tsGET /users/:id/week-validationsfetchWeekValidations
POST /users/:id/week-validationsvalidateWeek
PUT /week-validations/:id/reopenreopenWeek
changes.tsGET /users/:id/changesfetchChanges
notifications.tsGET /notificationsfetchNotifications
PUT /notifications/readmarkNotificationsRead
email-change.tsGET /users/:id/email-changefetchPendingEmailChange
POST /users/:id/email-changerequestEmailChange
POST /email-change/:tokenconfirmEmailChange

Tous les chemins sont relatifs à API_URL (https://api.time.jostud.com/api). La liste complète des routes de l'API, avec leurs paramètres, est dans le Swagger.

Les nouvelles options de l'API

Depuis les pull requests #162 à #166, POST /clocks/:id et /break acceptent un corps facultatif (action, client_id, time), POST /corrections un client_id, et POST /auth/login un "client": "mobile" qui donne des jetons Bearer au lieu des cookies. L'app ne les utilise pas encore : ils serviront au mode hors ligne.