feat: Migration Cloudinary vers Poppler pour conversion PDF→JPEG
- Remplacer Cloudinary (US) par solution 100% AWS eu-west-3 - Lambda odentas-sign-pdf-converter avec pdftoppm - Lambda Layer poppler-utils v5 avec dépendances complètes - Trigger S3 ObjectCreated pour conversion automatique - Support multi-pages validé (PDF 3 pages) - Stockage images dans S3 odentas-docs - PDFImageViewer pour affichage images converties - Conformité RGPD garantie (données EU uniquement)
This commit is contained in:
parent
40ab28fdc7
commit
59749d481b
33 changed files with 4812 additions and 362 deletions
|
|
@ -38,3 +38,6 @@ DEBUG_UPSTREAM=0
|
|||
# Used by AWS Lambda to authenticate API calls to Espace Paie
|
||||
# Generate with: openssl rand -hex 32
|
||||
LAMBDA_API_KEY=your-lambda-api-key-64-chars-hex
|
||||
|
||||
# Lambda Functions URLs
|
||||
LAMBDA_PDF_TO_IMAGES_URL=https://your-lambda-url.lambda-url.eu-west-3.on.aws/
|
||||
|
|
|
|||
13
.env.lambda.example
Normal file
13
.env.lambda.example
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Configuration Lambda PDF to Images
|
||||
# =================================
|
||||
|
||||
# URL de la Lambda AWS pour la conversion PDF vers images
|
||||
# Format: https://xxx.execute-api.REGION.amazonaws.com/STAGE/convert
|
||||
# Exemple: https://abc123def.execute-api.us-east-1.amazonaws.com/prod/convert
|
||||
LAMBDA_PDF_TO_IMAGES_URL=
|
||||
|
||||
# Instructions de déploiement:
|
||||
# 1. Déployez la Lambda depuis le dossier lambda-pdf-to-images/
|
||||
# 2. Créez une API Gateway REST API pointant vers la Lambda
|
||||
# 3. Copiez l'URL de l'API Gateway ici
|
||||
# 4. Redémarrez l'application Next.js
|
||||
196
LAMBDA_DEPLOYMENT.md
Normal file
196
LAMBDA_DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
# Déploiement Lambda PDF to Images - Odentas Sign
|
||||
|
||||
## ✅ Déploiement Réussi
|
||||
|
||||
La Lambda de conversion PDF vers images a été déployée avec succès sur AWS.
|
||||
|
||||
### Informations de Déploiement
|
||||
|
||||
- **Nom de la fonction** : `odentas-pdf-to-images`
|
||||
- **Région AWS** : `eu-west-3` (Paris)
|
||||
- **Runtime** : Node.js 20.x
|
||||
- **Mémoire** : 2048 MB
|
||||
- **Timeout** : 300 secondes (5 minutes)
|
||||
- **Rôle IAM** : `odentas-seal-lambda-role`
|
||||
|
||||
### URL de la Lambda
|
||||
|
||||
```
|
||||
https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/
|
||||
```
|
||||
|
||||
Cette URL a été ajoutée automatiquement dans `.env.local` :
|
||||
```env
|
||||
LAMBDA_PDF_TO_IMAGES_URL=https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/
|
||||
```
|
||||
|
||||
### Configuration CORS
|
||||
|
||||
- **AllowOrigins** : `*` (tous les domaines)
|
||||
- **AllowMethods** : `POST`
|
||||
- **AllowHeaders** : `*`
|
||||
- **Auth** : NONE (pas d'authentification requise)
|
||||
|
||||
Cela permet de tester en local et en production sans restrictions.
|
||||
|
||||
## 🚧 Prochaines Étapes
|
||||
|
||||
### 1. Ajouter le Layer Poppler
|
||||
|
||||
⚠️ **Important** : La Lambda nécessite un Layer contenant `poppler-utils` et `ImageMagick`.
|
||||
|
||||
Pour créer et déployer le layer :
|
||||
|
||||
```bash
|
||||
cd lambda-pdf-to-images
|
||||
./create-layer.sh # Nécessite Docker
|
||||
|
||||
# Puis déployer le layer
|
||||
aws lambda publish-layer-version \
|
||||
--layer-name poppler-imagemagick \
|
||||
--description "Poppler Utils et ImageMagick" \
|
||||
--zip-file fileb://poppler-layer.zip \
|
||||
--compatible-runtimes nodejs20.x \
|
||||
--compatible-architectures x86_64 \
|
||||
--region eu-west-3
|
||||
```
|
||||
|
||||
Ou utiliser un layer public existant :
|
||||
```bash
|
||||
aws lambda update-function-configuration \
|
||||
--function-name odentas-pdf-to-images \
|
||||
--layers arn:aws:lambda:eu-west-3:ACCOUNT:layer:poppler-imagemagick:1 \
|
||||
--region eu-west-3
|
||||
```
|
||||
|
||||
### 2. Tester la Lambda
|
||||
|
||||
Test avec curl :
|
||||
|
||||
```bash
|
||||
curl -X POST https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"pdfUrl": "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf",
|
||||
"scale": 1.5
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. Redémarrer l'application Next.js
|
||||
|
||||
```bash
|
||||
# L'URL Lambda a été ajoutée dans .env.local
|
||||
# Redémarrer le serveur de développement
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 4. Déployer sur Vercel
|
||||
|
||||
Ajouter la variable d'environnement sur Vercel :
|
||||
|
||||
1. Aller dans Settings > Environment Variables
|
||||
2. Ajouter :
|
||||
- **Nom** : `LAMBDA_PDF_TO_IMAGES_URL`
|
||||
- **Valeur** : `https://o4nfddsoi44rrhcrl3zlfeiury0uyasw.lambda-url.eu-west-3.on.aws/`
|
||||
- **Environment** : Production, Preview, Development
|
||||
|
||||
3. Redéployer l'application
|
||||
|
||||
## 🔄 Mise à Jour de la Lambda
|
||||
|
||||
Pour mettre à jour le code de la Lambda :
|
||||
|
||||
```bash
|
||||
cd lambda-pdf-to-images
|
||||
npm install
|
||||
zip -r lambda-pdf-to-images.zip index.js package.json node_modules/
|
||||
|
||||
aws lambda update-function-code \
|
||||
--function-name odentas-pdf-to-images \
|
||||
--zip-file fileb://lambda-pdf-to-images.zip \
|
||||
--region eu-west-3
|
||||
```
|
||||
|
||||
## 📊 Monitoring
|
||||
|
||||
Voir les logs CloudWatch :
|
||||
|
||||
```bash
|
||||
aws logs tail /aws/lambda/odentas-pdf-to-images --follow --region eu-west-3
|
||||
```
|
||||
|
||||
Voir les métriques :
|
||||
|
||||
```bash
|
||||
aws cloudwatch get-metric-statistics \
|
||||
--namespace AWS/Lambda \
|
||||
--metric-name Invocations \
|
||||
--dimensions Name=FunctionName,Value=odentas-pdf-to-images \
|
||||
--start-time 2025-10-27T00:00:00Z \
|
||||
--end-time 2025-10-27T23:59:59Z \
|
||||
--period 3600 \
|
||||
--statistics Sum \
|
||||
--region eu-west-3
|
||||
```
|
||||
|
||||
## 🔒 Sécurité
|
||||
|
||||
### Recommandations de Production
|
||||
|
||||
1. **Ajouter une authentification** : Utiliser une API Key ou JWT
|
||||
2. **Limiter les origines CORS** : Remplacer `*` par les domaines autorisés
|
||||
3. **Activer AWS WAF** : Protection contre les attaques DDoS
|
||||
4. **Configurer des alertes CloudWatch** : Pour les erreurs et la latence
|
||||
5. **Limiter la taille des PDFs** : Actuellement 50MB max
|
||||
|
||||
Pour activer l'authentification :
|
||||
|
||||
```bash
|
||||
aws lambda update-function-url-config \
|
||||
--function-name odentas-pdf-to-images \
|
||||
--auth-type AWS_IAM \
|
||||
--region eu-west-3
|
||||
```
|
||||
|
||||
## 💰 Coûts Estimés
|
||||
|
||||
Avec les paramètres actuels (2048MB, 300s timeout) :
|
||||
|
||||
- **Prix par invocation** : ~0.0000166667 USD par seconde
|
||||
- **Exemple** : Si conversion PDF prend 10 secondes → ~0.17 USD par invocation
|
||||
- **1000 conversions/mois** → ~170 USD/mois
|
||||
|
||||
Pour réduire les coûts :
|
||||
- Réduire la mémoire si possible (test avec 1024MB)
|
||||
- Mettre en cache les résultats dans S3
|
||||
- Utiliser CloudFront pour le CDN
|
||||
|
||||
## 🆘 Dépannage
|
||||
|
||||
### La Lambda retourne une erreur 500
|
||||
|
||||
Vérifier les logs :
|
||||
```bash
|
||||
aws logs tail /aws/lambda/odentas-pdf-to-images --follow --region eu-west-3
|
||||
```
|
||||
|
||||
### Erreur "pdftoppm: command not found"
|
||||
|
||||
Le layer poppler n'est pas installé. Voir section "Ajouter le Layer Poppler".
|
||||
|
||||
### Timeout après 300 secondes
|
||||
|
||||
Le PDF est trop volumineux. Augmenter le timeout ou la mémoire :
|
||||
```bash
|
||||
aws lambda update-function-configuration \
|
||||
--function-name odentas-pdf-to-images \
|
||||
--timeout 600 \
|
||||
--memory-size 3008 \
|
||||
--region eu-west-3
|
||||
```
|
||||
|
||||
## 📝 Support
|
||||
|
||||
- Code source : `/lambda-pdf-to-images/`
|
||||
- Documentation : `/lambda-pdf-to-images/README.md`
|
||||
- Équipe : Odentas Tech
|
||||
180
app/api/odentas-sign/requests/[id]/pdf-to-images/route.ts
Normal file
180
app/api/odentas-sign/requests/[id]/pdf-to-images/route.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { GetObjectCommand, HeadObjectCommand, S3Client } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import { supabaseAdmin } from '@/lib/odentas-sign/supabase';
|
||||
import { verifySignatureSession } from '@/lib/odentas-sign/jwt';
|
||||
|
||||
// Client S3
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || 'eu-west-3',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
// Bucket où sont stockées les images converties
|
||||
const IMAGES_BUCKET = process.env.AWS_S3_BUCKET || 'odentas-docs';
|
||||
|
||||
interface PageImage {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les images JPEG pré-converties depuis S3
|
||||
* (converties automatiquement par la Lambda lors de l'upload du PDF)
|
||||
*/
|
||||
async function getPreconvertedImagesFromS3(requestId: string): Promise<PageImage[]> {
|
||||
const pageImages: PageImage[] = [];
|
||||
let pageNum = 1;
|
||||
|
||||
while (true) {
|
||||
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
|
||||
|
||||
try {
|
||||
// Vérifier si la page existe
|
||||
await s3Client.send(
|
||||
new HeadObjectCommand({
|
||||
Bucket: IMAGES_BUCKET,
|
||||
Key: s3Key,
|
||||
})
|
||||
);
|
||||
|
||||
// Générer l'URL presignée (valide 24h)
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: IMAGES_BUCKET,
|
||||
Key: s3Key,
|
||||
});
|
||||
const s3Url = await getSignedUrl(s3Client, command, { expiresIn: 86400 });
|
||||
|
||||
pageImages.push({
|
||||
pageNumber: pageNum,
|
||||
imageUrl: s3Url,
|
||||
width: 1400,
|
||||
height: Math.round(1400 * 1.414), // Ratio A4
|
||||
});
|
||||
|
||||
pageNum++;
|
||||
} catch (error) {
|
||||
// Plus de pages, on sort de la boucle
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pageImages;
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
console.log('[PDF to Images API] Nouvelle requête', { requestId: params.id });
|
||||
|
||||
// Verify authorization header
|
||||
const authHeader = request.headers.get('authorization');
|
||||
console.log('[PDF to Images API] Auth header:', {
|
||||
present: !!authHeader,
|
||||
startsWithBearer: authHeader?.startsWith('Bearer '),
|
||||
length: authHeader?.length,
|
||||
});
|
||||
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
console.error('[PDF to Images API] Header Authorization manquant ou invalide');
|
||||
return NextResponse.json({ error: 'Non autorisé' }, { status: 401 });
|
||||
}
|
||||
|
||||
const sessionToken = authHeader.substring(7);
|
||||
console.log('[PDF to Images API] Token extrait:', {
|
||||
length: sessionToken.length,
|
||||
preview: sessionToken.substring(0, 20) + '...',
|
||||
});
|
||||
|
||||
// Vérifier et décoder le JWT
|
||||
const session = verifySignatureSession(sessionToken);
|
||||
console.log('[PDF to Images API] Session vérifiée:', {
|
||||
valid: !!session,
|
||||
signerId: session?.signerId,
|
||||
requestId: session?.requestId,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
console.error('[PDF to Images API] Session JWT invalide');
|
||||
return NextResponse.json({ error: 'Session invalide' }, { status: 401 });
|
||||
}
|
||||
|
||||
// Verify request ID matches
|
||||
if (session.requestId !== params.id) {
|
||||
console.error('[PDF to Images API] RequestId mismatch:', {
|
||||
sessionRequestId: session.requestId,
|
||||
paramsId: params.id,
|
||||
});
|
||||
return NextResponse.json({ error: 'Accès refusé' }, { status: 403 });
|
||||
}
|
||||
|
||||
console.log('[PDF to Images API] Requête Supabase pour requestId:', params.id);
|
||||
|
||||
// Get signature request (on n'a plus besoin du PDF, juste vérifier que la demande existe)
|
||||
const { data: requestData, error: requestError } = await supabaseAdmin
|
||||
.from('sign_requests')
|
||||
.select('id')
|
||||
.eq('id', params.id)
|
||||
.single();
|
||||
|
||||
console.log('[PDF to Images API] Résultat Supabase:', {
|
||||
hasData: !!requestData,
|
||||
hasError: !!requestError,
|
||||
error: requestError,
|
||||
});
|
||||
|
||||
if (requestError || !requestData) {
|
||||
console.error('[PDF to Images API] Demande non trouvée dans Supabase:', {
|
||||
requestId: params.id,
|
||||
error: requestError,
|
||||
});
|
||||
return NextResponse.json({ error: 'Demande non trouvée' }, { status: 404 });
|
||||
}
|
||||
|
||||
console.log('[PDF to Images API] Récupération des images pré-converties depuis S3...');
|
||||
|
||||
// Récupérer les images JPEG pré-converties depuis S3
|
||||
// (converties automatiquement par la Lambda lors de l'upload du PDF)
|
||||
const pages = await getPreconvertedImagesFromS3(params.id);
|
||||
|
||||
if (pages.length === 0) {
|
||||
console.error('[PDF to Images API] Aucune image trouvée dans S3');
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Images non disponibles',
|
||||
details: 'Les images du PDF n\'ont pas encore été converties. Veuillez réessayer dans quelques instants.',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`[PDF to Images API] ✅ ${pages.length} page(s) récupérées depuis S3`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
pages: pages.map((page: PageImage) => ({
|
||||
pageNumber: page.pageNumber,
|
||||
imageUrl: page.imageUrl,
|
||||
width: page.width,
|
||||
height: page.height,
|
||||
})),
|
||||
totalPages: pages.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[PDF to Images API] Erreur:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Erreur lors de la conversion PDF',
|
||||
details: error instanceof Error ? error.message : 'Erreur inconnue',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { verifySignatureSession } from '@/lib/odentas-sign/jwt';
|
||||
import { supabaseAdmin } from '@/lib/odentas-sign/supabase';
|
||||
import { getPresignedDownloadUrl } from '@/lib/odentas-sign/s3';
|
||||
import {
|
||||
extractPlaceholdersFromPdfBuffer,
|
||||
countPdfPagesFromBytes,
|
||||
estimatePositionsFromPlaceholders,
|
||||
} from '@/lib/odentas-sign/placeholders';
|
||||
|
||||
/**
|
||||
* GET /api/odentas-sign/requests/:id/positions
|
||||
|
|
@ -32,7 +38,7 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
|
||||
// Récupérer toutes les positions de signature
|
||||
// Récupérer toutes les positions de signature existantes
|
||||
const { data: positions, error } = await supabaseAdmin
|
||||
.from('sign_positions')
|
||||
.select('page, x, y, w, h, role')
|
||||
|
|
@ -47,19 +53,82 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
|
||||
// Transformer pour correspondre à l'interface frontend
|
||||
const transformedPositions = positions.map(p => ({
|
||||
// Si positions déjà présentes, renvoyer directement
|
||||
if (positions && positions.length > 0) {
|
||||
const transformedPositions = positions.map((p) => ({
|
||||
page: p.page,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: p.w,
|
||||
height: p.h,
|
||||
role: p.role,
|
||||
}));
|
||||
return NextResponse.json({ positions: transformedPositions });
|
||||
}
|
||||
|
||||
// Pas de positions en DB: tenter d'extraire depuis le PDF via placeholders
|
||||
// 1) Récupérer la clé S3 du PDF source
|
||||
const { data: signRequest, error: requestErr } = await supabaseAdmin
|
||||
.from('sign_requests')
|
||||
.select('source_s3_key')
|
||||
.eq('id', requestId)
|
||||
.single();
|
||||
|
||||
if (requestErr || !signRequest?.source_s3_key) {
|
||||
console.error('Impossible de récupérer sign_request pour extraction:', requestErr);
|
||||
return NextResponse.json({ positions: [] });
|
||||
}
|
||||
|
||||
// 2) Générer une URL présignée et télécharger le PDF
|
||||
const pdfUrl = await getPresignedDownloadUrl(signRequest.source_s3_key, 300);
|
||||
const resp = await fetch(pdfUrl);
|
||||
if (!resp.ok) {
|
||||
console.error('Téléchargement du PDF échoué:', resp.status, pdfUrl);
|
||||
return NextResponse.json({ positions: [] });
|
||||
}
|
||||
const arrayBuf = await resp.arrayBuffer();
|
||||
const bytes = Buffer.from(arrayBuf);
|
||||
|
||||
// 3) Fallback regex + estimation (pas d'extraction pdfjs côté serveur)
|
||||
const placeholders = extractPlaceholdersFromPdfBuffer(bytes);
|
||||
if (!placeholders || placeholders.length === 0) {
|
||||
return NextResponse.json({ positions: [] });
|
||||
}
|
||||
const pageCount = countPdfPagesFromBytes(bytes);
|
||||
const precise = estimatePositionsFromPlaceholders(placeholders, pageCount);
|
||||
|
||||
// 5) Persister en DB pour cette demande (meilleure UX aux prochains chargements)
|
||||
try {
|
||||
const rows = precise.map((pos) => ({
|
||||
request_id: requestId,
|
||||
role: pos.role,
|
||||
page: pos.page,
|
||||
x: pos.x, // mm
|
||||
y: pos.y, // mm
|
||||
w: pos.width, // mm
|
||||
h: pos.height, // mm
|
||||
kind: 'signature',
|
||||
label: pos.label,
|
||||
}));
|
||||
const { error: insertErr } = await supabaseAdmin.from('sign_positions').insert(rows);
|
||||
if (insertErr) {
|
||||
console.warn('Insertion positions estimées échouée (non bloquant):', insertErr);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Erreur lors de la persistance des positions estimées:', e);
|
||||
}
|
||||
|
||||
// 6) Retourner au format attendu par le front
|
||||
const transformedPositions = precise.map((p) => ({
|
||||
page: p.page,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
width: p.w,
|
||||
height: p.h,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
role: p.role,
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
positions: transformedPositions,
|
||||
});
|
||||
return NextResponse.json({ positions: transformedPositions });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la récupération des positions:', error);
|
||||
|
|
@ -69,3 +138,73 @@ export async function GET(
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/odentas-sign/requests/:id/positions
|
||||
* Upsert des positions détectées côté client (mm)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const requestId = params.id;
|
||||
|
||||
// Vérifier le token JWT
|
||||
const authHeader = request.headers.get('Authorization');
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token manquant ou invalide' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
const token = authHeader.split(' ')[1];
|
||||
const payload = verifySignatureSession(token);
|
||||
if (!payload || payload.requestId !== requestId) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Token invalide ou expiré' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const positions = Array.isArray(body?.positions) ? body.positions : [];
|
||||
if (positions.length === 0) {
|
||||
return NextResponse.json({ success: false, message: 'Aucune position fournie' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Nettoyer les positions existantes de type signature pour cette requête
|
||||
await supabaseAdmin
|
||||
.from('sign_positions')
|
||||
.delete()
|
||||
.eq('request_id', requestId)
|
||||
.eq('kind', 'signature');
|
||||
|
||||
// Insérer les nouvelles positions (mm)
|
||||
const rows = positions.map((p: any) => ({
|
||||
request_id: requestId,
|
||||
role: p.role,
|
||||
page: p.page,
|
||||
x: p.x,
|
||||
y: p.y,
|
||||
w: p.w,
|
||||
h: p.h,
|
||||
kind: p.kind || 'signature',
|
||||
label: p.label || null,
|
||||
}));
|
||||
|
||||
const { error: insertErr } = await supabaseAdmin
|
||||
.from('sign_positions')
|
||||
.insert(rows);
|
||||
|
||||
if (insertErr) {
|
||||
console.error('Erreur insertion positions (POST):', insertErr);
|
||||
return NextResponse.json({ error: 'Insertion des positions échouée' }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true, inserted: rows.length });
|
||||
} catch (error) {
|
||||
console.error('Erreur POST positions:', error);
|
||||
return NextResponse.json({ error: 'Erreur serveur' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
app/api/pdf-clean/route.ts
Normal file
65
app/api/pdf-clean/route.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const pdfUrl = searchParams.get('url');
|
||||
const requestId = searchParams.get('requestId');
|
||||
|
||||
if (!pdfUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL du PDF requise' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Récupérer le PDF depuis S3
|
||||
const response = await fetch(decodeURIComponent(pdfUrl));
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Erreur S3: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const pdfBytes = await response.arrayBuffer();
|
||||
|
||||
try {
|
||||
// Charger le PDF avec pdf-lib
|
||||
const pdfDoc = await PDFDocument.load(pdfBytes);
|
||||
const pages = pdfDoc.getPages();
|
||||
|
||||
// Parcourir chaque page et extraire les annotations
|
||||
// Note: pdf-lib ne peut pas modifier directement le texte rendu
|
||||
// On va donc simplement retourner le PDF tel quel
|
||||
// car les placeholders seront masqués par les overlays de signature
|
||||
|
||||
const modifiedPdfBytes = await pdfDoc.save();
|
||||
|
||||
return new NextResponse(Buffer.from(modifiedPdfBytes), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur parsing PDF avec pdf-lib:', error);
|
||||
// En cas d'erreur, retourner le PDF original
|
||||
return new NextResponse(Buffer.from(pdfBytes), {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur nettoyage PDF:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur serveur' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
45
app/api/pdf-proxy/route.ts
Normal file
45
app/api/pdf-proxy/route.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const pdfUrl = searchParams.get('url');
|
||||
|
||||
if (!pdfUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: 'URL du PDF requise' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Décoder l'URL si elle est encodée
|
||||
const decodedUrl = decodeURIComponent(pdfUrl);
|
||||
|
||||
// Fetcher le PDF depuis S3
|
||||
const response = await fetch(decodedUrl);
|
||||
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: `Erreur S3: ${response.status}` },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
// Retourner le PDF avec les headers CORS appropriés
|
||||
return new NextResponse(buffer, {
|
||||
headers: {
|
||||
'Content-Type': 'application/pdf',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Erreur proxy PDF:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Erreur serveur' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
171
app/signer/[requestId]/[signerId]/components/PDFImageViewer.tsx
Normal file
171
app/signer/[requestId]/[signerId]/components/PDFImageViewer.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Loader2, AlertCircle } from 'lucide-react';
|
||||
|
||||
interface SignPosition {
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface PDFImageViewerProps {
|
||||
pdfUrl: string;
|
||||
positions: SignPosition[];
|
||||
currentSignerRole: string;
|
||||
requestId: string;
|
||||
sessionToken: string;
|
||||
}
|
||||
|
||||
interface PageImage {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function PDFImageViewer({
|
||||
pdfUrl,
|
||||
positions,
|
||||
currentSignerRole,
|
||||
requestId,
|
||||
sessionToken,
|
||||
}: PDFImageViewerProps) {
|
||||
const [pageImages, setPageImages] = useState<PageImage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function convertPdfToImages() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
console.log('[PDFImageViewer] Début conversion', {
|
||||
requestId,
|
||||
pdfUrl,
|
||||
hasSessionToken: !!sessionToken,
|
||||
sessionTokenLength: sessionToken?.length,
|
||||
sessionTokenPreview: sessionToken?.substring(0, 20) + '...',
|
||||
});
|
||||
|
||||
// Appel API pour convertir le PDF en images
|
||||
const response = await fetch(`/api/odentas-sign/requests/${requestId}/pdf-to-images`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${sessionToken}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pdfUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
console.error('[PDFImageViewer] Erreur response:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
data,
|
||||
});
|
||||
throw new Error(data.error || 'Erreur lors de la conversion du PDF');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setPageImages(data.pages || []);
|
||||
} catch (err) {
|
||||
console.error('[PDFImageViewer] Erreur:', err);
|
||||
setError(err instanceof Error ? err.message : 'Erreur de chargement');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (pdfUrl) {
|
||||
convertPdfToImages();
|
||||
}
|
||||
}, [pdfUrl, requestId, sessionToken]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full bg-slate-50 rounded-xl flex flex-col items-center justify-center">
|
||||
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
|
||||
<p className="text-slate-600 font-medium">Conversion du PDF en cours...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="h-full bg-red-50 rounded-xl flex flex-col items-center justify-center p-6">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mb-4" />
|
||||
<p className="text-red-700 font-medium mb-2">Erreur de chargement</p>
|
||||
<p className="text-red-600 text-sm text-center">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageImages.length === 0) {
|
||||
return (
|
||||
<div className="h-full bg-slate-50 rounded-xl flex flex-col items-center justify-center">
|
||||
<p className="text-slate-500">Aucune page à afficher</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto space-y-4">
|
||||
{pageImages.map((page) => {
|
||||
// Filtrer les positions de signature pour cette page et ce rôle
|
||||
const pagePositions = positions.filter(
|
||||
(pos) => pos.page === page.pageNumber && pos.role === currentSignerRole
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={page.pageNumber}
|
||||
className="relative bg-white border border-slate-200 rounded-lg overflow-hidden"
|
||||
style={{
|
||||
aspectRatio: `${page.width} / ${page.height}`,
|
||||
}}
|
||||
>
|
||||
{/* Image de la page PDF */}
|
||||
<img
|
||||
src={page.imageUrl}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
|
||||
{/* Zones de signature superposées */}
|
||||
{pagePositions.map((pos, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="absolute border-2 border-dashed border-indigo-500 bg-indigo-100/30 pointer-events-none"
|
||||
style={{
|
||||
left: `${pos.x * 100}%`,
|
||||
top: `${pos.y * 100}%`,
|
||||
width: `${pos.width * 100}%`,
|
||||
height: `${pos.height * 100}%`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-xs font-semibold text-indigo-700 bg-white/80 px-2 py-1 rounded">
|
||||
Signez ici
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Numéro de page */}
|
||||
<div className="absolute bottom-2 right-2 bg-slate-900/70 text-white text-xs px-2 py-1 rounded">
|
||||
Page {page.pageNumber}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Viewer, Worker } from '@react-pdf-viewer/core';
|
||||
import { defaultLayoutPlugin } from '@react-pdf-viewer/default-layout';
|
||||
|
||||
// Import des styles
|
||||
import '@react-pdf-viewer/core/lib/styles/index.css';
|
||||
import '@react-pdf-viewer/default-layout/lib/styles/index.css';
|
||||
|
||||
interface SignaturePosition {
|
||||
page: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface PDFViewerProps {
|
||||
pdfUrl: string;
|
||||
positions: SignaturePosition[];
|
||||
currentSignerRole: string;
|
||||
}
|
||||
|
||||
export default function PDFViewer({ pdfUrl, positions, currentSignerRole }: PDFViewerProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Plugin pour la mise en page par défaut
|
||||
const defaultLayoutPluginInstance = defaultLayoutPlugin();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full bg-gray-50 rounded-lg border border-gray-200">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Initialisation du viewer...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gray-50 rounded-lg overflow-hidden border border-gray-200">
|
||||
<Worker workerUrl={`https://unpkg.com/pdfjs-dist@3.11.174/build/pdf.worker.min.js`}>
|
||||
<div className="relative h-full">
|
||||
<Viewer
|
||||
fileUrl={pdfUrl}
|
||||
plugins={[defaultLayoutPluginInstance]}
|
||||
defaultScale={1.2}
|
||||
/>
|
||||
|
||||
{/* Overlay custom pour les zones de signature */}
|
||||
<div className="absolute top-16 right-4 bg-white/95 backdrop-blur-sm p-3 rounded-lg shadow-lg border border-gray-200">
|
||||
<h4 className="text-xs font-semibold text-gray-700 mb-2">Zones de signature</h4>
|
||||
<div className="space-y-2">
|
||||
{positions.map((pos, index) => {
|
||||
const isCurrentSigner = pos.role === currentSignerRole;
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center gap-2 text-xs ${
|
||||
isCurrentSigner ? 'text-blue-600 font-semibold' : 'text-gray-600'
|
||||
}`}
|
||||
>
|
||||
{isCurrentSigner ? '✍️' : '📝'}
|
||||
<span>Page {pos.page}: {pos.role}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{positions.length === 0 && (
|
||||
<p className="text-xs text-gray-500 italic">Aucune zone définie</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Worker>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,6 +3,18 @@
|
|||
import { useState, useRef, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { PenTool, RotateCcw, Check, Loader2, AlertCircle, FileText, Info } from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
// Charger le PDFImageViewer côté client uniquement (conversion PDF vers images comme Docuseal)
|
||||
const PDFImageViewer = dynamic(() => import('./PDFImageViewer'), {
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
|
||||
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
|
||||
<p className="text-slate-600 font-medium">Chargement du visualiseur...</p>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
|
||||
interface SignatureCaptureProps {
|
||||
signerId: string;
|
||||
|
|
@ -44,20 +56,6 @@ export default function SignatureCapture({
|
|||
const [pdfUrl, setPdfUrl] = useState<string | null>(null);
|
||||
const [signaturePositions, setSignaturePositions] = useState<SignPosition[]>([]);
|
||||
const [isPdfLoading, setIsPdfLoading] = useState(true);
|
||||
const [PDFViewerComponent, setPDFViewerComponent] = useState<any>(null);
|
||||
|
||||
// Load PDF Viewer component (client-side only)
|
||||
useEffect(() => {
|
||||
async function loadPDFViewer() {
|
||||
try {
|
||||
const { default: PDFViewer } = await import('./PDFViewer');
|
||||
setPDFViewerComponent(() => PDFViewer);
|
||||
} catch (err) {
|
||||
console.error('[PDF] Erreur chargement viewer:', err);
|
||||
}
|
||||
}
|
||||
loadPDFViewer();
|
||||
}, []);
|
||||
|
||||
// Load PDF and signature positions
|
||||
useEffect(() => {
|
||||
|
|
@ -242,170 +240,188 @@ export default function SignatureCapture({
|
|||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="max-w-3xl mx-auto"
|
||||
className="w-full min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 py-8 px-4"
|
||||
>
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-8 text-white">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">Signature du document</h2>
|
||||
<p className="text-indigo-100">{documentTitle}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-indigo-200 uppercase tracking-wider mb-1">Signataire</p>
|
||||
<p className="font-semibold">{signerName}</p>
|
||||
<p className="text-sm text-indigo-100">{signerRole}</p>
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden mb-6">
|
||||
<div className="bg-gradient-to-r from-indigo-600 to-purple-600 px-8 py-6 text-white">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Signature du document</h1>
|
||||
<p className="text-indigo-100 text-lg">{documentTitle}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-indigo-200 uppercase tracking-wider mb-1">Signataire</p>
|
||||
<p className="font-semibold text-lg">{signerName}</p>
|
||||
<p className="text-sm text-indigo-100">{signerRole}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-8">
|
||||
{/* PDF Viewer */}
|
||||
{isPdfLoading ? (
|
||||
<div className="mb-8 bg-slate-50 rounded-xl p-12 flex flex-col items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 text-indigo-600 animate-spin mb-3" />
|
||||
<p className="text-slate-600">Chargement du document...</p>
|
||||
</div>
|
||||
) : pdfUrl && PDFViewerComponent ? (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-indigo-600" />
|
||||
Aperçu du document
|
||||
</h3>
|
||||
<div className="h-[600px]">
|
||||
<PDFViewerComponent
|
||||
pdfUrl={pdfUrl}
|
||||
positions={signaturePositions}
|
||||
currentSignerRole={signerRole}
|
||||
/>
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Left: PDF Viewer */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden h-full">
|
||||
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-indigo-600" />
|
||||
Document à signer
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{isPdfLoading ? (
|
||||
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
|
||||
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin mb-4" />
|
||||
<p className="text-slate-600 font-medium">Chargement du document...</p>
|
||||
</div>
|
||||
) : pdfUrl ? (
|
||||
<div className="h-[700px]">
|
||||
<PDFImageViewer
|
||||
pdfUrl={pdfUrl}
|
||||
positions={signaturePositions}
|
||||
currentSignerRole={signerRole}
|
||||
requestId={requestId}
|
||||
sessionToken={sessionToken}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[700px] bg-slate-50 rounded-xl flex flex-col items-center justify-center">
|
||||
<FileText className="w-16 h-16 text-slate-300 mb-4" />
|
||||
<p className="text-slate-500">Aucun document à afficher</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Info notice */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-8 flex gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-900">
|
||||
<p className="font-medium mb-1">Dessinez votre signature</p>
|
||||
<p className="text-blue-700">
|
||||
Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous. Vous pouvez recommencer à tout moment.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signature canvas */}
|
||||
<div className="mb-6">
|
||||
<div className="border-2 border-dashed border-slate-300 rounded-xl overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onTouchStart={startDrawing}
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={stopDrawing}
|
||||
className="w-full h-64 cursor-crosshair touch-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
|
||||
{!hasDrawn && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<PenTool className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||
<p className="text-slate-500 text-sm">Signez ici</p>
|
||||
{/* Right: Signature panel */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="bg-white rounded-2xl shadow-xl overflow-hidden sticky top-8">
|
||||
<div className="px-6 py-4 border-b border-slate-200 bg-slate-50">
|
||||
<h2 className="text-lg font-semibold text-slate-900 flex items-center gap-2">
|
||||
<PenTool className="w-5 h-5 text-indigo-600" />
|
||||
Votre signature
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{/* Info notice */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-6 flex gap-3">
|
||||
<Info className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-900">
|
||||
<p className="font-medium mb-1">Dessinez votre signature</p>
|
||||
<p className="text-blue-700">
|
||||
Utilisez votre souris, trackpad ou doigt pour signer dans le cadre ci-dessous.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Signature canvas */}
|
||||
<div className="mb-6">
|
||||
<div className="border-2 border-dashed border-slate-300 rounded-xl overflow-hidden bg-white relative group hover:border-indigo-400 transition-colors">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={startDrawing}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={stopDrawing}
|
||||
onMouseLeave={stopDrawing}
|
||||
onTouchStart={startDrawing}
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={stopDrawing}
|
||||
className="w-full h-48 cursor-crosshair touch-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
|
||||
{!hasDrawn && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center">
|
||||
<PenTool className="w-8 h-8 text-slate-400 mx-auto mb-2" />
|
||||
<p className="text-slate-500 text-sm">Signez ici</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clear button */}
|
||||
{hasDrawn && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={clearSignature}
|
||||
disabled={isSubmitting}
|
||||
className="mt-3 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Recommencer
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Consent checkbox */}
|
||||
<div className="bg-slate-50 rounded-xl p-5 mb-6">
|
||||
<label className="flex items-start gap-3 cursor-pointer group">
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consentChecked}
|
||||
onChange={(e) => setConsentChecked(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 leading-relaxed">
|
||||
<p>
|
||||
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Signature horodatée et archivée de manière sécurisée pendant 10 ans (eIDAS).
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={submitSignature}
|
||||
disabled={!hasDrawn || !consentChecked || isSubmitting}
|
||||
className="w-full px-6 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg flex items-center justify-center gap-2 text-lg"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Signature en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-5 h-5" />
|
||||
Valider ma signature
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Help text */}
|
||||
<p className="mt-4 text-center text-xs text-slate-500">
|
||||
En validant, vous acceptez que votre signature soit juridiquement contraignante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear button */}
|
||||
{hasDrawn && (
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
onClick={clearSignature}
|
||||
disabled={isSubmitting}
|
||||
className="mt-4 px-4 py-2 text-sm text-slate-700 hover:text-slate-900 font-medium flex items-center gap-2 hover:bg-slate-100 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
Recommencer
|
||||
</motion.button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Consent checkbox */}
|
||||
<div className="bg-slate-50 rounded-xl p-6 mb-6">
|
||||
<label className="flex items-start gap-4 cursor-pointer group">
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consentChecked}
|
||||
onChange={(e) => setConsentChecked(e.target.checked)}
|
||||
disabled={isSubmitting}
|
||||
className="w-5 h-5 text-indigo-600 border-slate-300 rounded focus:ring-indigo-500 focus:ring-2 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-700 leading-relaxed">
|
||||
<p>
|
||||
Je consens à signer électroniquement le document <span className="font-semibold">"{documentTitle}"</span> et confirme que cette signature a la même valeur juridique qu'une signature manuscrite.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
Votre signature sera horodatée, scellée et archivée de manière sécurisée pendant 10 ans conformément à la réglementation eIDAS.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-800">{error}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Submit button */}
|
||||
<button
|
||||
onClick={submitSignature}
|
||||
disabled={!hasDrawn || !consentChecked || isSubmitting}
|
||||
className="w-full px-8 py-4 bg-indigo-600 text-white rounded-xl font-semibold hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 hover:shadow-lg flex items-center justify-center gap-2"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="w-5 h-5 animate-spin" />
|
||||
Signature en cours...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-5 h-5" />
|
||||
Valider ma signature
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Help text */}
|
||||
<p className="mt-4 text-center text-sm text-slate-500">
|
||||
En validant, vous acceptez que votre signature soit juridiquement contraignante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document preview (placeholder for future PDF viewer) */}
|
||||
<div className="mt-8 bg-white rounded-2xl shadow-xl p-8">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<FileText className="w-6 h-6 text-slate-600" />
|
||||
<h3 className="text-lg font-semibold text-slate-900">Aperçu du document</h3>
|
||||
</div>
|
||||
<div className="bg-slate-100 rounded-xl p-8 text-center">
|
||||
<p className="text-slate-600">Le visualiseur de PDF sera intégré prochainement</p>
|
||||
<p className="text-sm text-slate-500 mt-2">Référence: {requestId.slice(0, 8)}...</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
|
|
|||
127
extract-placeholders.js
Normal file
127
extract-placeholders.js
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script pour extraire les positions exactes des placeholders {{Signature...}}
|
||||
* depuis un PDF
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Regex pour matcher les placeholders de signature
|
||||
* Format: {{Label;role=Role;type=type;height=H;width=W}}
|
||||
*/
|
||||
const PLACEHOLDER_REGEX = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g;
|
||||
|
||||
async function extractPlaceholders(pdfPath) {
|
||||
console.log(`\n📄 Analyse du PDF: ${pdfPath}\n`);
|
||||
|
||||
try {
|
||||
// Lire le PDF comme texte brut
|
||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
||||
const pdfText = pdfBuffer.toString('latin1');
|
||||
|
||||
console.log(`✅ PDF chargé\n`);
|
||||
|
||||
const placeholders = [];
|
||||
let pageNum = 1;
|
||||
|
||||
// Chercher les placeholders dans le texte
|
||||
let match;
|
||||
PLACEHOLDER_REGEX.lastIndex = 0; // Reset la regex
|
||||
|
||||
while ((match = PLACEHOLDER_REGEX.exec(pdfText)) !== null) {
|
||||
placeholders.push({
|
||||
page: pageNum, // On va essayer de déterminer la page
|
||||
label: match[1].trim(),
|
||||
role: match[2].trim(),
|
||||
type: match[3].trim(),
|
||||
height: parseInt(match[4]),
|
||||
width: parseInt(match[5]),
|
||||
fullMatch: match[0],
|
||||
});
|
||||
}
|
||||
|
||||
// Essayer de mieux déterminer les pages en cherchant les marqueurs de page
|
||||
const pageBreakRegex = /\x0c/g; // Form feed character
|
||||
let pageBreakMatch;
|
||||
let lastBreakIndex = 0;
|
||||
let pageBreaks = [0];
|
||||
|
||||
while ((pageBreakMatch = pageBreakRegex.exec(pdfText)) !== null) {
|
||||
pageBreaks.push(pageBreakMatch.index);
|
||||
}
|
||||
|
||||
// Associer les placeholders aux pages correctes
|
||||
placeholders.forEach(ph => {
|
||||
const placeholderPos = pdfText.indexOf(ph.fullMatch);
|
||||
if (placeholderPos !== -1) {
|
||||
// Trouver quelle page contient ce placeholder
|
||||
for (let i = pageBreaks.length - 1; i >= 0; i--) {
|
||||
if (placeholderPos >= pageBreaks[i]) {
|
||||
ph.page = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (placeholders.length === 0) {
|
||||
console.log('⚠️ Aucun placeholder trouvé!\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher les résultats
|
||||
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
||||
console.log('📍 PLACEHOLDERS DÉTECTÉS');
|
||||
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
||||
|
||||
placeholders.forEach((ph, idx) => {
|
||||
console.log(`${idx + 1}. ${ph.label}`);
|
||||
console.log(` Rôle: ${ph.role}`);
|
||||
console.log(` Page: ${ph.page}`);
|
||||
console.log(` Dimensions: ${ph.width} × ${ph.height} mm`);
|
||||
console.log(` Type: ${ph.type}\n`);
|
||||
});
|
||||
|
||||
// Générer le code pour test-odentas-sign.js
|
||||
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
|
||||
console.log('📋 CODE POUR test-odentas-sign.js');
|
||||
console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
|
||||
|
||||
console.log('positions: [');
|
||||
placeholders.forEach(ph => {
|
||||
console.log(` {`);
|
||||
console.log(` role: '${ph.role}',`);
|
||||
console.log(` page: ${ph.page},`);
|
||||
console.log(` x: 100, // À ajuster selon la position exacte`);
|
||||
console.log(` y: 680, // À ajuster selon la position exacte`);
|
||||
console.log(` w: ${ph.width},`);
|
||||
console.log(` h: ${ph.height},`);
|
||||
console.log(` kind: 'signature',`);
|
||||
console.log(` label: '${ph.label}',`);
|
||||
console.log(` },`);
|
||||
});
|
||||
console.log(']');
|
||||
|
||||
// Sauvegarder en JSON
|
||||
const outputPath = pdfPath.replace('.pdf', '-placeholders.json');
|
||||
fs.writeFileSync(outputPath, JSON.stringify(placeholders, null, 2));
|
||||
console.log(`\n💾 Résultats sauvegardés: ${outputPath}\n`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Point d'entrée
|
||||
const pdfPath = process.argv[2] || path.join(__dirname, 'test-contrat.pdf');
|
||||
|
||||
if (!fs.existsSync(pdfPath)) {
|
||||
console.error(`❌ Fichier non trouvé: ${pdfPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
extractPlaceholders(pdfPath).catch(console.error);
|
||||
213
find-placeholder-positions.js
Normal file
213
find-placeholder-positions.js
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script pour extraire les positions EXACTES des placeholders depuis un PDF
|
||||
* Utilise pdfjs-dist pour accéder au texte rendu et ses coordonnées
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
|
||||
// Placeholder patterns à chercher
|
||||
const PATTERNS = [
|
||||
/Signature Employeur/i,
|
||||
/Signature Employé/i,
|
||||
/Signature Salarié/i,
|
||||
/\{\{Signature[^}]*\}\}/g,
|
||||
];
|
||||
|
||||
/**
|
||||
* Télécharge un fichier depuis une URL
|
||||
*/
|
||||
function downloadFile(url, filepath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const protocol = url.startsWith('https') ? https : http;
|
||||
const file = fs.createWriteStream(filepath);
|
||||
|
||||
protocol.get(url, (response) => {
|
||||
response.pipe(file);
|
||||
file.on('finish', () => {
|
||||
file.close();
|
||||
resolve();
|
||||
});
|
||||
}).on('error', (err) => {
|
||||
fs.unlink(filepath, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait les positions des placeholders en utilisant une approche basée sur le texte brut
|
||||
*/
|
||||
async function extractPositions(pdfPath) {
|
||||
console.log('\n📄 Extraction des positions des placeholders\n');
|
||||
console.log(`Fichier: ${pdfPath}\n`);
|
||||
|
||||
try {
|
||||
// Lire le fichier PDF
|
||||
const pdfBuffer = fs.readFileSync(pdfPath);
|
||||
const pdfText = pdfBuffer.toString('binary');
|
||||
|
||||
// Chercher les pages (marqueurs de page dans le PDF)
|
||||
const pages = [];
|
||||
let currentPage = 1;
|
||||
let pageStart = 0;
|
||||
|
||||
// Chercher les marqueurs "endobj" qui marquent la fin des objets de page
|
||||
const pageMarkerRegex = /\/Type\s*\/Page(?!s)/g;
|
||||
let match;
|
||||
while ((match = pageMarkerRegex.exec(pdfText)) !== null) {
|
||||
pages.push({
|
||||
num: currentPage,
|
||||
startOffset: pageStart,
|
||||
endOffset: match.index,
|
||||
});
|
||||
pageStart = match.index;
|
||||
currentPage++;
|
||||
}
|
||||
pages.push({
|
||||
num: currentPage,
|
||||
startOffset: pageStart,
|
||||
endOffset: pdfText.length,
|
||||
});
|
||||
|
||||
console.log(`Total pages détectées: ${pages.length}\n`);
|
||||
|
||||
// Chercher les placeholders avec les patterns
|
||||
const results = [];
|
||||
|
||||
for (const pattern of PATTERNS) {
|
||||
pattern.lastIndex = 0;
|
||||
let matchResult;
|
||||
|
||||
while ((matchResult = pattern.exec(pdfText)) !== null) {
|
||||
const textFound = matchResult[0];
|
||||
const position = matchResult.index;
|
||||
|
||||
// Déterminer la page
|
||||
let pageNum = 1;
|
||||
for (const page of pages) {
|
||||
if (position >= page.startOffset && position <= page.endOffset) {
|
||||
pageNum = page.num;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Essayer d'extraire les coordonnées si c'est un placeholder formaté
|
||||
let role = 'Inconnu';
|
||||
let width = 150;
|
||||
let height = 60;
|
||||
|
||||
if (textFound.includes('Employeur')) {
|
||||
role = 'Employeur';
|
||||
} else if (textFound.includes('Employé') || textFound.includes('Salarié')) {
|
||||
role = 'Salarié';
|
||||
}
|
||||
|
||||
// Extraire les dimensions si présentes dans le placeholder
|
||||
const dimensionsMatch = textFound.match(/height=(\d+);width=(\d+)/i);
|
||||
if (dimensionsMatch) {
|
||||
height = parseInt(dimensionsMatch[1]);
|
||||
width = parseInt(dimensionsMatch[2]);
|
||||
}
|
||||
|
||||
results.push({
|
||||
text: textFound,
|
||||
page: pageNum,
|
||||
offsetInPDF: position,
|
||||
role,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
console.log('❌ Aucun placeholder trouvé dans le PDF!\n');
|
||||
console.log('Cherchez-vous les patterns corrects?\n');
|
||||
console.log('Patterns en cours de recherche:');
|
||||
PATTERNS.forEach(p => console.log(` - ${p}`));
|
||||
console.log('\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Afficher les résultats
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📍 PLACEHOLDERS TROUVÉS');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
results.forEach((result, idx) => {
|
||||
console.log(`${idx + 1}. ${result.text}`);
|
||||
console.log(` Rôle: ${result.role}`);
|
||||
console.log(` Page: ${result.page}`);
|
||||
console.log(` Dimensions: ${result.width} × ${result.height} mm`);
|
||||
console.log(` Position dans PDF: offset ${result.offsetInPDF}\n`);
|
||||
});
|
||||
|
||||
// Générer les positions pour test-odentas-sign.js
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
||||
console.log('📋 POSITIONS POUR test-odentas-sign.js');
|
||||
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
||||
|
||||
console.log('positions: [');
|
||||
|
||||
// Grouper par rôle
|
||||
const byRole = {};
|
||||
results.forEach(r => {
|
||||
if (!byRole[r.role]) byRole[r.role] = [];
|
||||
byRole[r.role].push(r);
|
||||
});
|
||||
|
||||
let xPos = 20;
|
||||
Object.entries(byRole).forEach(([role, items]) => {
|
||||
items.forEach(item => {
|
||||
console.log(` {`);
|
||||
console.log(` role: '${item.role}',`);
|
||||
console.log(` page: ${item.page},`);
|
||||
console.log(` x: ${xPos},`);
|
||||
console.log(` y: 260,`);
|
||||
console.log(` w: ${item.width},`);
|
||||
console.log(` h: ${item.height},`);
|
||||
console.log(` kind: 'signature',`);
|
||||
console.log(` label: '${item.text}',`);
|
||||
console.log(` },`);
|
||||
xPos += item.width + 20;
|
||||
});
|
||||
});
|
||||
|
||||
console.log(']');
|
||||
|
||||
// Sauvegarder en JSON
|
||||
const outputPath = pdfPath.replace('.pdf', '-positions.json');
|
||||
const positionsData = results.map(r => ({
|
||||
role: r.role,
|
||||
page: r.page,
|
||||
x: 20, // À ajuster manuellement
|
||||
y: 260, // À ajuster manuellement
|
||||
w: r.width,
|
||||
h: r.height,
|
||||
kind: 'signature',
|
||||
label: r.text,
|
||||
}));
|
||||
|
||||
fs.writeFileSync(outputPath, JSON.stringify(positionsData, null, 2));
|
||||
console.log(`\n💾 Sauvegardé: ${outputPath}\n`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erreur:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Point d'entrée
|
||||
const pdfPath = process.argv[2] || path.join(__dirname, 'test-contrat.pdf');
|
||||
|
||||
if (!fs.existsSync(pdfPath)) {
|
||||
console.error(`\n❌ Fichier PDF non trouvé: ${pdfPath}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
extractPositions(pdfPath).catch(console.error);
|
||||
116
lambda-pdf-converter/README.md
Normal file
116
lambda-pdf-converter/README.md
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
# Lambda PDF Converter pour Odentas Sign
|
||||
|
||||
## Description
|
||||
|
||||
Cette Lambda est déclenchée automatiquement par S3 quand un PDF est uploadé dans le bucket `odentas-sign` (prefix `source/`).
|
||||
|
||||
Elle convertit le PDF en images JPEG via Cloudinary et stocke les images dans `odentas-docs` pour être utilisées sur les pages de signature.
|
||||
|
||||
## Déploiement
|
||||
|
||||
### 1. Installation des dépendances
|
||||
|
||||
```bash
|
||||
cd lambda-pdf-converter
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Créer le ZIP de déploiement
|
||||
|
||||
```bash
|
||||
zip -r lambda-pdf-converter.zip . -x "*.git*" -x "README.md"
|
||||
```
|
||||
|
||||
### 3. Créer la Lambda dans AWS
|
||||
|
||||
1. Allez dans AWS Lambda Console
|
||||
2. Créer une nouvelle fonction :
|
||||
- **Nom** : `odentas-sign-pdf-converter`
|
||||
- **Runtime** : Node.js 20.x
|
||||
- **Architecture** : x86_64
|
||||
- **Rôle d'exécution** : Créer un nouveau rôle avec les permissions de base
|
||||
|
||||
3. Uploader le ZIP `lambda-pdf-converter.zip`
|
||||
|
||||
### 4. Configuration de la Lambda
|
||||
|
||||
#### Variables d'environnement
|
||||
|
||||
```
|
||||
CLOUDINARY_CLOUD_NAME=duecox5va
|
||||
CLOUDINARY_API_KEY=265234555873541
|
||||
CLOUDINARY_API_SECRET=DS5k0Zo2LxDkE5KmA3nFsT3bL1M
|
||||
AWS_REGION=eu-west-3
|
||||
SOURCE_BUCKET=odentas-sign
|
||||
DEST_BUCKET=odentas-docs
|
||||
```
|
||||
|
||||
#### Configuration générale
|
||||
|
||||
- **Mémoire** : 512 MB (ou plus selon la taille des PDF)
|
||||
- **Timeout** : 5 minutes (300 secondes)
|
||||
- **Retry** : 0 (pas de retry automatique)
|
||||
|
||||
#### Permissions IAM
|
||||
|
||||
Ajouter ces permissions au rôle de la Lambda :
|
||||
|
||||
```json
|
||||
{
|
||||
"Version": "2012-10-17",
|
||||
"Statement": [
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:GetObject"
|
||||
],
|
||||
"Resource": "arn:aws:s3:::odentas-sign/*"
|
||||
},
|
||||
{
|
||||
"Effect": "Allow",
|
||||
"Action": [
|
||||
"s3:PutObject"
|
||||
],
|
||||
"Resource": "arn:aws:s3:::odentas-docs/odentas-sign-images/*"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Configurer le trigger S3
|
||||
|
||||
1. Dans la Lambda, ajouter un trigger "S3"
|
||||
2. Configuration :
|
||||
- **Bucket** : `odentas-sign`
|
||||
- **Event type** : `PUT` (ObjectCreated:Put)
|
||||
- **Prefix** : `source/`
|
||||
- **Suffix** : `.pdf`
|
||||
|
||||
### 6. Tester
|
||||
|
||||
1. Uploadez un PDF de test dans `s3://odentas-sign/source/test/TEST-123456789.pdf`
|
||||
2. Vérifiez les logs CloudWatch de la Lambda
|
||||
3. Vérifiez que les images sont créées dans `s3://odentas-docs/odentas-sign-images/TEST/page-*.jpg`
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Upload PDF
|
||||
↓
|
||||
s3://odentas-sign/source/{folder}/{requestId}-{timestamp}.pdf
|
||||
↓
|
||||
Event S3 ObjectCreated
|
||||
↓
|
||||
Lambda odentas-sign-pdf-converter
|
||||
↓
|
||||
Cloudinary (conversion PDF → JPEG)
|
||||
↓
|
||||
s3://odentas-docs/odentas-sign-images/{requestId}/page-{num}.jpg
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- La Lambda extrait le `requestId` depuis le nom du fichier (partie avant le premier tiret)
|
||||
- Format attendu : `{requestId}-{timestamp}.pdf`
|
||||
- Exemple : `4fc9bdf9-eacc-4eed-b713-da40a095c5e7-1234567890.pdf` → requestId = `4fc9bdf9`
|
||||
- Les images sont stockées avec metadata pour traçabilité
|
||||
14
lambda-pdf-converter/create-poppler-layer.sh
Executable file
14
lambda-pdf-converter/create-poppler-layer.sh
Executable file
|
|
@ -0,0 +1,14 @@
|
|||
#!/bin/bash
|
||||
# Script pour créer une Lambda Layer avec Poppler depuis un container Amazon Linux
|
||||
|
||||
docker run --rm -v "$PWD":/var/task public.ecr.aws/lambda/nodejs:20 bash -c "
|
||||
yum install -y poppler-utils
|
||||
mkdir -p /tmp/layer/bin
|
||||
cp /usr/bin/pdftoppm /tmp/layer/bin/
|
||||
cp /usr/bin/pdfinfo /tmp/layer/bin/
|
||||
# Copier les libs nécessaires
|
||||
mkdir -p /tmp/layer/lib
|
||||
ldd /usr/bin/pdftoppm | grep '=>' | awk '{print \$3}' | xargs -I {} cp {} /tmp/layer/lib/ 2>/dev/null || true
|
||||
cd /tmp/layer
|
||||
zip -r /var/task/poppler-layer.zip .
|
||||
"
|
||||
49
lambda-pdf-converter/deploy.sh
Executable file
49
lambda-pdf-converter/deploy.sh
Executable file
|
|
@ -0,0 +1,49 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Script de déploiement de la Lambda PDF Converter
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Déploiement de la Lambda odentas-sign-pdf-converter"
|
||||
echo ""
|
||||
|
||||
cd lambda-pdf-converter
|
||||
|
||||
# 1. Installation des dépendances
|
||||
echo "📦 Installation des dépendances..."
|
||||
npm install --production
|
||||
|
||||
# 2. Création du ZIP
|
||||
echo "📦 Création du package ZIP..."
|
||||
rm -f lambda-pdf-converter.zip
|
||||
zip -r lambda-pdf-converter.zip . -x "*.git*" -x "README.md" -x "deploy.sh"
|
||||
|
||||
echo ""
|
||||
echo "✅ Package créé: lambda-pdf-converter.zip"
|
||||
echo ""
|
||||
echo "📋 Prochaines étapes:"
|
||||
echo ""
|
||||
echo "1. Créer la Lambda dans AWS Console:"
|
||||
echo " - Nom: odentas-sign-pdf-converter"
|
||||
echo " - Runtime: Node.js 20.x"
|
||||
echo " - Mémoire: 512 MB"
|
||||
echo " - Timeout: 5 minutes"
|
||||
echo ""
|
||||
echo "2. Uploader le fichier lambda-pdf-converter.zip"
|
||||
echo ""
|
||||
echo "3. Configurer les variables d'environnement:"
|
||||
echo " CLOUDINARY_CLOUD_NAME=duecox5va"
|
||||
echo " CLOUDINARY_API_KEY=265234555873541"
|
||||
echo " CLOUDINARY_API_SECRET=DS5k0Zo2LxDkE5KmA3nFsT3bL1M"
|
||||
echo " AWS_REGION=eu-west-3"
|
||||
echo " SOURCE_BUCKET=odentas-sign"
|
||||
echo " DEST_BUCKET=odentas-docs"
|
||||
echo ""
|
||||
echo "4. Ajouter le trigger S3:"
|
||||
echo " - Bucket: odentas-sign"
|
||||
echo " - Event: PUT"
|
||||
echo " - Prefix: source/"
|
||||
echo " - Suffix: .pdf"
|
||||
echo ""
|
||||
echo "5. Ajouter les permissions IAM au rôle de la Lambda"
|
||||
echo ""
|
||||
170
lambda-pdf-converter/index.js
Normal file
170
lambda-pdf-converter/index.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Lambda déclenchée par S3 ObjectCreated
|
||||
* Convertit automatiquement les PDF en images JPEG avec pdftoppm
|
||||
* et les stocke dans S3 pour les pages de signature
|
||||
*/
|
||||
|
||||
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { spawn } = require('child_process');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const { pipeline } = require('stream/promises');
|
||||
|
||||
// Client S3
|
||||
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'eu-west-3' });
|
||||
|
||||
const SOURCE_BUCKET = process.env.SOURCE_BUCKET || 'odentas-sign';
|
||||
const DEST_BUCKET = process.env.DEST_BUCKET || 'odentas-docs';
|
||||
|
||||
/**
|
||||
* Stream un objet S3 vers un fichier local
|
||||
*/
|
||||
async function streamToFile(readable, filePath) {
|
||||
const fileHandle = await fs.open(filePath, 'w');
|
||||
const writable = fileHandle.createWriteStream();
|
||||
await pipeline(readable, writable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un PDF en images JPEG avec pdftoppm
|
||||
*/
|
||||
async function convertPdfToImages(pdfPath, requestId, outputDir) {
|
||||
const outputPrefix = path.join(outputDir, 'page');
|
||||
|
||||
const args = [
|
||||
'-jpeg',
|
||||
'-jpegopt', 'quality=90',
|
||||
'-r', '150', // 150 DPI pour bonne qualité
|
||||
pdfPath,
|
||||
outputPrefix
|
||||
];
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('pdftoppm', args);
|
||||
let stderr = '';
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`pdftoppm exit ${code}: ${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler principal
|
||||
*/
|
||||
exports.handler = async (event) => {
|
||||
console.log('[Lambda] Event reçu:', JSON.stringify(event, null, 2));
|
||||
|
||||
try {
|
||||
// Traiter chaque record S3
|
||||
for (const record of event.Records || []) {
|
||||
const bucket = record.s3.bucket.name;
|
||||
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
|
||||
|
||||
console.log(`[Lambda] Fichier détecté: s3://${bucket}/${key}`);
|
||||
|
||||
if (!key.toLowerCase().endsWith('.pdf')) {
|
||||
console.log('[Lambda] Ignoré (pas un PDF)');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extraire le requestId depuis la clé S3
|
||||
// Format attendu: source/{folder}/{requestId}-{timestamp}.pdf
|
||||
const keyParts = key.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const requestId = filename.split('-')[0];
|
||||
|
||||
if (!requestId) {
|
||||
throw new Error(`Impossible d'extraire requestId depuis la clé: ${key}`);
|
||||
}
|
||||
|
||||
console.log(`[Lambda] Request ID extrait: ${requestId}`);
|
||||
|
||||
const tmpDir = '/tmp';
|
||||
const pdfPath = path.join(tmpDir, `${requestId}.pdf`);
|
||||
|
||||
// 1. Télécharger le PDF depuis S3
|
||||
console.log('[Lambda] Téléchargement du PDF depuis S3...');
|
||||
const getResponse = await s3Client.send(
|
||||
new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
})
|
||||
);
|
||||
|
||||
await streamToFile(getResponse.Body, pdfPath);
|
||||
const stats = await fs.stat(pdfPath);
|
||||
console.log(`[Lambda] PDF téléchargé: ${stats.size} bytes`);
|
||||
|
||||
// 2. Convertir avec pdftoppm → génère /tmp/page-1.jpg, /tmp/page-2.jpg ...
|
||||
console.log('[Lambda] Conversion PDF → JPEG avec pdftoppm...');
|
||||
await convertPdfToImages(pdfPath, requestId, tmpDir);
|
||||
|
||||
// 3. Lister les images générées
|
||||
const files = await fs.readdir(tmpDir);
|
||||
const pageFiles = files
|
||||
.filter(f => f.startsWith('page-') && f.endsWith('.jpg'))
|
||||
.sort((a, b) => {
|
||||
const numA = parseInt(a.match(/page-(\d+)/)[1], 10);
|
||||
const numB = parseInt(b.match(/page-(\d+)/)[1], 10);
|
||||
return numA - numB;
|
||||
});
|
||||
|
||||
console.log(`[Lambda] ${pageFiles.length} page(s) générée(s):`, pageFiles);
|
||||
|
||||
if (pageFiles.length === 0) {
|
||||
throw new Error('Aucune page générée par pdftoppm');
|
||||
}
|
||||
|
||||
// 4. Upload vers S3
|
||||
const uploadedPages = [];
|
||||
for (let i = 0; i < pageFiles.length; i++) {
|
||||
const pageFile = pageFiles[i];
|
||||
const pageNum = i + 1;
|
||||
const imagePath = path.join(tmpDir, pageFile);
|
||||
|
||||
console.log(`[Lambda] Upload page ${pageNum}: ${pageFile}`);
|
||||
|
||||
const imageBuffer = await fs.readFile(imagePath);
|
||||
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
|
||||
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: DEST_BUCKET,
|
||||
Key: s3Key,
|
||||
Body: imageBuffer,
|
||||
ContentType: 'image/jpeg',
|
||||
CacheControl: 'public, max-age=31536000', // 1 an
|
||||
})
|
||||
);
|
||||
|
||||
console.log(`[Lambda] Page ${pageNum} stockée sur S3 → ${s3Key}`);
|
||||
uploadedPages.push(s3Key);
|
||||
|
||||
// Supprimer le fichier temporaire
|
||||
await fs.unlink(imagePath);
|
||||
}
|
||||
|
||||
// 5. Cleanup du PDF
|
||||
await fs.unlink(pdfPath);
|
||||
|
||||
console.log(`[Lambda] ✅ Conversion terminée: ${uploadedPages.length} page(s) pour ${requestId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({ message: 'Conversion réussie' }),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Lambda] ❌ Erreur:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
203
lambda-pdf-converter/index.js.cloudinary.bak
Normal file
203
lambda-pdf-converter/index.js.cloudinary.bak
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Lambda déclenchée par S3 ObjectCreated
|
||||
* Convertit automatiquement les PDF en images JPEG avec pdfjs-dist + canvas
|
||||
* et les stocke dans S3 pour les pages de signature
|
||||
*/
|
||||
|
||||
const { S3Client, GetObjectCommand, PutObjectCommand } = require('@aws-sdk/client-s3');
|
||||
const { getDocument } = require('pdfjs-dist/legacy/build/pdf.js');
|
||||
const { createCanvas } = require('canvas');
|
||||
const sharp = require('sharp');
|
||||
|
||||
// Client S3
|
||||
const s3Client = new S3Client({ region: process.env.AWS_REGION || 'eu-west-3' });
|
||||
|
||||
const SOURCE_BUCKET = process.env.SOURCE_BUCKET || 'odentas-sign';
|
||||
const DEST_BUCKET = process.env.DEST_BUCKET || 'odentas-docs';
|
||||
|
||||
/**
|
||||
* Convertit une page PDF en image JPEG avec Sharp
|
||||
*/
|
||||
async function convertPdfPageToJpeg(pdfPage, scale = 2.0) {
|
||||
const viewport = pdfPage.getViewport({ scale });
|
||||
const canvas = createCanvas(viewport.width, viewport.height);
|
||||
const context = canvas.getContext('2d');
|
||||
|
||||
await pdfPage.render({
|
||||
canvasContext: context,
|
||||
viewport: viewport,
|
||||
}).promise;
|
||||
|
||||
// Convertir le canvas en buffer puis optimiser avec Sharp
|
||||
const rawImageBuffer = canvas.toBuffer('raw');
|
||||
|
||||
return await sharp(rawImageBuffer, {
|
||||
raw: {
|
||||
width: Math.floor(viewport.width),
|
||||
height: Math.floor(viewport.height),
|
||||
channels: 4, // RGBA
|
||||
},
|
||||
})
|
||||
.jpeg({
|
||||
quality: 90,
|
||||
progressive: true,
|
||||
})
|
||||
.resize(1400, null, { // Max 1400px de largeur
|
||||
withoutEnlargement: true,
|
||||
fit: 'inside',
|
||||
})
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler principal
|
||||
*/
|
||||
exports.handler = async (event) => {
|
||||
console.log('[Lambda] Event reçu:', JSON.stringify(event, null, 2));
|
||||
|
||||
try {
|
||||
// Récupérer les informations du fichier uploadé
|
||||
const record = event.Records[0];
|
||||
const bucket = record.s3.bucket.name;
|
||||
const key = decodeURIComponent(record.s3.object.key.replace(/\+/g, ' '));
|
||||
|
||||
console.log(`[Lambda] Fichier détecté: s3://${bucket}/${key}`);
|
||||
|
||||
// Extraire le requestId depuis la clé S3
|
||||
// Format attendu: source/{folder}/{requestId}-{timestamp}.pdf
|
||||
const keyParts = key.split('/');
|
||||
const filename = keyParts[keyParts.length - 1];
|
||||
const requestId = filename.split('-')[0]; // Prend la partie avant le premier tiret
|
||||
|
||||
if (!requestId) {
|
||||
throw new Error(`Impossible d'extraire requestId depuis la clé: ${key}`);
|
||||
}
|
||||
|
||||
console.log(`[Lambda] Request ID extrait: ${requestId}`);
|
||||
|
||||
// 1. Télécharger le PDF depuis S3
|
||||
console.log('[Lambda] Téléchargement du PDF depuis S3...');
|
||||
const getObjectCommand = new GetObjectCommand({
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
});
|
||||
const s3Response = await s3Client.send(getObjectCommand);
|
||||
const pdfBuffer = Buffer.from(await s3Response.Body.transformToByteArray());
|
||||
console.log(`[Lambda] PDF téléchargé: ${pdfBuffer.length} bytes`);
|
||||
|
||||
// 2. Upload sur Cloudinary pour conversion (upload en tant que PDF, pas en JPG)
|
||||
console.log('[Lambda] Upload sur Cloudinary pour conversion...');
|
||||
const uploadResult = await new Promise((resolve, reject) => {
|
||||
const uploadStream = cloudinary.uploader.upload_stream(
|
||||
{
|
||||
folder: `odentas-sign-temp/${requestId}`,
|
||||
resource_type: 'raw', // 'raw' pour préserver le PDF tel quel
|
||||
public_id: `pdf-${requestId}`,
|
||||
transformation: [
|
||||
{ width: 1400, crop: 'scale' },
|
||||
{ quality: 90 },
|
||||
],
|
||||
},
|
||||
(error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
}
|
||||
);
|
||||
|
||||
bufferToStream(pdfBuffer).pipe(uploadStream);
|
||||
});
|
||||
|
||||
console.log('[Lambda] Upload Cloudinary réussi:', uploadResult.secure_url);
|
||||
console.log('[Lambda] Pages détectées par Cloudinary:', uploadResult.pages);
|
||||
|
||||
const baseUrl = uploadResult.secure_url;
|
||||
|
||||
// Cloudinary ne retourne pas toujours le nombre de pages correctement
|
||||
// On va tester chaque page jusqu'à ce qu'on obtienne une erreur 404
|
||||
const pages = [];
|
||||
let pageNum = 1;
|
||||
const maxPages = 100; // Sécurité pour éviter une boucle infinie
|
||||
|
||||
console.log('[Lambda] Détection du nombre de pages...');
|
||||
|
||||
// 3. Pour chaque page, télécharger depuis Cloudinary et stocker sur S3
|
||||
while (pageNum <= maxPages) {
|
||||
// Pour un PDF uploadé en 'raw', on doit forcer le format JPG avec .jpg
|
||||
const public_id_with_folder = `${uploadResult.folder}/${uploadResult.public_id}`;
|
||||
const cloudinaryPageUrl = `https://res.cloudinary.com/${process.env.CLOUDINARY_CLOUD_NAME}/image/upload/pg_${pageNum}/w_1400,q_90/${public_id_with_folder}.jpg`;
|
||||
|
||||
console.log(`[Lambda] Test page ${pageNum}: Téléchargement depuis Cloudinary...`);
|
||||
|
||||
// Télécharger l'image
|
||||
const imageResponse = await fetch(cloudinaryPageUrl);
|
||||
|
||||
// Si 4xx, on a atteint la fin du PDF (404 ou 400 selon Cloudinary)
|
||||
if (imageResponse.status >= 400 && imageResponse.status < 500) {
|
||||
console.log(`[Lambda] Page ${pageNum} non trouvée (${imageResponse.status}), fin du PDF`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Erreur téléchargement page ${pageNum}: ${imageResponse.statusText}`);
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
|
||||
// Stocker sur S3
|
||||
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: DEST_BUCKET,
|
||||
Key: s3Key,
|
||||
Body: imageBuffer,
|
||||
ContentType: 'image/jpeg',
|
||||
CacheControl: 'public, max-age=31536000',
|
||||
Metadata: {
|
||||
'source-pdf': key,
|
||||
'request-id': requestId,
|
||||
'page-number': pageNum.toString(),
|
||||
'converted-at': new Date().toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
pages.push(s3Key);
|
||||
console.log(`[Lambda] Page ${pageNum} stockée sur S3 → ${s3Key}`);
|
||||
|
||||
pageNum++;
|
||||
}
|
||||
|
||||
const totalPages = pages.length;
|
||||
|
||||
// 4. Nettoyer Cloudinary
|
||||
console.log('[Lambda] Nettoyage Cloudinary...');
|
||||
try {
|
||||
await cloudinary.uploader.destroy(`odentas-sign-temp/${requestId}/pdf-${requestId}`);
|
||||
console.log('[Lambda] Nettoyage Cloudinary terminé');
|
||||
} catch (cleanupError) {
|
||||
console.warn('[Lambda] Erreur nettoyage Cloudinary (non bloquant):', cleanupError);
|
||||
}
|
||||
|
||||
console.log(`[Lambda] ✅ Conversion terminée: ${totalPages} page(s) pour ${requestId}`);
|
||||
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
requestId,
|
||||
totalPages,
|
||||
message: 'PDF converti avec succès',
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[Lambda] ❌ Erreur:', error);
|
||||
|
||||
return {
|
||||
statusCode: 500,
|
||||
body: JSON.stringify({
|
||||
success: false,
|
||||
error: error.message,
|
||||
}),
|
||||
};
|
||||
}
|
||||
};
|
||||
BIN
lambda-pdf-converter/lambda-pdf-converter.zip
Normal file
BIN
lambda-pdf-converter/lambda-pdf-converter.zip
Normal file
Binary file not shown.
1654
lambda-pdf-converter/package-lock.json
generated
Normal file
1654
lambda-pdf-converter/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
9
lambda-pdf-converter/package.json
Normal file
9
lambda-pdf-converter/package.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "lambda-pdf-converter",
|
||||
"version": "1.0.0",
|
||||
"description": "Lambda pour convertir les PDF en images JPEG via Cloudinary lors de l'upload sur S3",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.913.0"
|
||||
}
|
||||
}
|
||||
BIN
lambda-pdf-converter/poppler-layer.zip
Normal file
BIN
lambda-pdf-converter/poppler-layer.zip
Normal file
Binary file not shown.
23
lambda-pdf-converter/s3-notification.json
Normal file
23
lambda-pdf-converter/s3-notification.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"LambdaFunctionConfigurations": [
|
||||
{
|
||||
"Id": "odentas-sign-pdf-converter-trigger",
|
||||
"LambdaFunctionArn": "arn:aws:lambda:eu-west-3:292468105557:function:odentas-sign-pdf-converter",
|
||||
"Events": ["s3:ObjectCreated:*"],
|
||||
"Filter": {
|
||||
"Key": {
|
||||
"FilterRules": [
|
||||
{
|
||||
"Name": "prefix",
|
||||
"Value": "source/"
|
||||
},
|
||||
{
|
||||
"Name": "suffix",
|
||||
"Value": ".pdf"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
218
lib/odentas-sign/cloudinary.ts
Normal file
218
lib/odentas-sign/cloudinary.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { v2 as cloudinary } from 'cloudinary';
|
||||
import { S3Client, PutObjectCommand, GetObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
|
||||
// Configuration Cloudinary
|
||||
cloudinary.config({
|
||||
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
|
||||
api_key: process.env.CLOUDINARY_API_KEY,
|
||||
api_secret: process.env.CLOUDINARY_API_SECRET,
|
||||
secure: true,
|
||||
});
|
||||
|
||||
// Client S3 pour stocker les images converties
|
||||
const s3Client = new S3Client({
|
||||
region: process.env.AWS_REGION || 'eu-west-3',
|
||||
credentials: {
|
||||
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
|
||||
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
|
||||
},
|
||||
});
|
||||
|
||||
const S3_BUCKET = process.env.AWS_S3_BUCKET || 'odentas-docs';
|
||||
|
||||
interface PageImage {
|
||||
pageNumber: number;
|
||||
imageUrl: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si les images d'un PDF existent déjà dans S3
|
||||
*/
|
||||
async function checkImagesExistInS3(requestId: string): Promise<PageImage[] | null> {
|
||||
try {
|
||||
// On essaie de trouver les images existantes
|
||||
// On commence par vérifier la page 1
|
||||
const firstPageKey = `odentas-sign-images/${requestId}/page-1.jpg`;
|
||||
|
||||
try {
|
||||
await s3Client.send(new HeadObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: firstPageKey,
|
||||
}));
|
||||
} catch (error) {
|
||||
// La première page n'existe pas, donc les images ne sont pas en cache
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[S3 Cache] Images trouvées dans S3, récupération...');
|
||||
|
||||
// La page 1 existe, on récupère toutes les pages
|
||||
const pageImages: PageImage[] = [];
|
||||
let pageNum = 1;
|
||||
|
||||
while (true) {
|
||||
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
|
||||
|
||||
try {
|
||||
// Vérifier si la page existe
|
||||
await s3Client.send(new HeadObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: s3Key,
|
||||
}));
|
||||
|
||||
// Générer l'URL presignée
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: s3Key,
|
||||
});
|
||||
const s3Url = await getSignedUrl(s3Client, command, { expiresIn: 86400 });
|
||||
|
||||
pageImages.push({
|
||||
pageNumber: pageNum,
|
||||
imageUrl: s3Url,
|
||||
width: 1400,
|
||||
height: Math.round(1400 * 1.414),
|
||||
});
|
||||
|
||||
pageNum++;
|
||||
} catch (error) {
|
||||
// Plus de pages, on sort de la boucle
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[S3 Cache] ✅ ${pageImages.length} page(s) récupérées depuis S3 (pas de conversion)`);
|
||||
return pageImages.length > 0 ? pageImages : null;
|
||||
} catch (error) {
|
||||
console.log('[S3 Cache] Pas de cache trouvé, conversion nécessaire');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un PDF en images JPEG via Cloudinary,
|
||||
* puis stocke les images sur S3 pour économiser la bande passante Cloudinary
|
||||
*/
|
||||
export async function convertPdfToImagesWithCloudinary(
|
||||
pdfBuffer: Buffer,
|
||||
requestId: string
|
||||
): Promise<PageImage[]> {
|
||||
try {
|
||||
// 1. Vérifier si les images existent déjà dans S3
|
||||
const cachedImages = await checkImagesExistInS3(requestId);
|
||||
if (cachedImages) {
|
||||
console.log('[S3 Cache] ✅ Utilisation du cache S3, pas de conversion Cloudinary');
|
||||
return cachedImages;
|
||||
}
|
||||
|
||||
console.log('[Cloudinary] Pas de cache S3, début conversion...');
|
||||
console.log('[Cloudinary] Début upload PDF, taille:', pdfBuffer.length);
|
||||
|
||||
// 1. Upload le PDF sur Cloudinary pour conversion
|
||||
const uploadResult = await new Promise<any>((resolve, reject) => {
|
||||
const uploadStream = cloudinary.uploader.upload_stream(
|
||||
{
|
||||
folder: `odentas-sign-temp/${requestId}`,
|
||||
resource_type: 'image',
|
||||
format: 'jpg',
|
||||
public_id: `pdf-${requestId}`,
|
||||
transformation: [
|
||||
{ width: 1400, crop: 'scale' },
|
||||
{ quality: 90 },
|
||||
],
|
||||
},
|
||||
(error, result) => {
|
||||
if (error) reject(error);
|
||||
else resolve(result);
|
||||
}
|
||||
);
|
||||
|
||||
uploadStream.end(pdfBuffer);
|
||||
});
|
||||
|
||||
console.log('[Cloudinary] Upload réussi, conversion en cours...');
|
||||
|
||||
const baseUrl = uploadResult.secure_url;
|
||||
const pages = uploadResult.pages || 1;
|
||||
|
||||
const pageImages: PageImage[] = [];
|
||||
|
||||
// 2. Pour chaque page, télécharger l'image depuis Cloudinary et la stocker sur S3
|
||||
for (let pageNum = 1; pageNum <= pages; pageNum++) {
|
||||
// URL Cloudinary de la page
|
||||
const cloudinaryPageUrl = baseUrl.replace(
|
||||
'/upload/',
|
||||
`/upload/pg_${pageNum}/w_1400,q_90/`
|
||||
);
|
||||
|
||||
console.log(`[Cloudinary] Téléchargement page ${pageNum}/${pages} depuis Cloudinary...`);
|
||||
|
||||
// Télécharger l'image depuis Cloudinary
|
||||
const imageResponse = await fetch(cloudinaryPageUrl);
|
||||
if (!imageResponse.ok) {
|
||||
throw new Error(`Erreur téléchargement page ${pageNum}: ${imageResponse.statusText}`);
|
||||
}
|
||||
|
||||
const imageBuffer = Buffer.from(await imageResponse.arrayBuffer());
|
||||
|
||||
// 3. Stocker l'image sur S3
|
||||
const s3Key = `odentas-sign-images/${requestId}/page-${pageNum}.jpg`;
|
||||
await s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: s3Key,
|
||||
Body: imageBuffer,
|
||||
ContentType: 'image/jpeg',
|
||||
CacheControl: 'public, max-age=31536000', // Cache 1 an
|
||||
})
|
||||
);
|
||||
|
||||
// Générer une URL presignée S3 (valide 24h, renouvelée à chaque accès)
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: S3_BUCKET,
|
||||
Key: s3Key,
|
||||
});
|
||||
const s3Url = await getSignedUrl(s3Client, command, { expiresIn: 86400 }); // 24h
|
||||
|
||||
pageImages.push({
|
||||
pageNumber: pageNum,
|
||||
imageUrl: s3Url,
|
||||
width: 1400,
|
||||
height: Math.round(1400 * 1.414), // Ratio A4
|
||||
});
|
||||
|
||||
console.log(`[Cloudinary → S3] Page ${pageNum}/${pages} stockée sur S3: ${s3Key}`);
|
||||
}
|
||||
|
||||
// 4. Nettoyer Cloudinary (supprimer le PDF temporaire)
|
||||
try {
|
||||
await cloudinary.uploader.destroy(`odentas-sign-temp/${requestId}/pdf-${requestId}`);
|
||||
console.log('[Cloudinary] Nettoyage temporaire effectué');
|
||||
} catch (cleanupError) {
|
||||
console.warn('[Cloudinary] Erreur nettoyage (non bloquant):', cleanupError);
|
||||
}
|
||||
|
||||
console.log(`[Cloudinary → S3] ✅ ${pages} page(s) converties et stockées sur S3`);
|
||||
return pageImages;
|
||||
} catch (error) {
|
||||
console.error('[Cloudinary] Erreur:', error);
|
||||
throw new Error(
|
||||
`Erreur Cloudinary: ${error instanceof Error ? error.message : 'Erreur inconnue'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Supprime un PDF et ses pages de Cloudinary
|
||||
*/
|
||||
export async function deleteFromCloudinary(requestId: string): Promise<void> {
|
||||
try {
|
||||
await cloudinary.api.delete_resources_by_prefix(`odentas-sign/${requestId}`);
|
||||
console.log(`[Cloudinary] PDF ${requestId} supprimé`);
|
||||
} catch (error) {
|
||||
console.error('[Cloudinary] Erreur suppression:', error);
|
||||
}
|
||||
}
|
||||
52
lib/odentas-sign/pdf-converter.ts
Normal file
52
lib/odentas-sign/pdf-converter.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { pdf } from 'pdf-to-img';
|
||||
|
||||
interface PageImage {
|
||||
pageNumber: number;
|
||||
imageBase64: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un PDF (buffer) en images JPEG
|
||||
* Solution ultra simple avec pdf-to-img
|
||||
*/
|
||||
export async function convertPdfToJpegImages(
|
||||
pdfBuffer: Buffer,
|
||||
targetWidth: number = 1400
|
||||
): Promise<PageImage[]> {
|
||||
try {
|
||||
console.log('[PDF Converter] Début conversion, taille buffer:', pdfBuffer.length);
|
||||
|
||||
const pages: PageImage[] = [];
|
||||
|
||||
// Convertir le PDF en images
|
||||
const document = await pdf(pdfBuffer, {
|
||||
scale: 2.0, // Haute résolution
|
||||
});
|
||||
|
||||
let pageNum = 1;
|
||||
for await (const page of document) {
|
||||
// page est déjà un Buffer PNG
|
||||
const imageBase64 = `data:image/png;base64,${page.toString('base64')}`;
|
||||
|
||||
pages.push({
|
||||
pageNumber: pageNum,
|
||||
imageBase64,
|
||||
width: targetWidth,
|
||||
height: Math.round(targetWidth * 1.414), // Ratio A4 approximatif
|
||||
});
|
||||
|
||||
console.log(`[PDF Converter] Page ${pageNum} convertie`);
|
||||
pageNum++;
|
||||
}
|
||||
|
||||
console.log(`[PDF Converter] ${pages.length} page(s) converties avec succès`);
|
||||
return pages;
|
||||
} catch (error) {
|
||||
console.error('[PDF Converter] Erreur:', error);
|
||||
throw new Error(`Erreur de conversion PDF: ${error instanceof Error ? error.message : 'Erreur inconnue'}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
126
lib/odentas-sign/placeholders.ts
Normal file
126
lib/odentas-sign/placeholders.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// Utilities to extract DocuSeal-style signature placeholders from PDFs
|
||||
// and estimate reasonable positions when exact text coordinates are unavailable.
|
||||
|
||||
export type PlaceholderMatch = {
|
||||
fullMatch: string;
|
||||
label: string;
|
||||
role: string;
|
||||
type: string;
|
||||
width: number; // mm
|
||||
height: number; // mm
|
||||
startIndex: number;
|
||||
endIndex: number;
|
||||
};
|
||||
|
||||
export type EstimatedPosition = {
|
||||
role: string;
|
||||
label: string;
|
||||
page: number; // 1-indexed
|
||||
x: number; // mm from left
|
||||
y: number; // mm from top
|
||||
width: number; // mm
|
||||
height: number; // mm
|
||||
};
|
||||
|
||||
const PLACEHOLDER_REGEX = /\{\{([^;]+);role=([^;]+);type=([^;]+);height=(\d+);width=(\d+)\}\}/g;
|
||||
|
||||
/**
|
||||
* Count PDF pages by scanning for '/Type /Page' markers in raw bytes.
|
||||
* This is heuristic but robust enough for most PDFs without full parsing.
|
||||
*/
|
||||
export function countPdfPagesFromBytes(bytes: Uint8Array | Buffer): number {
|
||||
try {
|
||||
const text = bufferToLatin1String(bytes);
|
||||
// Count '/Type /Page' but not '/Type /Pages'
|
||||
const matches = text.match(/\/Type\s*\/Page(?!s)\b/g);
|
||||
if (matches && matches.length > 0) return matches.length;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
// Default to 1 page if unknown
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract placeholders from PDF bytes by regex scanning the raw stream text.
|
||||
*/
|
||||
export function extractPlaceholdersFromPdfBuffer(bytes: Uint8Array | Buffer): PlaceholderMatch[] {
|
||||
const text = bufferToLatin1String(bytes);
|
||||
const placeholders: PlaceholderMatch[] = [];
|
||||
|
||||
PLACEHOLDER_REGEX.lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = PLACEHOLDER_REGEX.exec(text)) !== null) {
|
||||
placeholders.push({
|
||||
fullMatch: match[0],
|
||||
label: match[1].trim(),
|
||||
role: match[2].trim(),
|
||||
type: match[3].trim(),
|
||||
height: parseInt(match[4], 10),
|
||||
width: parseInt(match[5], 10),
|
||||
startIndex: match.index,
|
||||
endIndex: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return placeholders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate reasonable positions (in mm) for placeholders when exact coordinates are unknown.
|
||||
* Assumes A4 portrait (210 x 297mm). Places fields near the bottom margin, left/right by role.
|
||||
*/
|
||||
export function estimatePositionsFromPlaceholders(
|
||||
placeholders: PlaceholderMatch[],
|
||||
pageCount: number
|
||||
): EstimatedPosition[] {
|
||||
const A4_WIDTH_MM = 210;
|
||||
const A4_HEIGHT_MM = 297;
|
||||
const MARGIN_X_MM = 20;
|
||||
const MARGIN_BOTTOM_MM = 30;
|
||||
|
||||
// Prefer placing on the last page by default
|
||||
const defaultPage = Math.max(1, pageCount);
|
||||
|
||||
return placeholders.map((ph) => {
|
||||
// Les placeholders portent déjà les dimensions attendues du cadre
|
||||
// de signature (en millimètres), on les utilise telles quelles.
|
||||
const width = Math.max(20, ph.width || 150); // mm
|
||||
const height = Math.max(10, ph.height || 60); // mm
|
||||
|
||||
// Default Y: bottom area
|
||||
const y = A4_HEIGHT_MM - MARGIN_BOTTOM_MM - height;
|
||||
|
||||
// Role-based horizontal placement: employer left, employee right
|
||||
const roleLc = ph.role.toLowerCase();
|
||||
const isEmployee = roleLc.includes('salari') || roleLc.includes('employé') || roleLc.includes('employe');
|
||||
|
||||
const x = isEmployee
|
||||
? Math.max(MARGIN_X_MM, A4_WIDTH_MM - MARGIN_X_MM - width)
|
||||
: MARGIN_X_MM;
|
||||
|
||||
return {
|
||||
role: ph.role,
|
||||
label: ph.label,
|
||||
page: defaultPage,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function bufferToLatin1String(bytes: Uint8Array | Buffer): string {
|
||||
if (typeof Buffer !== 'undefined' && (bytes as Buffer).toString) {
|
||||
return (bytes as Buffer).toString('latin1');
|
||||
}
|
||||
// Fallback for environments without Node Buffer
|
||||
let result = '';
|
||||
const chunk = 8192;
|
||||
for (let i = 0; i < bytes.length; i += chunk) {
|
||||
const slice = bytes.slice(i, i + chunk);
|
||||
result += Array.from(slice as any, (b: number) => String.fromCharCode(b)).join('');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
|
@ -11,6 +11,12 @@ const nextConfig = {
|
|||
// Configuration pour optimiser les chunks et éviter les erreurs de modules Supabase
|
||||
webpack: (config, { dev, isServer }) => {
|
||||
if (!isServer) {
|
||||
// Ignorer le module 'canvas' côté client (optionnel pour pdfjs-dist)
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
canvas: false,
|
||||
};
|
||||
|
||||
// Optimiser les chunks pour éviter les problèmes avec Supabase
|
||||
config.optimization.splitChunks = {
|
||||
...config.optimization.splitChunks,
|
||||
|
|
|
|||
874
package-lock.json
generated
874
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,7 @@
|
|||
"axios": "^1.12.2",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"cloudinary": "^2.8.0",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dotenv": "^17.2.2",
|
||||
|
|
@ -43,13 +44,16 @@
|
|||
"nprogress": "^0.2.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"pdf-parse": "^2.4.5",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"pdf-to-img": "^5.0.0",
|
||||
"pdf2pic": "^3.2.0",
|
||||
"posthog-js": "^1.275.1",
|
||||
"posthog-node": "^5.9.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.62.0",
|
||||
"react-pdf": "^10.2.0",
|
||||
"sharp": "^0.34.4",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"use-debounce": "^10.0.6"
|
||||
|
|
|
|||
1
poppler-layer/poppler.rpm
Normal file
1
poppler-layer/poppler.rpm
Normal file
|
|
@ -0,0 +1 @@
|
|||
404: Not Found
|
||||
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"success": true,
|
||||
"request": {
|
||||
"id": "0d14754a-740b-42e0-9766-60582e116d09",
|
||||
"ref": "REAL-1761586268897",
|
||||
"id": "89a4786c-07e4-4dfa-8fdd-3089dbef5996",
|
||||
"ref": "REAL-1761590226249",
|
||||
"title": "Contrat CDDU - contrat_cddu_LYXHX3GI_240V001",
|
||||
"status": "pending",
|
||||
"created_at": "2025-10-27T17:31:09.550025+00:00"
|
||||
"created_at": "2025-10-27T18:37:09.227226+00:00"
|
||||
},
|
||||
"signers": [
|
||||
{
|
||||
"signerId": "12430034-e696-428a-876e-ba4d35b1ff2c",
|
||||
"signerId": "43a3a850-74e8-4281-b1ab-e61000304201",
|
||||
"role": "Employeur",
|
||||
"name": "Odentas Paie",
|
||||
"email": "paie@odentas.fr",
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/0d14754a-740b-42e0-9766-60582e116d09/12430034-e696-428a-876e-ba4d35b1ff2c"
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/89a4786c-07e4-4dfa-8fdd-3089dbef5996/43a3a850-74e8-4281-b1ab-e61000304201"
|
||||
},
|
||||
{
|
||||
"signerId": "1c8914ad-4cfa-40e2-870b-b6e269ba29f3",
|
||||
"signerId": "d2646860-3d0f-42d6-abdc-c6084e870088",
|
||||
"role": "Salarié",
|
||||
"name": "Renaud Breviere",
|
||||
"email": "renaud.breviere@gmail.com",
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/0d14754a-740b-42e0-9766-60582e116d09/1c8914ad-4cfa-40e2-870b-b6e269ba29f3"
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/89a4786c-07e4-4dfa-8fdd-3089dbef5996/d2646860-3d0f-42d6-abdc-c6084e870088"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -10,7 +10,16 @@ echo ""
|
|||
|
||||
# Créer une nouvelle demande de signature
|
||||
echo "📝 1. Création d'une demande de signature..."
|
||||
RESPONSE=$(node create-real-signature.js)
|
||||
|
||||
# Utiliser le PDF de test existant
|
||||
PDF_FILE="contrat_cddu_LYXHX3GI_240V001.pdf"
|
||||
|
||||
if [ ! -f "$PDF_FILE" ]; then
|
||||
echo "❌ Erreur: Fichier PDF non trouvé: $PDF_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
RESPONSE=$(node create-real-signature.js "$PDF_FILE")
|
||||
echo "$RESPONSE"
|
||||
|
||||
# Extraire le request_id et les signer IDs du JSON
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"success": true,
|
||||
"request": {
|
||||
"id": "75b4408d-1bbd-464f-a9ea-2b4e5075a817",
|
||||
"ref": "TEST-1761582838435",
|
||||
"id": "4fc9bdf9-eacc-4eed-b713-da40a095c5e7",
|
||||
"ref": "TEST-1761595009432",
|
||||
"title": "Contrat CDDU - Test Local",
|
||||
"status": "pending",
|
||||
"created_at": "2025-10-27T16:34:07.361187+00:00"
|
||||
"created_at": "2025-10-27T19:56:51.428927+00:00"
|
||||
},
|
||||
"signers": [
|
||||
{
|
||||
"signerId": "95c4ccdc-1a26-4426-a56f-653758159b54",
|
||||
"signerId": "8f0c03c2-0891-4696-9f03-1d5d562cfb9e",
|
||||
"role": "Employeur",
|
||||
"name": "Odentas Paie",
|
||||
"email": "paie@odentas.fr",
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/95c4ccdc-1a26-4426-a56f-653758159b54"
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/4fc9bdf9-eacc-4eed-b713-da40a095c5e7/8f0c03c2-0891-4696-9f03-1d5d562cfb9e"
|
||||
},
|
||||
{
|
||||
"signerId": "d481f070-2ac6-4f82-aff3-862783904d5d",
|
||||
"signerId": "1f162f81-a235-4079-b499-aa9dadbe9eff",
|
||||
"role": "Salarié",
|
||||
"name": "Renaud Breviere",
|
||||
"email": "renaud.breviere@gmail.com",
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/75b4408d-1bbd-464f-a9ea-2b4e5075a817/d481f070-2ac6-4f82-aff3-862783904d5d"
|
||||
"signatureUrl": "https://espace-paie.odentas.fr/signer/4fc9bdf9-eacc-4eed-b713-da40a095c5e7/1f162f81-a235-4079-b499-aa9dadbe9eff"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -82,23 +82,23 @@ async function main() {
|
|||
positions: [
|
||||
{
|
||||
role: 'Employeur',
|
||||
page: 1,
|
||||
x: 100,
|
||||
y: 680,
|
||||
w: 200,
|
||||
page: 3,
|
||||
x: 20,
|
||||
y: 260,
|
||||
w: 150,
|
||||
h: 60,
|
||||
kind: 'signature',
|
||||
label: 'Signature Employeur',
|
||||
},
|
||||
{
|
||||
role: 'Salarié',
|
||||
page: 1,
|
||||
x: 350,
|
||||
y: 680,
|
||||
w: 200,
|
||||
page: 3,
|
||||
x: 180,
|
||||
y: 260,
|
||||
w: 150,
|
||||
h: 60,
|
||||
kind: 'signature',
|
||||
label: 'Signature Salarié',
|
||||
label: 'Signature Employé',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue