diff --git a/UPLOAD_MANUAL_SIGNED_CONTRACT.md b/UPLOAD_MANUAL_SIGNED_CONTRACT.md new file mode 100644 index 0000000..acfc664 --- /dev/null +++ b/UPLOAD_MANUAL_SIGNED_CONTRACT.md @@ -0,0 +1,128 @@ +# Upload Manuel du Contrat Signé - Guide + +## Vue d'ensemble + +Cette fonctionnalité permet au staff d'uploader manuellement le PDF d'un contrat signé lorsqu'il a été reçu par un moyen externe (email, courrier, etc.) au lieu de passer par le processus Docuseal. + +## Comment utiliser + +### Accès +1. Aller sur la page `staff/contrats/[id]` d'un contrat +2. Dans la section **Documents**, cliquer sur le bouton **"Ajouter le contrat signé manuellement"** + +### Upload +1. Une modale s'ouvre avec une zone de dépôt de fichier +2. Cliquer sur la zone ou glisser-déposer un fichier PDF +3. Vérifications automatiques : + - Type de fichier : doit être un PDF + - Taille maximum : 10 Mo +4. Cliquer sur **"Uploader"** + +### Après l'upload +- Le PDF est stocké dans S3 à la clé : `contracts//.pdf` +- Le champ `contract_pdf_s3_key` est mis à jour dans la base de données +- Le champ `contrat_signe` passe à "Oui" +- Le contrat apparaît dans la section Documents avec le même style qu'un contrat signé via Docuseal + +## Architecture technique + +### Fichiers créés + +#### 1. API Route : `/app/api/staff/contrats/[id]/upload-signed-pdf/route.ts` +- **Méthode** : POST +- **Authentification** : Staff uniquement +- **Corps de la requête** : FormData avec un champ `file` contenant le PDF +- **Traitement** : + 1. Vérification des droits staff + 2. Validation du type de fichier (application/pdf) + 3. Récupération de l'organisation pour obtenir l'`api_name` + 4. Upload dans S3 avec la clé `contracts//.pdf` + 5. Mise à jour du contrat : `contract_pdf_s3_key` et `contrat_signe = "Oui"` + +#### 2. Composant : `/components/staff/contracts/ManualSignedContractUpload.tsx` +- Modal avec zone de dépôt de fichier +- Validation côté client (type, taille) +- États de chargement et de succès +- Callback `onSuccess` pour rafraîchir les données après upload + +#### 3. Modifications : `/components/staff/contracts/ContractEditor.tsx` +- Import du composant `ManualSignedContractUpload` et de l'icône `Upload` +- Ajout du state `isUploadModalOpen` +- Ajout du bouton d'upload dans la section Documents +- Invalidation de la query `signed-contract-pdf` après upload pour rafraîchir automatiquement +- Modification de la condition `enabled` de la query pour toujours vérifier l'existence d'un contrat signé + +### Flux de données + +``` +1. Utilisateur clique sur "Ajouter le contrat signé manuellement" + ↓ +2. Modal s'ouvre → Sélection du fichier PDF + ↓ +3. Click sur "Uploader" → POST /api/staff/contrats/[id]/upload-signed-pdf + ↓ +4. API vérifie les droits → Récupère l'organization → Upload S3 + ↓ +5. Mise à jour BDD (contract_pdf_s3_key, contrat_signe) + ↓ +6. Success → Invalidation de la query → Rafraîchissement automatique + ↓ +7. Le contrat signé apparaît dans la section Documents +``` + +### Stockage S3 + +Le PDF uploadé est stocké exactement au même endroit que s'il avait été signé via Docuseal : +- **Bucket** : `odentas-docs` (ou valeur de `AWS_S3_BUCKET`) +- **Clé** : `contracts//.pdf` +- **ACL** : Private +- **ContentType** : application/pdf + +Cela garantit que : +- L'API `/api/staff/contrats/[id]/signed-pdf` fonctionne de la même manière +- Les emails de notification peuvent récupérer le PDF +- La logique existante n'est pas perturbée + +## Cas d'usage + +### Quand utiliser cette fonctionnalité ? + +1. **Contrat signé par courrier** : Le salarié ou l'employeur a imprimé, signé et renvoyé le contrat par courrier +2. **Contrat signé par email** : Le PDF signé a été renvoyé par email au lieu de passer par Docuseal +3. **Problème technique Docuseal** : En cas de dysfonctionnement de Docuseal, permet de continuer le processus +4. **Migration de données** : Upload de contrats historiques déjà signés + +### Points d'attention + +- Le PDF doit être **déjà signé** par les deux parties +- L'upload marque automatiquement `contrat_signe = "Oui"` +- Si un contrat signé existe déjà, il sera **remplacé** +- La limite de 10 Mo est suffisante pour la plupart des contrats (quelques pages) + +## Sécurité + +- Accès réservé au **staff uniquement** (vérification via `staff_users.is_staff`) +- Validation stricte du type MIME (`application/pdf`) +- Upload direct vers S3 avec ACL Private +- Pas d'exécution de code du PDF (simple stockage) + +## Tests + +Pour tester la fonctionnalité : + +1. Se connecter en tant que staff +2. Aller sur un contrat existant (ex: `/staff/contrats/123`) +3. Cliquer sur "Ajouter le contrat signé manuellement" +4. Uploader un PDF de test +5. Vérifier que : + - Le PDF apparaît dans la section Documents + - Le statut du contrat passe à "signé" + - Le lien "Contrat CDDU signé" ouvre le bon fichier + - Le champ `contract_pdf_s3_key` est bien rempli dans la BDD + +## Notes techniques + +- Utilise `@aws-sdk/client-s3` pour l'upload (PutObjectCommand) +- Utilise `useQueryClient` pour invalider les queries React Query +- Le modal utilise les composants shadcn/ui (`Dialog`) +- Compatible avec la logique existante de récupération des contrats signés diff --git a/app/api/staff/contrats/[id]/upload-signed-pdf/route.ts b/app/api/staff/contrats/[id]/upload-signed-pdf/route.ts index 399b565..2499240 100644 --- a/app/api/staff/contrats/[id]/upload-signed-pdf/route.ts +++ b/app/api/staff/contrats/[id]/upload-signed-pdf/route.ts @@ -19,17 +19,22 @@ export async function POST( { params }: { params: { id: string } } ) { try { + console.log("📤 [UPLOAD] Début de l'upload manuel, ID reçu:", params.id); + const sb = createSbServer(); // Vérification de l'authentification const { data: { user }, error: authError } = await sb.auth.getUser(); if (authError || !user) { + console.log("❌ [UPLOAD] Erreur authentification:", authError); return NextResponse.json( { error: "Non autorisé" }, { status: 401 } ); } + console.log("✅ [UPLOAD] Utilisateur authentifié:", user.id); + // Vérification des droits staff const { data: me } = await sb .from("staff_users") @@ -38,38 +43,62 @@ export async function POST( .maybeSingle(); if (!me?.is_staff) { + console.log("❌ [UPLOAD] Utilisateur non-staff"); return NextResponse.json( { error: "Accès refusé - staff requis" }, { status: 403 } ); } + console.log("✅ [UPLOAD] Droits staff validés"); + // Récupération du contrat + console.log("🔍 [UPLOAD] Recherche du contrat avec ID:", params.id); const { data: contract, error: contractError } = await sb .from("cddu_contracts") - .select("id, contract_number, organization_id, contract_pdf_s3_key") + .select("id, contract_number, org_id, contract_pdf_s3_key") .eq("id", params.id) .single(); + console.log("🔍 [UPLOAD] Résultat de la recherche:", { contract, contractError }); + if (contractError || !contract) { + console.log("❌ [UPLOAD] Contrat introuvable. Erreur:", contractError?.message, "Code:", contractError?.code); return NextResponse.json( - { error: "Contrat introuvable" }, + { error: "Contrat introuvable", details: contractError?.message }, { status: 404 } ); } - // Récupération de l'organisation pour obtenir l'api_name + console.log("✅ [UPLOAD] Contrat trouvé:", contract.id, contract.contract_number); + + // Récupération de l'organisation pour obtenir le nom et le slugifier let orgApiName = "unknown_org"; - if (contract.organization_id) { - const { data: org } = await sb + console.log("🔍 [UPLOAD] org_id du contrat:", contract.org_id); + + if (contract.org_id) { + const { data: org, error: orgError } = await sb .from("organizations") - .select("api_name") - .eq("id", contract.organization_id) + .select("name") + .eq("id", contract.org_id) .single(); - if (org?.api_name) { - orgApiName = org.api_name; + console.log("🔍 [UPLOAD] Organisation trouvée:", { org, orgError }); + + if (org?.name) { + // Slugifier le nom de l'organisation + orgApiName = org.name + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + console.log("✅ [UPLOAD] Nom d'organisation slugifié:", orgApiName); + } else { + console.log("❌ [UPLOAD] Organisation sans nom"); } + } else { + console.log("❌ [UPLOAD] Contrat sans org_id"); } // Récupération du fichier depuis FormData diff --git a/components/staff/contracts/ManualSignedContractUpload.tsx b/components/staff/contracts/ManualSignedContractUpload.tsx index 661edd1..3388618 100644 --- a/components/staff/contracts/ManualSignedContractUpload.tsx +++ b/components/staff/contracts/ManualSignedContractUpload.tsx @@ -48,24 +48,31 @@ export function ManualSignedContractUpload({ return; } + console.log("📤 [UPLOAD CLIENT] Début upload pour contrat:", contractId); setUploading(true); try { const formData = new FormData(); formData.append("file", selectedFile); - const response = await fetch(`/api/staff/contrats/${contractId}/upload-signed-pdf`, { + const url = `/api/staff/contrats/${contractId}/upload-signed-pdf`; + console.log("📤 [UPLOAD CLIENT] URL de l'API:", url); + + const response = await fetch(url, { method: "POST", body: formData, }); + console.log("📤 [UPLOAD CLIENT] Réponse reçue, status:", response.status); + if (!response.ok) { const errorData = await response.json(); + console.error("❌ [UPLOAD CLIENT] Erreur:", errorData); throw new Error(errorData.error || "Erreur lors de l'upload"); } const result = await response.json(); - console.log("Upload réussi:", result); + console.log("✅ [UPLOAD CLIENT] Upload réussi:", result); setUploadSuccess(true); toast.success("Contrat signé uploadé avec succès");