Major changes across backend, frontend, infrastructure: - Provider system with model selection (Google, DeepL, OpenAI, Ollama, Google Cloud) - Admin panel: user management, pricing, settings - Glossary system with CSV import/export - Subscription and tier quota management - Security hardening (rate limiting, API key auth, path traversal fixes) - Docker compose for dev, prod, and IONOS deployment - Alembic migrations for new tables - Frontend: dashboard, pricing page, landing page, i18n (en/fr) - Test suite and verification scripts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
443 lines
14 KiB
Markdown
443 lines
14 KiB
Markdown
# Story 4.1: Setup TanStack Query & API Client
|
|
|
|
Status: done
|
|
|
|
## Story
|
|
|
|
En tant que **Développeur**,
|
|
Je veux **configurer TanStack Query avec un client API typé**,
|
|
de sorte que **le frontend puisse récupérer les données du backend de manière cohérente**.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **API Client centralisé**: `apiClient` gère l'URL de base depuis les variables d'environnement (`NEXT_PUBLIC_API_URL`).
|
|
2. **Authentification automatique**: `apiClient` ajoute automatiquement le header `Authorization: Bearer {token}` pour JWT ou `X-API-Key: {key}` selon le contexte.
|
|
3. **Parsing d'erreurs**: `apiClient` parse les réponses d'erreur au format `{error, message, details?}`.
|
|
4. **QueryProvider**: Le provider TanStack Query est configuré et wrappe l'app dans `layout.tsx`.
|
|
5. **State local UI**: React `useState` est utilisé pour l'état UI local (pas de Zustand pour ce cas).
|
|
6. **Migration**: La logique apiClient existante dans `frontend/src/app/` est migrée vers le nouveau module.
|
|
|
|
## Tasks / Subtasks
|
|
|
|
- [x] **Task 1: Installer TanStack Query** (AC: #4)
|
|
- [x] 1.1 `npm install @tanstack/react-query` dans `frontend/`
|
|
- [x] 1.2 Vérifier la version installée dans `package.json`
|
|
|
|
- [x] **Task 2: Créer le QueryProvider** (AC: #4)
|
|
- [x] 2.1 Créer `src/providers/QueryProvider.tsx`
|
|
- [x] 2.2 Configurer QueryClient avec options par défaut (staleTime, retry)
|
|
- [x] 2.3 Exporter le composant provider
|
|
|
|
- [x] **Task 3: Créer le client API centralisé** (AC: #1, #2, #3)
|
|
- [x] 3.1 Créer `src/lib/apiClient.ts`
|
|
- [x] 3.2 Implémenter la configuration de base URL depuis `NEXT_PUBLIC_API_URL`
|
|
- [x] 3.3 Implémenter l'intercepteur d'authentification (JWT token depuis cookies/localStorage)
|
|
- [x] 3.4 Implémenter le parsing des erreurs structurées
|
|
- [x] 3.5 Exporter les méthodes: `get`, `post`, `patch`, `delete`
|
|
|
|
- [x] **Task 4: Créer les types API** (AC: #3)
|
|
- [x] 4.1 Créer `src/lib/types.ts` avec `ApiError`, `ApiResponse`, `ApiSuccessResponse`
|
|
- [x] 4.2 Documenter les formats selon l'architecture
|
|
|
|
- [x] **Task 5: Intégrer QueryProvider dans layout.tsx** (AC: #4)
|
|
- [x] 5.1 Modifier `src/app/layout.tsx` pour wrapper avec QueryProvider
|
|
- [x] 5.2 S'assurer que le provider est côté client ('use client' si nécessaire)
|
|
|
|
- [x] **Task 6: Créer .env.local.example** (AC: #1)
|
|
- [x] 6.1 Documenter `NEXT_PUBLIC_API_URL=http://localhost:8000`
|
|
|
|
- [x] **Task 7: Tests** (AC: Tous)
|
|
- [x] 7.1 Test unitaire pour apiClient (construction URL, headers)
|
|
- [x] 7.2 Test unitaire pour le parsing d'erreurs
|
|
|
|
## Dev Notes
|
|
|
|
### 🏗️ Architecture - Stack Frontend
|
|
|
|
**Technologies:**
|
|
- **Next.js**: 16.0.6 (App Router)
|
|
- **React**: 19.2.0
|
|
- **State Management (Server)**: TanStack Query v5 (à installer)
|
|
- **State Management (Local)**: React useState (existant, suffisant)
|
|
- **HTTP**: fetch natif (pas d'axios nécessaire)
|
|
|
|
**⚠️ Règle Critique App Router:**
|
|
```
|
|
🚨 FICHIERS SPÉCIAUX: page.tsx, layout.tsx, route.ts → TOUJOURS minuscules
|
|
→ JAMAIS Page.tsx, Layout.tsx (le framework retourne 404)
|
|
```
|
|
|
|
### 📁 Structure à Créer
|
|
|
|
```
|
|
frontend/src/
|
|
├── lib/ # ⭐ Utilitaires GLOBAUX
|
|
│ ├── apiClient.ts # CRÉÉ - Client HTTP centralisé
|
|
│ └── types.ts # CRÉÉ - Types API globaux
|
|
├── providers/
|
|
│ └── QueryProvider.tsx # CRÉÉ - TanStack Query setup
|
|
└── app/
|
|
└── layout.tsx # MODIFIÉ - Ajout QueryProvider
|
|
```
|
|
|
|
### 🔧 Implémentation apiClient.ts
|
|
|
|
```typescript
|
|
// src/lib/apiClient.ts
|
|
|
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';
|
|
|
|
interface ApiError {
|
|
error: string;
|
|
message: string;
|
|
details?: Record<string, unknown>;
|
|
}
|
|
|
|
interface ApiSuccessResponse<T> {
|
|
data: T;
|
|
meta?: Record<string, unknown>;
|
|
}
|
|
|
|
type ApiResponse<T> = ApiSuccessResponse<T>;
|
|
|
|
class ApiClient {
|
|
private baseUrl: string;
|
|
|
|
constructor(baseUrl: string) {
|
|
this.baseUrl = baseUrl;
|
|
}
|
|
|
|
private getAuthToken(): string | null {
|
|
// JWT depuis localStorage ou httpOnly cookie
|
|
if (typeof window !== 'undefined') {
|
|
return localStorage.getItem('token');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async request<T>(
|
|
endpoint: string,
|
|
options: RequestInit = {}
|
|
): Promise<ApiResponse<T>> {
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
const token = this.getAuthToken();
|
|
|
|
const headers: HeadersInit = {
|
|
'Content-Type': 'application/json',
|
|
...options.headers,
|
|
};
|
|
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
...options,
|
|
headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error: ApiError = await response.json();
|
|
throw new ApiClientError(error.message, error.error, error.details, response.status);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
async get<T>(endpoint: string): Promise<ApiResponse<T>> {
|
|
return this.request<T>(endpoint, { method: 'GET' });
|
|
}
|
|
|
|
async post<T>(endpoint: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'POST',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
async patch<T>(endpoint: string, body?: unknown): Promise<ApiResponse<T>> {
|
|
return this.request<T>(endpoint, {
|
|
method: 'PATCH',
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
}
|
|
|
|
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
|
|
return this.request<T>(endpoint, { method: 'DELETE' });
|
|
}
|
|
|
|
async upload<T>(endpoint: string, formData: FormData): Promise<ApiResponse<T>> {
|
|
const url = `${this.baseUrl}${endpoint}`;
|
|
const token = this.getAuthToken();
|
|
|
|
const headers: HeadersInit = {};
|
|
if (token) {
|
|
headers['Authorization'] = `Bearer ${token}`;
|
|
}
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers,
|
|
body: formData,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error: ApiError = await response.json();
|
|
throw new ApiClientError(error.message, error.error, error.details, response.status);
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
}
|
|
|
|
export class ApiClientError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public code: string,
|
|
public details?: Record<string, unknown>,
|
|
public status: number
|
|
) {
|
|
super(message);
|
|
this.name = 'ApiClientError';
|
|
}
|
|
}
|
|
|
|
export const apiClient = new ApiClient(API_BASE_URL);
|
|
```
|
|
|
|
### 🔧 Implémentation QueryProvider.tsx
|
|
|
|
```typescript
|
|
// src/providers/QueryProvider.tsx
|
|
'use client';
|
|
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { useState, type ReactNode } from 'react';
|
|
|
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
|
const [queryClient] = useState(
|
|
() =>
|
|
new QueryClient({
|
|
defaultOptions: {
|
|
queries: {
|
|
staleTime: 60 * 1000, // 1 minute
|
|
retry: 1,
|
|
refetchOnWindowFocus: false,
|
|
},
|
|
},
|
|
})
|
|
);
|
|
|
|
return (
|
|
<QueryClientProvider client={queryClient}>
|
|
{children}
|
|
</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 🔧 Modification layout.tsx
|
|
|
|
```typescript
|
|
// src/app/layout.tsx
|
|
import type { Metadata } from "next";
|
|
import { Inter } from "next/font/google";
|
|
import "./globals.css";
|
|
import { Sidebar } from "@/components/sidebar";
|
|
import { QueryProvider } from "@/providers/QueryProvider";
|
|
|
|
const inter = Inter({ subsets: ["latin"] });
|
|
|
|
export const metadata: Metadata = {
|
|
title: "Translate Co. - Document Translation",
|
|
description: "Translate Excel, Word, and PowerPoint documents while preserving formatting",
|
|
};
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
}: Readonly<{ children: React.ReactNode }>) {
|
|
return (
|
|
<html lang="en" className="dark">
|
|
<body className={`${inter.className} bg-[#262626] text-zinc-100 antialiased`}>
|
|
<QueryProvider>
|
|
<Sidebar />
|
|
<main className="ml-64 min-h-screen p-8">
|
|
<div className="max-w-6xl mx-auto">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
</QueryProvider>
|
|
</body>
|
|
</html>
|
|
);
|
|
}
|
|
```
|
|
|
|
### 🚨 Anti-Patterns à Éviter
|
|
|
|
1. **NE PAS utiliser axios** - Le projet utilise fetch natif, pas besoin d'ajouter une dépendance supplémentaire
|
|
|
|
2. **NE PAS hardcoder les URLs** - Toujours utiliser `NEXT_PUBLIC_API_URL`
|
|
|
|
3. **NE PAS utiliser Zustand pour le state server** - TanStack Query gère le cache et les requêtes
|
|
|
|
4. **NE PAS oublier le header Authorization** - Le token doit être injecté automatiquement
|
|
|
|
5. **NE PAS ignorer le format d'erreur** - `{error, message, details?}` est obligatoire
|
|
|
|
6. **NE PAS créer Page.tsx** - Toujours `page.tsx` en minuscules
|
|
|
|
### 📊 Format API (Architecture)
|
|
|
|
**Succès:**
|
|
```json
|
|
{
|
|
"data": { ... },
|
|
"meta": { "rate_limit_remaining": 45 }
|
|
}
|
|
```
|
|
|
|
**Erreur:**
|
|
```json
|
|
{
|
|
"error": "QUOTA_EXCEEDED",
|
|
"message": "Limite quotidienne atteinte",
|
|
"details": { "current_usage": 5, "limit": 5 }
|
|
}
|
|
```
|
|
|
|
**⚠️ Pas de champ `data` dans les erreurs.**
|
|
|
|
### 🧪 Pattern d'Utilisation Futur
|
|
|
|
```typescript
|
|
// Exemple dans un composant
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { apiClient } from '@/lib/apiClient';
|
|
|
|
function UserProfile() {
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['user', 'me'],
|
|
queryFn: () => apiClient.get<User>('/api/v1/auth/me'),
|
|
});
|
|
|
|
if (isLoading) return <LoadingSpinner />;
|
|
if (error) return <ErrorMessage error={error} />;
|
|
|
|
return <div>{data.data.email}</div>;
|
|
}
|
|
```
|
|
|
|
### Project Structure Notes
|
|
|
|
- **Nouveau fichier:** `src/lib/apiClient.ts` - Client HTTP centralisé
|
|
- **Nouveau fichier:** `src/lib/types.ts` - Types API globaux
|
|
- **Nouveau fichier:** `src/providers/QueryProvider.tsx` - Provider TanStack Query
|
|
- **Modification:** `src/app/layout.tsx` - Intégration QueryProvider
|
|
- **Nouveau fichier:** `.env.local.example` - Documentation des variables d'environnement
|
|
|
|
### References
|
|
|
|
- [Source: _bmad-output/planning-artifacts/epics.md#Story-4.1] - Story requirements
|
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Frontend-Architecture] - TanStack Query decision
|
|
- [Source: _bmad-output/planning-artifacts/architecture.md#API-Response-Formats] - Format API
|
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Naming-Patterns] - Conventions naming
|
|
- [Source: _bmad-output/planning-artifacts/architecture.md#Organisation-Frontend] - Structure colocation
|
|
- [Source: frontend/package.json] - Dépendances actuelles (React 19, Next.js 16)
|
|
- [Source: frontend/src/app/dashboard/page.tsx] - Pattern fetch actuel à migrer
|
|
|
|
## Dev Agent Record
|
|
|
|
### Agent Model Used
|
|
|
|
Claude 3.5 Sonnet (claude-3-5-sonnet)
|
|
|
|
### Debug Log References
|
|
|
|
- Fixed TypeScript error: A required parameter cannot follow an optional parameter in ApiClientError constructor
|
|
- Fixed TypeScript error: HeadersInit type issue - changed to Record<string, string> for better type safety
|
|
- Vitest configuration created for testing framework setup
|
|
|
|
### Completion Notes List
|
|
|
|
- ✅ TanStack Query v5.90.21 installé avec succès
|
|
- ✅ QueryProvider créé avec configuration par défaut (staleTime: 60s, retry: 1, refetchOnWindowFocus: false)
|
|
- ✅ apiClient centralisé avec support JWT (Authorization: Bearer) et API Key (X-API-Key)
|
|
- ✅ Parsing d'erreurs structurées au format {error, message, details?}
|
|
- ✅ Types API créés: ApiError, ApiResponse, ApiSuccessResponse, User, UsageStats
|
|
- ✅ Layout.tsx modifié pour wrapper l'app avec QueryProvider
|
|
- ✅ .env.local.example créé avec NEXT_PUBLIC_API_URL
|
|
- ✅ Suite de tests créée avec Vitest (5 tests passants)
|
|
- ✅ Build Next.js réussi sans erreurs TypeScript
|
|
- ✅ Test framework configuré: vitest + @testing-library/react + jsdom
|
|
|
|
### File List
|
|
|
|
**Nouveaux fichiers:**
|
|
- `frontend/src/lib/apiClient.ts` - Client HTTP centralisé avec authentification automatique
|
|
- `frontend/src/lib/types.ts` - Types TypeScript pour les réponses API
|
|
- `frontend/src/lib/apiClient.test.ts` - Tests unitaires pour l'apiClient
|
|
- `frontend/src/providers/QueryProvider.tsx` - Provider TanStack Query
|
|
- `frontend/src/test/setup.ts` - Configuration des tests
|
|
- `frontend/vitest.config.ts` - Configuration Vitest
|
|
- `frontend/.env.local.example` - Documentation des variables d'environnement
|
|
|
|
**Fichiers modifiés:**
|
|
- `frontend/package.json` - Ajout de @tanstack/react-query, vitest et dépendances de test, suppression axios (inutilisé)
|
|
- `frontend/src/app/layout.tsx` - Intégration du QueryProvider
|
|
- `frontend/src/lib/api.ts` - Refactor pour utiliser apiClient (AC#6 migration)
|
|
|
|
## Change Log
|
|
|
|
- 2026-02-22: Implémentation complète de la Story 4.1 - Setup TanStack Query & API Client
|
|
- 2026-02-22: Code Review - Corrections appliquées (voir Senior Developer Review)
|
|
|
|
## Senior Developer Review (AI)
|
|
|
|
### Review Date: 2026-02-22
|
|
|
|
### Issues Found & Fixed:
|
|
|
|
| # | Severity | Issue | Status |
|
|
|---|----------|-------|--------|
|
|
| 1 | HIGH | AC#6 Migration claim was FALSE - api.ts still had duplicate fetch logic | ✅ Fixed |
|
|
| 2 | HIGH | Duplicate error handling code in apiClient.ts (request/upload) | ✅ Fixed |
|
|
| 3 | HIGH | Unused axios dependency in package.json | ✅ Fixed |
|
|
| 4 | MEDIUM | Missing tests for auth header injection | ✅ Fixed |
|
|
| 5 | MEDIUM | No global error handling in QueryProvider | ✅ Fixed |
|
|
| 6 | MEDIUM | Non-JSON error responses not handled gracefully | ✅ Fixed |
|
|
| 7 | LOW | Magic number for staleTime (60 * 1000) | ✅ Fixed |
|
|
|
|
### Changes Applied:
|
|
|
|
1. **apiClient.ts** - Refactored:
|
|
- Extracted `parseErrorResponse()` method (DRY)
|
|
- Added `buildHeaders()` helper method
|
|
- Added `download()` method for blob responses
|
|
- Added `DEFAULT_STALE_TIME_MS` constant export
|
|
- Improved non-JSON error handling
|
|
|
|
2. **api.ts** - Refactored:
|
|
- Now uses `apiClient` for internal API calls
|
|
- `extractTextsFromDocument()` uses `apiClient.upload()`
|
|
- `getOllamaModels()` uses `apiClient.get()`
|
|
- Removed duplicate `getBaseUrl()` function
|
|
|
|
3. **apiClient.test.ts** - Expanded:
|
|
- Added tests for auth headers (JWT token, API key)
|
|
- Added tests for `setApiKey`/`clearApiKey`
|
|
- Added tests for `DEFAULT_STALE_TIME_MS`
|
|
- 11 tests total (was 5)
|
|
|
|
4. **QueryProvider.tsx** - Enhanced:
|
|
- Added global error handler stub
|
|
- Added `DEFAULT_STALE_TIME_MS` export
|
|
- Added mutations default options
|
|
|
|
5. **package.json** - Cleaned:
|
|
- Removed unused `axios` dependency
|
|
|
|
### Review Outcome: ✅ APPROVED
|
|
|
|
All HIGH and MEDIUM issues fixed. Story ready for completion.
|