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
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 :
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
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),
});- Elle construit l'URL complète.
- Elle ajoute le jeton CSRF dans l'en-tête
x-csrf-token. - Elle demande d'envoyer les cookies (
credentials: 'include') : c'est eux qui prouvent qu'on est connecté. - Elle transforme le corps en JSON.
- Elle lit la réponse en JSON (sauf un
204, qui n'a pas de contenu). - 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 :
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
401en même temps, une seule demande/auth/refreshpart. Les trois attendent la même promesse (refreshing), puis rejouent chacune leur requête. - Session vraiment expirée. Si
/auth/refreshrépond lui aussi401(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
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 SecureStore | Pour le retrouver après avoir fermé l'app |
| Fonction | Quand |
|---|---|
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 :
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 :
| Fonction | Renvoie | Exemple |
|---|---|---|
errorStatus(error) | Le code HTTP, ou null si ce n'est pas une ApiError | errorStatus(e) === 401 : mauvais mot de passe |
errorCode(error) | Le code stable général | errorCode(e) === 'conflict' : il gère encore une équipe |
errorDetail(error, champ) | Le code stable d'un champ | errorDetail(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 :
(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.
| Fonction | Rôle | Exemple |
|---|---|---|
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 UTC | 8 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 :
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
| Fichier | Méthode et chemin | Fonction |
|---|---|---|
auth.ts | POST /auth/login | login |
GET /auth/me | fetchMe | |
POST /auth/logout | logout | |
POST /auth/forgot-password | requestPasswordReset | |
GET /password-tokens/:token | fetchPasswordLink | |
POST /password-tokens/:token | usePasswordLink | |
client.ts | POST /auth/refresh | (automatique) |
working-times.ts | GET /workingtime/:userId?start&end | fetchWorkingTimes |
GET /workingtime/:userId/:id | fetchWorkingTime | |
POST /workingtime/:userId | createWorkingTime | |
PUT /workingtime/:id | updateWorkingTime | |
DELETE /workingtime/:id | deleteWorkingTime | |
GET /clocks/:userId | fetchLastClockOut | |
POST /clocks/:userId | toggleClock | |
POST /clocks/:userId/break | toggleBreak | |
corrections.ts | GET /corrections | fetchCorrections |
POST /corrections | requestCorrection | |
PUT /corrections/:id/approve | approveCorrection | |
PUT /corrections/:id/reject | rejectCorrection | |
teams.ts | GET /teams | fetchTeams |
POST /teams | createTeam | |
PUT /teams/:id | updateTeam | |
DELETE /teams/:id | deleteTeam | |
POST /teams/:id/members | addMember | |
DELETE /teams/:id/members/:userId | removeMember | |
GET /teams/:id/averages?start&end | fetchTeamReport | |
users.ts | GET /users | fetchUsers |
POST /users | inviteUser | |
PUT /users/:id/role | changeRole | |
DELETE /users/:id | deleteUser | |
POST /users/:id/invitation | resendInvitation | |
PUT /users/:id | updateName | |
PUT /users/:id/preferences | updatePreferences | |
PUT /users/:id/password | changePassword | |
GET /users/:id/data | fetchMyData, fetchMyTeamNames | |
absences.ts | GET /users/:id/absences | fetchAbsences |
POST /users/:id/absences | createAbsences | |
DELETE /absences/:id | deleteAbsence | |
weeks.ts | GET /users/:id/week-validations | fetchWeekValidations |
POST /users/:id/week-validations | validateWeek | |
PUT /week-validations/:id/reopen | reopenWeek | |
changes.ts | GET /users/:id/changes | fetchChanges |
notifications.ts | GET /notifications | fetchNotifications |
PUT /notifications/read | markNotificationsRead | |
email-change.ts | GET /users/:id/email-change | fetchPendingEmailChange |
POST /users/:id/email-change | requestEmailChange | |
POST /email-change/:token | confirmEmailChange |
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.