# Spécification API - Module Véhicules ## A) Fichiers frontend identifiés ### Pages et composants - **Page liste :** `frontend/src/pages/Vehicles.tsx` - **Formulaire création :** `frontend/src/components/AddVehicleDialog.tsx` - **Formulaire édition :** `frontend/src/components/EditVehicleDialog.tsx` - **Carte affichage :** `frontend/src/components/VehicleCard.tsx` - **Schéma TypeScript :** `frontend/src/shared/schema.ts` (lignes 48-63, 318-327) ### Services API - **Helper API :** `frontend/src/lib/queryClient.ts` (fonction `apiRequest`) - **Utilisation :** Les appels utilisent `apiRequest(url, { method, body })` qui fait des `fetch()` simples --- ## B) Tableau UI → Champ/Endpoint | Élément UI | Champ Backend | Type | Requis | Notes | |------------|---------------|------|--------|-------| | **Table/Liste** | | | | | | Immatriculation (badge) | `immatriculation` | string | ✅ | Unique, affiché en badge | | Nom du camion | `nom` | string | ❌ | Optionnel | | Couleur | `couleur` | string | ❌ | Optionnel | | Marque | `marque` | string | ❌ | Optionnel | | Modèle | `modele` | string | ❌ | Optionnel | | Année | `annee` | integer | ❌ | Optionnel | | Photo | `photoUrl` | string | ❌ | URL de l'image | | Statut | `status` | enum | ❌ | "actif" | "maintenance" | "hors_service" | | Kilométrage | `kilometrage` | integer | ❌ | **Calculé côté frontend** depuis maintenances | | **Formulaire Création** | | | | | | Immatriculation | `immatriculation` | string | ✅ | Requis, validation frontend | | Nom | `nom` | string | ❌ | Optionnel | | Couleur | `couleur` | string | ❌ | Optionnel | | Marque | `marque` | string | ❌ | Optionnel | | Modèle | `modele` | string | ❌ | Optionnel | | Année | `annee` | integer | ❌ | Optionnel, min 1900, max année courante+1 | | Photo (upload) | `photoUrl` + `photoCloudinaryPublicId` | string | ❌ | Upload via `/api/upload/vehicle-photo` | | **Formulaire Édition** | | | | | | Tous les champs création + | | | | | | Statut | `status` | enum | ❌ | Sélecteur: actif/maintenance/hors_service | ### Champs techniques (non visibles dans UI) - `id` - integer (auto, primary key) - `photoCloudinaryPublicId` - string (optionnel, pour Cloudinary) - `plaquePhotoUrl` - string (optionnel, non utilisé dans UI actuelle) - `plaquePhotoCloudinaryPublicId` - string (optionnel, non utilisé dans UI actuelle) - `createdAt` - timestamp (auto) - `updatedAt` - timestamp (auto) --- ## C) SPEC API - Endpoints ### Format général **Base URL :** `/api/vehicles` **Authentification :** Tous les endpoints requièrent une session Sanctum valide (middleware `auth:sanctum` avec cookies) **Format de réponse succès :** - ❌ **PAS de wrapper** `{data: ...}` - retour direct array/object - ✅ **camelCase obligatoire** - conforme au schéma TypeScript frontend - Exemple : `[{id: 1, immatriculation: "AB-123-CD", photoUrl: "...", createdAt: "..."}]` **Format d'erreur :** - Erreur de validation : `{message: "...", errors: {field: ["..."]}}` - Erreur serveur : `{message: "..."}` **Convention de nommage :** - ✅ **Backend DOIT renvoyer camelCase** (photoUrl, photoCloudinaryPublicId, createdAt, updatedAt) - Le frontend ne fait **aucune conversion** snake_case/camelCase - DB reste en snake_case (Laravel standard), conversion uniquement dans Resource --- ### 1. GET /api/vehicles **Description :** Liste tous les véhicules **Query params :** Aucun (pas de pagination visible dans le frontend actuel) **Réponse 200 :** ```json [ { "id": 1, "immatriculation": "AB-123-CD", "nom": "Le Rouge", "couleur": "Rouge", "marque": "Volvo", "modele": "FH16", "annee": 2023, "photoUrl": "https://example.com/photo.jpg", "photoCloudinaryPublicId": "vehicles/abc123", "status": "actif", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:30:00Z" }, { "id": 2, "immatriculation": "XY-789-ZW", "nom": null, "couleur": null, "marque": "Mercedes", "modele": "Actros", "annee": 2022, "photoUrl": null, "photoCloudinaryPublicId": null, "status": "maintenance", "createdAt": "2024-01-10T08:00:00Z", "updatedAt": "2024-01-14T15:20:00Z" } ] ``` **Erreurs :** - `401 Unauthorized` - Session invalide ou expirée --- ### 2. GET /api/vehicles/{id} **Description :** Récupère un véhicule par ID **Réponse 200 :** ```json { "id": 1, "immatriculation": "AB-123-CD", "nom": "Le Rouge", "couleur": "Rouge", "marque": "Volvo", "modele": "FH16", "annee": 2023, "photoUrl": "https://example.com/photo.jpg", "photoCloudinaryPublicId": "vehicles/abc123", "status": "actif", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:30:00Z" } ``` **Erreurs :** - `401 Unauthorized` - Token invalide - `404 Not Found` - Véhicule non trouvé --- ### 3. POST /api/vehicles **Description :** Crée un nouveau véhicule **Body (JSON, camelCase accepté ou snake_case) :** ```json { "immatriculation": "AB-123-CD", "nom": "Le Rouge", "couleur": "Rouge", "marque": "Volvo", "modele": "FH16", "annee": 2023, "photoUrl": "https://example.com/photo.jpg", "photoCloudinaryPublicId": "vehicles/abc123" } ``` **Note :** Le frontend envoie en camelCase, mais le backend peut accepter les deux formats. **Réponse 201 :** ```json { "id": 1, "immatriculation": "AB-123-CD", "nom": "Le Rouge", "couleur": "Rouge", "marque": "Volvo", "modele": "FH16", "annee": 2023, "photoUrl": "https://example.com/photo.jpg", "photoCloudinaryPublicId": "vehicles/abc123", "status": "actif", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T10:30:00Z" } ``` **Erreurs :** - `401 Unauthorized` - Session invalide - `422 Unprocessable Entity` - Erreur de validation : ```json { "message": "Les données fournies sont invalides.", "errors": { "immatriculation": ["L'immatriculation est obligatoire.", "Cette immatriculation existe déjà."], "annee": ["L'année doit être entre 1900 et 2025."] } } ``` --- ### 4. PATCH /api/vehicles/{id} **Description :** Met à jour un véhicule (partiel) **Body (JSON, champs optionnels) :** ```json { "immatriculation": "AB-123-CD", "nom": "Le Rouge Modifié", "couleur": "Bleu", "marque": "Volvo", "modele": "FH16", "annee": 2024, "photoUrl": "https://example.com/new-photo.jpg", "photoCloudinaryPublicId": "vehicles/xyz789", "status": "maintenance" } ``` **Réponse 200 :** ```json { "id": 1, "immatriculation": "AB-123-CD", "nom": "Le Rouge Modifié", "couleur": "Bleu", "marque": "Volvo", "modele": "FH16", "annee": 2024, "photoUrl": "https://example.com/new-photo.jpg", "photoCloudinaryPublicId": "vehicles/xyz789", "status": "maintenance", "createdAt": "2024-01-15T10:30:00Z", "updatedAt": "2024-01-15T14:45:00Z" } ``` **Erreurs :** - `401 Unauthorized` - Session invalide - `404 Not Found` - Véhicule non trouvé - `422 Unprocessable Entity` - Erreur de validation --- ### 5. DELETE /api/vehicles/{id} **Description :** Supprime un véhicule **Réponse 204 :** No Content (succès) **Erreurs :** - `401 Unauthorized` - Session invalide - `404 Not Found` - Véhicule non trouvé - `409 Conflict` - Si le véhicule est utilisé (trips, missions, etc.) : ```json { "message": "Impossible de supprimer ce véhicule car il est utilisé dans des missions actives." } ``` --- ## D) Design DB - Table `vehicles` (MVP) ### Schéma minimal MVP ```sql CREATE TABLE vehicles ( id INTEGER PRIMARY KEY AUTO_INCREMENT, immatriculation VARCHAR(255) NOT NULL UNIQUE, nom VARCHAR(255) NULL, couleur VARCHAR(255) NULL, marque VARCHAR(255) NULL, modele VARCHAR(255) NULL, annee INTEGER NULL, photo_url TEXT NULL, photo_cloudinary_public_id VARCHAR(255) NULL, plaque_photo_url TEXT NULL, plaque_photo_cloudinary_public_id VARCHAR(255) NULL, status VARCHAR(50) NOT NULL DEFAULT 'actif', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_immatriculation (immatriculation), INDEX idx_status (status), INDEX idx_marque (marque) ); ``` ### Colonnes détaillées | Colonne | Type SQL | Nullable | Default | Index | Unique | Notes | |---------|----------|----------|---------|-------|--------|-------| | `id` | INTEGER | ❌ | AUTO_INCREMENT | PRIMARY | ✅ | Clé primaire | | `immatriculation` | VARCHAR(255) | ❌ | - | ✅ | ✅ | **Requis, unique** | | `nom` | VARCHAR(255) | ✅ | NULL | - | - | Nom du camion | | `couleur` | VARCHAR(255) | ✅ | NULL | - | - | Couleur | | `marque` | VARCHAR(255) | ✅ | NULL | ✅ | - | Marque (Volvo, Mercedes, etc.) | | `modele` | VARCHAR(255) | ✅ | NULL | - | - | Modèle | | `annee` | INTEGER | ✅ | NULL | - | - | Année de fabrication | | `photo_url` | TEXT | ✅ | NULL | - | - | URL photo véhicule | | `photo_cloudinary_public_id` | VARCHAR(255) | ✅ | NULL | - | - | Public ID Cloudinary | | `plaque_photo_url` | TEXT | ✅ | NULL | - | - | URL photo plaque (Phase 2) | | `plaque_photo_cloudinary_public_id` | VARCHAR(255) | ✅ | NULL | - | - | Public ID Cloudinary plaque (Phase 2) | | `status` | VARCHAR(50) | ❌ | 'actif' | ✅ | - | Enum: actif, maintenance, hors_service | | `created_at` | TIMESTAMP | ❌ | CURRENT_TIMESTAMP | - | - | Auto | | `updated_at` | TIMESTAMP | ❌ | CURRENT_TIMESTAMP | - | - | Auto ON UPDATE | ### Contraintes - **UNIQUE** sur `immatriculation` - ❌ **PAS de CHECK constraints** (géré par validation Laravel uniquement) ### Soft Delete **Décision MVP :** ❌ Pas de soft delete pour l'instant - Le frontend fait un DELETE classique - Si besoin Phase 2 : ajouter `deleted_at TIMESTAMP NULL` ### Phase 2 (Extensions futures) - `deleted_at` - Soft delete - `gps_device_id` - Lien vers dispositif GPS - `insurance_expiry` - Date expiration assurance - `registration_expiry` - Date expiration immatriculation - `last_maintenance_date` - Dernière maintenance - `last_maintenance_km` - Dernier kilométrage maintenance - Relations avec : - `trips` (via `vehicle_id`) - `missions` (via `vehicle_id`) - `maintenances` (via `vehicle_id`) - `breakdowns` (via `vehicle_id`) --- ## E) Plan d'implémentation Laravel ### 1. Migration **Fichier :** `database/migrations/YYYY_MM_DD_HHMMSS_create_vehicles_table.php` ```php id(); $table->string('immatriculation')->unique(); $table->string('nom')->nullable(); $table->string('couleur')->nullable(); $table->string('marque')->nullable(); $table->string('modele')->nullable(); $table->integer('annee')->nullable(); $table->text('photo_url')->nullable(); $table->string('photo_cloudinary_public_id')->nullable(); $table->text('plaque_photo_url')->nullable(); $table->string('plaque_photo_cloudinary_public_id')->nullable(); $table->string('status')->default('actif'); $table->timestamps(); $table->index('status'); $table->index('marque'); }); } public function down(): void { Schema::dropIfExists('vehicles'); } }; ``` ### 2. Modèle **Fichier :** `app/Models/Vehicle.php` ```php 'integer', 'created_at' => 'datetime', 'updated_at' => 'datetime', ]; // Scopes pour recherche/filtrage public function scopeSearch($query, $search) { return $query->where(function ($q) use ($search) { $q->where('immatriculation', 'like', "%{$search}%") ->orWhere('marque', 'like', "%{$search}%") ->orWhere('modele', 'like', "%{$search}%"); }); } public function scopeStatus($query, $status) { return $query->where('status', $status); } } ``` ### 3. Form Requests **Fichier :** `app/Http/Requests/VehicleStoreRequest.php` ```php ['required', 'string', 'max:255', 'unique:vehicles,immatriculation'], 'nom' => ['nullable', 'string', 'max:255'], 'couleur' => ['nullable', 'string', 'max:255'], 'marque' => ['nullable', 'string', 'max:255'], 'modele' => ['nullable', 'string', 'max:255'], 'annee' => ['nullable', 'integer', 'min:1900', 'max:' . (date('Y') + 1)], 'photoUrl' => ['nullable', 'url', 'max:2048'], 'photoCloudinaryPublicId' => ['nullable', 'string', 'max:255'], ]; } protected function prepareForValidation(): void { // Normaliser camelCase vers snake_case pour la DB $data = $this->all(); if (isset($data['photoUrl'])) { $data['photo_url'] = $data['photoUrl']; unset($data['photoUrl']); } if (isset($data['photoCloudinaryPublicId'])) { $data['photo_cloudinary_public_id'] = $data['photoCloudinaryPublicId']; unset($data['photoCloudinaryPublicId']); } $this->replace($data); } } ``` **Fichier :** `app/Http/Requests/VehicleUpdateRequest.php` ```php route('vehicle'); $vehicleId = $vehicle instanceof \App\Models\Vehicle ? $vehicle->id : $vehicle; return [ 'immatriculation' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('vehicles')->ignore($vehicleId)], 'nom' => ['sometimes', 'nullable', 'string', 'max:255'], 'couleur' => ['sometimes', 'nullable', 'string', 'max:255'], 'marque' => ['sometimes', 'nullable', 'string', 'max:255'], 'modele' => ['sometimes', 'nullable', 'string', 'max:255'], 'annee' => ['sometimes', 'nullable', 'integer', 'min:1900', 'max:' . (date('Y') + 1)], 'photoUrl' => ['sometimes', 'nullable', 'url', 'max:2048'], 'photoCloudinaryPublicId' => ['sometimes', 'nullable', 'string', 'max:255'], 'status' => ['sometimes', 'nullable', 'string', Rule::in(['actif', 'maintenance', 'hors_service'])], ]; } protected function prepareForValidation(): void { // Normaliser camelCase vers snake_case pour la DB $data = $this->all(); if (isset($data['photoUrl'])) { $data['photo_url'] = $data['photoUrl']; unset($data['photoUrl']); } if (isset($data['photoCloudinaryPublicId'])) { $data['photo_cloudinary_public_id'] = $data['photoCloudinaryPublicId']; unset($data['photoCloudinaryPublicId']); } $this->replace($data); } } ``` ### 4. Resource **Fichier :** `app/Http/Resources/VehicleResource.php` ```php $this->id, 'immatriculation' => $this->immatriculation, 'nom' => $this->nom, 'couleur' => $this->couleur, 'marque' => $this->marque, 'modele' => $this->modele, 'annee' => $this->annee, 'photoUrl' => $this->photo_url, // Conversion snake_case -> camelCase 'photoCloudinaryPublicId' => $this->photo_cloudinary_public_id, 'plaquePhotoUrl' => $this->plaque_photo_url, 'plaquePhotoCloudinaryPublicId' => $this->plaque_photo_cloudinary_public_id, 'status' => $this->status, 'createdAt' => $this->created_at?->toISOString(), 'updatedAt' => $this->updated_at?->toISOString(), ]; } } ``` ### 5. Controller **Fichier :** `app/Http/Controllers/VehicleController.php` ```php has('search')) { $query->search($request->input('search')); } // Filtre par statut (si présent) if ($request->has('status')) { $query->status($request->input('status')); } $vehicles = $query->orderBy('immatriculation')->get(); // Retourner array brut (pas de wrapper {data: ...}) return response()->json(VehicleResource::collection($vehicles)->resolve()); } public function show(Vehicle $vehicle): JsonResponse { // Retourner object brut (pas de wrapper {data: ...}) return response()->json((new VehicleResource($vehicle))->resolve()); } public function store(VehicleStoreRequest $request): JsonResponse { $vehicle = Vehicle::create($request->validated()); // Retourner object brut return response()->json((new VehicleResource($vehicle))->resolve(), 201); } public function update(VehicleUpdateRequest $request, Vehicle $vehicle): JsonResponse { $vehicle->update($request->validated()); // Retourner object brut return response()->json((new VehicleResource($vehicle->fresh()))->resolve()); } public function destroy(Vehicle $vehicle): JsonResponse { // Vérifier si le véhicule est utilisé (Phase 2: vérifier trips, missions, etc.) // Pour MVP, on supprime directement $vehicle->delete(); return response()->json(null, 204); } } ``` ### 6. Routes **Fichier :** `routes/api.php` (ajouter dans le groupe auth:sanctum) ```php use App\Http\Controllers\VehicleController; Route::middleware('auth:sanctum')->group(function () { // ... routes existantes (me, logout) Route::apiResource('vehicles', VehicleController::class); }); ``` --- ## F) Smoke Tests - Commandes curl ### Prérequis 1. Obtenir une session via login (cookies) 2. Utiliser les cookies de session dans les requêtes suivantes ### 1. Login (pour obtenir la session) ```bash curl -X POST https://apiparcapp.jrbxsolutions.com/api/login \ -H "Content-Type: application/json" \ -c cookies.txt \ -d '{"username":"admin","password":"password123"}' ``` **Réponse attendue :** ```json { "token": "1|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "user": {...} } ``` **Note :** Les cookies de session sont stockés dans `cookies.txt` ### 2. GET /api/vehicles (Liste) ```bash curl -X GET https://apiparcapp.jrbxsolutions.com/api/vehicles \ -b cookies.txt \ -H "Accept: application/json" ``` **Réponse attendue :** `200 OK` avec tableau de véhicules ### 3. POST /api/vehicles (Création) ```bash curl -X POST https://apiparcapp.jrbxsolutions.com/api/vehicles \ -b cookies.txt \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "immatriculation": "TEST-123-AB", "nom": "Véhicule Test", "couleur": "Rouge", "marque": "Volvo", "modele": "FH16", "annee": 2023, "photoUrl": "https://example.com/photo.jpg" }' ``` **Réponse attendue :** `201 Created` avec véhicule créé (camelCase) ### 4. GET /api/vehicles/{id} (Détails) ```bash curl -X GET https://apiparcapp.jrbxsolutions.com/api/vehicles/1 \ -b cookies.txt \ -H "Accept: application/json" ``` **Réponse attendue :** `200 OK` avec véhicule (camelCase) ### 5. PATCH /api/vehicles/{id} (Mise à jour) ```bash curl -X PATCH https://apiparcapp.jrbxsolutions.com/api/vehicles/1 \ -b cookies.txt \ -H "Content-Type: application/json" \ -H "Accept: application/json" \ -d '{ "nom": "Véhicule Modifié", "status": "maintenance" }' ``` **Réponse attendue :** `200 OK` avec véhicule mis à jour (camelCase) ### 6. DELETE /api/vehicles/{id} (Suppression) ```bash curl -X DELETE https://apiparcapp.jrbxsolutions.com/api/vehicles/1 \ -b cookies.txt \ -H "Accept: application/json" ``` **Réponse attendue :** `204 No Content` --- ## Points d'attention - Shared Hosting (Bluehost) ### 1. Headers CORS - Vérifier que les headers CORS sont configurés dans `config/cors.php` - Autoriser l'origine frontend : `https://parcapp.jrbxsolutions.com` - Autoriser les credentials : `Access-Control-Allow-Credentials: true` ### 2. Sanctum SPA Mode (Cookies) - ✅ Utiliser **cookies/session** Sanctum (SPA mode activé) - ✅ Frontend envoie `credentials: "include"` dans fetch() - ✅ Backend doit accepter les cookies de session - ⚠️ Configurer `SANCTUM_STATEFUL_DOMAINS` dans `.env` : ``` SANCTUM_STATEFUL_DOMAINS=parcapp.jrbxsolutions.com,localhost:5173 ``` ### 3. ModSecurity (Bluehost) - Si POST est bloqué, utiliser GET avec `_method=PATCH` ou configurer ModSecurity - Alternative : Route GET pour logout (déjà fait dans routes/api.php ligne 11) ### 4. Timeouts - Augmenter `max_execution_time` pour les uploads de photos - Configurer `upload_max_filesize` et `post_max_size` pour les images ### 5. Base de données - Sur Bluehost : MySQL (pas SQLite) - Vérifier `.env` : `DB_CONNECTION=mysql` - Configurer `DB_HOST`, `DB_DATABASE`, `DB_USERNAME`, `DB_PASSWORD` --- ## Checklist d'intégration - [ ] Migration créée et exécutée - [ ] Modèle Vehicle créé avec fillable/casts/scopes - [ ] FormRequests créés (Store/Update) avec validation - [ ] VehicleResource créé - [ ] VehicleController créé avec toutes les méthodes - [ ] Routes ajoutées dans `routes/api.php` avec middleware `auth:sanctum` - [ ] Tests curl passent (tous les endpoints) - [ ] Frontend peut créer/modifier/supprimer des véhicules - [ ] Validation fonctionne (immatriculation unique, année valide, etc.) - [ ] Gestion d'erreurs fonctionne (404, 422, 401) --- **Date de création :** Aujourd'hui **Statut :** Prêt pour implémentation