espace-paie-odentas/app/api/docuseal-signature/route.ts
odentas d7bdb1ef08 feat: Add notification tracking system with smart reminders
- Add database columns for last_employer_notification_at and last_employee_notification_at in cddu_contracts
- Update all email sending endpoints to record timestamps (remind-employer, relance-salarie, docuseal-signature, signature-salarie)
- Create smart reminder system with 24h cooldown to prevent spam
- Add progress tracking modal with real-time status (pending/sending/success/error)
- Display actual employer/employee email addresses in reminder modal
- Show notification timestamps in contracts grid with color coding (green/orange/red based on contract start date)
- Change employer email button URL from DocuSeal direct link to /signatures-electroniques
- Create /api/staff/organizations/emails endpoint for bulk email fetching
- Add retroactive migration script for historical email_logs data
- Update Contract TypeScript type and API responses to include new fields
2025-10-22 21:49:35 +02:00

554 lines
No EOL
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from 'next/server';
import { createSbServiceRole } from '@/lib/supabaseServer';
import { DynamoDBClient, PutItemCommand, UpdateItemCommand } from '@aws-sdk/client-dynamodb';
import { S3Client, GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { sendUniversalEmailV2, renderUniversalEmailV2, EmailDataV2 } from '@/lib/emailTemplateService';
import { ENV } from '@/lib/cleanEnv';
import axios from 'axios';
// Configuration AWS
const region = ENV.AWS_REGION;
const dynamoDBClient = new DynamoDBClient({
region,
credentials: {
accessKeyId: ENV.AWS_ACCESS_KEY_ID,
secretAccessKey: ENV.AWS_SECRET_ACCESS_KEY
}
});
// Vérification des variables d'environnement au démarrage
console.log('🔧 Configuration DynamoDB:', {
region,
hasAccessKey: !!ENV.AWS_ACCESS_KEY_ID,
hasSecretKey: !!ENV.AWS_SECRET_ACCESS_KEY,
accessKeyLength: ENV.AWS_ACCESS_KEY_ID?.length,
secretKeyLength: ENV.AWS_SECRET_ACCESS_KEY?.length
});
const s3Client = new S3Client({ region });
// Envoi d'e-mails géré via le service de templates universels
export async function POST(request: NextRequest) {
console.log('=== DocuSeal Signature API ===');
try {
// Utiliser le service role pour les opérations serveur
const supabase = createSbServiceRole();
const data = await request.json();
console.log('Données reçues:', JSON.stringify(data, null, 2));
const {
contractId,
pdfS3Key,
employerEmail,
employeeEmail,
reference,
employeeName,
startDate,
role,
analytique,
structure,
signerFirstName,
employerCode,
employeeFirstName,
matricule,
contractType = 'CDDU',
skipEmployerEmail = false, // Nouveau paramètre pour éviter l'envoi d'email à l'employeur
orgId = null // ID de l'organisation pour récupérer la signature
} = data;
// Validation des champs requis
if (!contractId || !pdfS3Key || !employerEmail || !employeeEmail) {
return NextResponse.json(
{ error: 'Champs manquants: contractId, pdfS3Key, employerEmail, employeeEmail' },
{ status: 400 }
);
}
// 🔒 SÉCURITÉ : Validation du format des emails
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(employeeEmail)) {
console.error('❌ [SÉCURITÉ] Format d\'email salarié invalide:', employeeEmail);
return NextResponse.json(
{ error: 'Format d\'email salarié invalide' },
{ status: 400 }
);
}
// Validation de l'email employeur avec fallback sécurisé
let validatedEmployerEmail = employerEmail;
if (!emailRegex.test(employerEmail)) {
console.warn('⚠️ [SÉCURITÉ] Email employeur invalide, utilisation du fallback:', employerEmail);
validatedEmployerEmail = "paie@odentas.fr";
console.log('✅ [SÉCURITÉ] Email employeur remplacé par:', validatedEmployerEmail);
}
// 🔒 SÉCURITÉ CRITIQUE : Vérifier que le contrat appartient bien à l'organisation spécifiée
console.log('🔒 [SÉCURITÉ] Vérification de la cohérence contrat/organisation...');
console.log('🔒 [SÉCURITÉ] contractId:', contractId);
console.log('🔒 [SÉCURITÉ] orgId fourni:', orgId);
const { data: contractVerification, error: contractVerifError } = await supabase
.from('cddu_contracts')
.select('org_id, employee_matricule, employee_name, contract_number')
.eq('id', contractId)
.single();
if (contractVerifError || !contractVerification) {
console.error('❌ [SÉCURITÉ] Contrat non trouvé:', contractVerifError);
return NextResponse.json(
{ error: 'Contrat introuvable' },
{ status: 404 }
);
}
console.log('🔒 [SÉCURITÉ] org_id du contrat en base:', contractVerification.org_id);
// Vérifier que l'org_id fourni correspond bien à celui du contrat
if (orgId && contractVerification.org_id !== orgId) {
console.error('❌ [SÉCURITÉ CRITIQUE] Tentative de signature avec une mauvaise organisation!');
console.error(' - org_id du contrat:', contractVerification.org_id);
console.error(' - org_id fourni:', orgId);
console.error(' - contrat:', contractVerification.contract_number);
console.error(' - salarié:', contractVerification.employee_name);
return NextResponse.json(
{
error: 'SÉCURITÉ : Le contrat n\'appartient pas à l\'organisation spécifiée',
details: 'Incohérence détectée entre le contrat et l\'organisation'
},
{ status: 403 }
);
}
// Vérifier également que le matricule fourni correspond (double vérification)
if (matricule && contractVerification.employee_matricule !== matricule) {
console.error('❌ [SÉCURITÉ] Incohérence détectée sur le matricule du salarié!');
console.error(' - Matricule du contrat:', contractVerification.employee_matricule);
console.error(' - Matricule fourni:', matricule);
return NextResponse.json(
{
error: 'SÉCURITÉ : Le matricule du salarié ne correspond pas au contrat',
details: 'Incohérence détectée entre les données'
},
{ status: 403 }
);
}
console.log('✅ [SÉCURITÉ] Vérification réussie - Le contrat appartient bien à l\'organisation');
console.log(' - Contrat:', contractVerification.contract_number);
console.log(' - Organisation:', contractVerification.org_id);
console.log(' - Salarié:', contractVerification.employee_name);
console.log(' - Matricule:', contractVerification.employee_matricule);
// Récupérer la signature base64 de l'organisation si disponible
let employerSignatureB64 = null;
if (orgId) {
console.log('🔍 [SIGNATURE] Recherche de la signature pour org_id:', orgId);
const { data: orgDetails } = await supabase
.from('organization_details')
.select('signature_b64')
.eq('org_id', orgId)
.maybeSingle();
if (orgDetails?.signature_b64) {
employerSignatureB64 = orgDetails.signature_b64;
console.log('✅ [SIGNATURE] Signature trouvée pour l\'employeur');
} else {
console.log(' [SIGNATURE] Aucune signature trouvée pour l\'employeur');
}
}
// Extraire le submission_id depuis le nom du fichier PDF
// pdfS3Key format: "unsigned-contracts/contrat_cddu_REFERENCE.pdf"
// submission_id attendu: "contrats/contrat_cddu_REFERENCE" (format pour DynamoDB)
const pdfFileName = pdfS3Key.split('/').pop() || ''; // "contrat_cddu_REFERENCE.pdf"
const contractFileName = pdfFileName.replace('.pdf', ''); // "contrat_cddu_REFERENCE"
const submissionId = `contrats/${contractFileName}`; // "contrats/contrat_cddu_REFERENCE"
console.log('📝 [SUBMISSION] pdfS3Key:', pdfS3Key);
console.log('📝 [SUBMISSION] pdfFileName:', pdfFileName);
console.log('📝 [SUBMISSION] contractFileName:', contractFileName);
console.log('📝 [SUBMISSION] submission_id généré:', submissionId);
const formattedDate = formatDate(startDate);
// Étape 1 : Stocker les données du contrat dans DynamoDB
console.log('🗄️ [DYNAMODB] Tentative de stockage pour submission_id:', submissionId);
try {
await storeContractData(submissionId, {
employerEmail: validatedEmployerEmail,
employeeEmail,
reference,
salarie: employeeName,
date: startDate,
poste: role,
analytique,
structure,
prenom_signataire: signerFirstName,
code_employeur: employerCode,
prenom_salarie: employeeFirstName,
matricule,
typecontrat: contractType
});
console.log('✅ [DYNAMODB] Données du contrat stockées avec succès dans DynamoDB');
} catch (dynamoError) {
console.error('❌ [DYNAMODB] Erreur détaillée lors du stockage:', {
error: dynamoError,
message: dynamoError instanceof Error ? dynamoError.message : 'Erreur inconnue',
stack: dynamoError instanceof Error ? dynamoError.stack : undefined
});
// Continue le processus même si DynamoDB échoue
}
// Étape 2 : Récupération du PDF depuis S3
const getObjectCommand = new GetObjectCommand({
Bucket: ENV.AWS_S3_BUCKET,
Key: pdfS3Key,
});
const s3Object = await s3Client.send(getObjectCommand);
const bodyBytes = await s3Object.Body?.transformToByteArray();
if (!bodyBytes) {
throw new Error('Impossible de lire le contenu du fichier PDF depuis S3');
}
const pdfBase64 = Buffer.from(bodyBytes).toString('base64');
console.log('PDF récupéré depuis S3:', pdfS3Key);
// Extraire le nom de fichier depuis la clé S3 (exemple: "unsigned-contracts/contrat_cddu_6ET5Z3XW_DEMO002.pdf")
const docusealFileName = pdfS3Key.split('/').pop() || `contrat_${submissionId}.pdf`;
console.log('Nom de fichier extrait pour DocuSeal:', docusealFileName);
// Debug de la configuration DocuSeal
console.log('🔧 [DOCUSEAL] Configuration:', {
hasToken: !!ENV.DOCUSEAL_TOKEN,
tokenLength: ENV.DOCUSEAL_TOKEN.length,
tokenPreview: ENV.DOCUSEAL_TOKEN.substring(0, 10) + '...',
apiBase: ENV.DOCUSEAL_API_BASE,
apiBaseRaw: process.env.DOCUSEAL_API_BASE,
rawTokenLength: process.env.DOCUSEAL_TOKEN?.length
});
// Étape 3 : Création du template DocuSeal
const templateResponse = await axios.post(`${ENV.DOCUSEAL_API_BASE}/templates/pdf`, {
name: `CDDU - ${reference}`,
documents: [{ name: docusealFileName, file: pdfBase64 }]
}, {
headers: {
'X-Auth-Token': ENV.DOCUSEAL_TOKEN,
'Content-Type': 'application/json'
}
});
const templateId = templateResponse.data.id;
console.log('Template DocuSeal créé:', templateResponse.data);
// Étape 4 : Création de la soumission DocuSeal
console.log('🔧 [DOCUSEAL] Création de la soumission avec:', {
templateId,
hasEmployerSignature: !!employerSignatureB64,
signaturePreview: employerSignatureB64 ? employerSignatureB64.substring(0, 50) + '...' : 'N/A'
});
const submissionPayload = {
template_id: templateId,
send_email: false,
submitters: [
{
role: 'Employeur',
email: validatedEmployerEmail,
// Pré-remplir la signature de l'employeur si disponible
...(employerSignatureB64 && {
values: {
signature: employerSignatureB64
}
})
},
{
role: 'Salarié',
email: employeeEmail
}
]
};
console.log('📤 [DOCUSEAL] Payload complet:', JSON.stringify(submissionPayload, null, 2));
const submissionResponse = await axios.post(`${ENV.DOCUSEAL_API_BASE}/submissions`, submissionPayload, {
headers: {
'X-Auth-Token': ENV.DOCUSEAL_TOKEN,
'Content-Type': 'application/json'
}
});
console.log('Soumission DocuSeal créée:', submissionResponse.data);
if (employerSignatureB64) {
console.log('✅ [SIGNATURE] Signature employeur pré-remplie');
}
// Récupérer le submission_id DocuSeal
const docusealSubmissionId = submissionResponse.data[0].submission_id;
console.log(`DocuSeal submission_id: ${docusealSubmissionId}`);
// Mettre à jour DynamoDB avec le docusealSubID
console.log('🗄️ [DYNAMODB] Mise à jour avec docusealSubID:', docusealSubmissionId);
try {
await updateContractWithDocusealId(submissionId, docusealSubmissionId);
console.log('✅ [DYNAMODB] Mise à jour réussie avec docusealSubID');
} catch (updateError) {
console.error('❌ [DYNAMODB] Erreur lors de la mise à jour:', updateError);
}
// Récupérer les informations pour le lien de signature
const employerSubmission = submissionResponse.data.find((sub: any) => sub.role === 'Employeur');
const employeeSubmission = submissionResponse.data.find((sub: any) => sub.role === 'Salarié');
const embedCode = employerSubmission.slug;
const employeeSlug = employeeSubmission?.slug || null;
// Construire l'URL propre sans paramètres (les data-* sont ajoutés sur le composant HTML)
const signatureLink = `https://staging.paie.odentas.fr/odentas-sign?docuseal_id=${embedCode}`;
console.log('🔗 [SIGNATURE] Lien généré:', signatureLink);
console.log('🔗 [SIGNATURE] Slug salarié:', employeeSlug);
if (employerSignatureB64) {
console.log('✅ [SIGNATURE] Signature B64 disponible pour pré-remplissage côté client');
}
// Étape 5 : Envoi de l'email de signature via le template universel (employeur)
// Seulement si skipEmployerEmail est false (signature individuelle)
let messageId = '';
let emailLink = '';
if (!skipEmployerEmail) {
const emailData: EmailDataV2 = {
firstName: signerFirstName,
organizationName: structure,
employerCode: employerCode,
employeeName: employeeName,
documentType: contractType || 'Contrat',
contractReference: reference,
status: 'En attente',
ctaUrl: signatureLink,
contractId: contractId, // Ajout pour traçabilité dans email_logs
};
// Rendu HTML pour archivage puis envoi
const rendered = await renderUniversalEmailV2({
type: 'signature-request-employer',
toEmail: validatedEmployerEmail,
subject: `Demande de signature ${reference}`,
data: emailData,
});
messageId = await sendUniversalEmailV2({
type: 'signature-request-employer',
toEmail: validatedEmployerEmail,
subject: `Demande de signature ${reference}`,
data: emailData,
});
// Mettre à jour le timestamp de dernière notification employeur
const now = new Date().toISOString();
const { error: updateError } = await supabase
.from('cddu_contracts')
.update({ last_employer_notification_at: now })
.eq('id', contractId);
if (updateError) {
console.warn("⚠️ [DOCUSEAL] Impossible de mettre à jour last_employer_notification_at:", updateError);
} else {
console.log("✅ [DOCUSEAL] Timestamp notification employeur enregistré:", now);
}
// Étape 6 : Upload de l'email HTML rendu sur S3 et logging
const emailHtml = rendered.html;
emailLink = await uploadEmailToS3(emailHtml, messageId);
console.log('Email HTML uploadé sur S3:', emailLink);
} else {
console.log('⏭️ [EMAIL] Envoi d\'email employeur ignoré (mode bulk)');
}
// Étape 7 : Mise à jour du contrat dans Supabase avec les infos DocuSeal
console.log('🗄️ [SUPABASE] Début de la mise à jour du contrat:', {
contractId,
templateId,
docusealSubmissionId,
signatureLink,
employeeSlug
});
const supabaseResult = await supabase
.from('cddu_contracts')
.update({
docuseal_template_id: templateId,
docuseal_submission_id: docusealSubmissionId,
employee_docuseal_slug: employeeSlug,
signature_status: 'En attente',
signature_link: signatureLink,
updated_at: new Date().toISOString()
})
.eq('id', contractId);
console.log('🗄️ [SUPABASE] Résultat de la mise à jour:', {
error: supabaseResult.error,
status: supabaseResult.status,
statusText: supabaseResult.statusText,
count: supabaseResult.count
});
if (supabaseResult.error) {
console.error('❌ [SUPABASE] Erreur lors de la mise à jour:', supabaseResult.error);
throw new Error(`Erreur Supabase: ${supabaseResult.error.message}`);
}
console.log('✅ [SUPABASE] Contrat mis à jour avec succès dans Supabase');
return NextResponse.json({
success: true,
message: 'Signature électronique initiée avec succès',
data: {
templateId,
submissionId: docusealSubmissionId,
signatureLink,
emailLink
}
});
} catch (error) {
console.error('Erreur lors de l\'initiation de la signature:', error);
return NextResponse.json(
{
error: 'Erreur lors de l\'initiation de la signature électronique',
details: error instanceof Error ? error.message : 'Erreur inconnue'
},
{ status: 500 }
);
}
}
// Fonction pour formater la date
function formatDate(dateStr: string): string {
try {
const dateObj = new Date(dateStr);
if (isNaN(dateObj.getTime())) {
console.error('Date invalide:', dateStr);
return dateStr;
}
const day = String(dateObj.getUTCDate()).padStart(2, '0');
const month = String(dateObj.getUTCMonth() + 1).padStart(2, '0');
const year = dateObj.getUTCFullYear();
return `${day}/${month}/${year}`;
} catch (error) {
console.error('Erreur lors du formatage de la date:', error);
return dateStr;
}
}
// Fonction pour stocker les données du contrat dans DynamoDB
async function storeContractData(submissionId: string, data: any) {
console.log('🗄️ [DYNAMODB] storeContractData - Données à sauvegarder:', {
submissionId,
dataKeys: Object.keys(data),
data
});
const command = new PutItemCommand({
TableName: 'DocuSealNotification',
Item: {
submission_id: { S: submissionId },
employerEmail: { S: data.employerEmail || '' },
employeeEmail: { S: data.employeeEmail || '' },
reference: { S: data.reference || '' },
salarie: { S: data.salarie || '' },
date: { S: data.date || '' },
poste: { S: data.poste || '' },
analytique: { S: data.analytique || '' },
structure: { S: data.structure || '' },
prenom_signataire: { S: data.prenom_signataire || '' },
code_employeur: { S: data.code_employeur || '' },
prenom_salarie: { S: data.prenom_salarie || '' },
matricule: { S: data.matricule || '' },
typecontrat: { S: data.typecontrat || '' }
}
});
console.log('🗄️ [DYNAMODB] Commande DynamoDB préparée:', {
TableName: command.input.TableName,
submissionId: command.input.Item?.submission_id
});
try {
const result = await dynamoDBClient.send(command);
console.log('✅ [DYNAMODB] PutItem réussi:', result);
return result;
} catch (error) {
console.error('❌ [DYNAMODB] Erreur détaillée dans storeContractData:', {
error,
errorName: error instanceof Error ? error.name : 'Unknown',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
errorCode: (error as any)?.code,
errorRequestId: (error as any)?.$metadata?.requestId
});
// Ne pas faire échouer tout le processus pour DynamoDB
console.log('⚠️ Continuing without DynamoDB storage...');
throw error; // Re-throw pour que le catch parent puisse logger
}
}
// Fonction pour mettre à jour l'enregistrement dans DynamoDB
async function updateContractWithDocusealId(submissionId: string, docusealSubID: string | number) {
console.log('🗄️ [DYNAMODB] updateContractWithDocusealId:', { submissionId, docusealSubID });
const command = new UpdateItemCommand({
TableName: 'DocuSealNotification',
Key: {
submission_id: { S: submissionId }
},
UpdateExpression: 'set docusealSubID = :docusealSubID',
ExpressionAttributeValues: {
':docusealSubID': { S: String(docusealSubID) } // Convertir en string
},
ReturnValues: 'UPDATED_NEW'
});
try {
const result = await dynamoDBClient.send(command);
console.log('✅ [DYNAMODB] UpdateItem réussi:', result);
return result;
} catch (error) {
console.error('❌ [DYNAMODB] Erreur détaillée dans updateContractWithDocusealId:', {
error,
errorName: error instanceof Error ? error.name : 'Unknown',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
errorCode: (error as any)?.code,
errorRequestId: (error as any)?.$metadata?.requestId
});
// Ne pas faire échouer tout le processus pour DynamoDB
console.log('⚠️ Continuing without DynamoDB update...');
throw error; // Re-throw pour le catch parent
}
}
// Fonction pour uploader l'email HTML sur S3
async function uploadEmailToS3(emailHtml: string, key: string): Promise<string> {
const command = new PutObjectCommand({
Bucket: ENV.S3_BUCKET_NAME_EMAILS,
Key: `${key}.html`,
Body: emailHtml,
ContentType: 'text/html',
ACL: 'public-read'
});
try {
await s3Client.send(command);
return `https://${ENV.S3_BUCKET_NAME_EMAILS}.s3.amazonaws.com/${key}.html`;
} catch (error) {
console.error('Erreur lors de l\'upload de l\'email sur S3:', error);
throw new Error('Échec de l\'upload de l\'email HTML sur S3.');
}
}
// Anciennes fonctions HTML/SES supprimées au profit du système universel