fix: Corriger les noms de colonnes dans upload-signed-pdf
- organization_id -> org_id - api_name -> name (puis slugification) - Ajouter logs détaillés pour debug - Améliorer la gestion des cas où org_id est null
This commit is contained in:
parent
b0fea6813b
commit
a58d4982e6
3 changed files with 175 additions and 11 deletions
128
UPLOAD_MANUAL_SIGNED_CONTRACT.md
Normal file
128
UPLOAD_MANUAL_SIGNED_CONTRACT.md
Normal file
|
|
@ -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/<org-slug>/<contract_number>.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/<org-slug>/<contract_number>.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/<org-slug>/<contract_number>.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
|
||||||
|
|
@ -19,17 +19,22 @@ export async function POST(
|
||||||
{ params }: { params: { id: string } }
|
{ params }: { params: { id: string } }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
console.log("📤 [UPLOAD] Début de l'upload manuel, ID reçu:", params.id);
|
||||||
|
|
||||||
const sb = createSbServer();
|
const sb = createSbServer();
|
||||||
|
|
||||||
// Vérification de l'authentification
|
// Vérification de l'authentification
|
||||||
const { data: { user }, error: authError } = await sb.auth.getUser();
|
const { data: { user }, error: authError } = await sb.auth.getUser();
|
||||||
if (authError || !user) {
|
if (authError || !user) {
|
||||||
|
console.log("❌ [UPLOAD] Erreur authentification:", authError);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Non autorisé" },
|
{ error: "Non autorisé" },
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("✅ [UPLOAD] Utilisateur authentifié:", user.id);
|
||||||
|
|
||||||
// Vérification des droits staff
|
// Vérification des droits staff
|
||||||
const { data: me } = await sb
|
const { data: me } = await sb
|
||||||
.from("staff_users")
|
.from("staff_users")
|
||||||
|
|
@ -38,38 +43,62 @@ export async function POST(
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
if (!me?.is_staff) {
|
if (!me?.is_staff) {
|
||||||
|
console.log("❌ [UPLOAD] Utilisateur non-staff");
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Accès refusé - staff requis" },
|
{ error: "Accès refusé - staff requis" },
|
||||||
{ status: 403 }
|
{ status: 403 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("✅ [UPLOAD] Droits staff validés");
|
||||||
|
|
||||||
// Récupération du contrat
|
// Récupération du contrat
|
||||||
|
console.log("🔍 [UPLOAD] Recherche du contrat avec ID:", params.id);
|
||||||
const { data: contract, error: contractError } = await sb
|
const { data: contract, error: contractError } = await sb
|
||||||
.from("cddu_contracts")
|
.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)
|
.eq("id", params.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
|
console.log("🔍 [UPLOAD] Résultat de la recherche:", { contract, contractError });
|
||||||
|
|
||||||
if (contractError || !contract) {
|
if (contractError || !contract) {
|
||||||
|
console.log("❌ [UPLOAD] Contrat introuvable. Erreur:", contractError?.message, "Code:", contractError?.code);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Contrat introuvable" },
|
{ error: "Contrat introuvable", details: contractError?.message },
|
||||||
{ status: 404 }
|
{ 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";
|
let orgApiName = "unknown_org";
|
||||||
if (contract.organization_id) {
|
console.log("🔍 [UPLOAD] org_id du contrat:", contract.org_id);
|
||||||
const { data: org } = await sb
|
|
||||||
|
if (contract.org_id) {
|
||||||
|
const { data: org, error: orgError } = await sb
|
||||||
.from("organizations")
|
.from("organizations")
|
||||||
.select("api_name")
|
.select("name")
|
||||||
.eq("id", contract.organization_id)
|
.eq("id", contract.org_id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (org?.api_name) {
|
console.log("🔍 [UPLOAD] Organisation trouvée:", { org, orgError });
|
||||||
orgApiName = org.api_name;
|
|
||||||
|
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
|
// Récupération du fichier depuis FormData
|
||||||
|
|
|
||||||
|
|
@ -48,24 +48,31 @@ export function ManualSignedContractUpload({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("📤 [UPLOAD CLIENT] Début upload pour contrat:", contractId);
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", selectedFile);
|
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",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log("📤 [UPLOAD CLIENT] Réponse reçue, status:", response.status);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
|
console.error("❌ [UPLOAD CLIENT] Erreur:", errorData);
|
||||||
throw new Error(errorData.error || "Erreur lors de l'upload");
|
throw new Error(errorData.error || "Erreur lors de l'upload");
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.log("Upload réussi:", result);
|
console.log("✅ [UPLOAD CLIENT] Upload réussi:", result);
|
||||||
|
|
||||||
setUploadSuccess(true);
|
setUploadSuccess(true);
|
||||||
toast.success("Contrat signé uploadé avec succès");
|
toast.success("Contrat signé uploadé avec succès");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue