feat: Adapter emails de factures studio avec lien de téléchargement direct

- Créer route publique /api/public/invoices/[id]/download pour générer liens S3 à la volée
- Supprimer code employeur de l'infoCard pour factures studio
- Adapter texte bouton CTA (Télécharger vs Voir la facture)
- Rediriger vers page de téléchargement au lieu de l'Espace Paie pour clients studio
- Lien pré-signé S3 valable 15 minutes (suffisant pour téléchargement)
This commit is contained in:
odentas 2025-11-07 18:39:09 +01:00
parent 5351456516
commit 3c4e6a1e1d
3 changed files with 132 additions and 5 deletions

View file

@ -0,0 +1,105 @@
// app/api/public/invoices/[id]/download/route.ts
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export const runtime = 'nodejs';
/**
* Route publique pour télécharger une facture
* Génère un lien pré-signé S3 à la volée et redirige vers le PDF
*
* Usage: GET /api/public/invoices/[id]/download?token=[optional_token]
*/
export async function GET(
req: Request,
{ params }: { params: { id: string } }
) {
try {
console.log('[api/public/invoices/download] Request for invoice:', params.id);
// Créer un client Supabase avec la service role key pour accès direct
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!supabaseUrl || !supabaseServiceKey) {
console.error('[api/public/invoices/download] Missing Supabase configuration');
return NextResponse.json(
{ error: 'Configuration error' },
{ status: 500 }
);
}
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Récupérer la facture
const { data: invoice, error: invoiceError } = await supabase
.from('invoices')
.select('id, pdf_s3_key, invoice_type, invoice_number, status')
.eq('id', params.id)
.single();
if (invoiceError || !invoice) {
console.error('[api/public/invoices/download] Invoice not found:', invoiceError);
return NextResponse.json(
{ error: 'Facture introuvable' },
{ status: 404 }
);
}
// Vérifier que la facture a bien un PDF
if (!invoice.pdf_s3_key) {
console.error('[api/public/invoices/download] No PDF for invoice:', params.id);
return NextResponse.json(
{ error: 'PDF non disponible pour cette facture' },
{ status: 404 }
);
}
// Vérifier que c'est bien une facture studio (sécurité)
if (invoice.invoice_type !== 'studio_renouvellement' && invoice.invoice_type !== 'studio_site_web') {
console.warn('[api/public/invoices/download] Attempt to access non-studio invoice:', {
invoiceId: params.id,
type: invoice.invoice_type
});
// On pourrait bloquer ici, mais pour l'instant on laisse passer
// return NextResponse.json({ error: 'Accès non autorisé' }, { status: 403 });
}
// Générer le lien pré-signé S3
const bucket = (process.env.AWS_S3_BUCKET || 'odentas-docs').trim();
const region = process.env.AWS_REGION || 'eu-west-3';
const expireSeconds = 900; // 15 minutes - suffisant pour un téléchargement
const s3Client = new S3Client({ region });
const command = new GetObjectCommand({
Bucket: bucket,
Key: invoice.pdf_s3_key
});
const signedUrl = await getSignedUrl(s3Client, command, {
expiresIn: expireSeconds
});
console.log('[api/public/invoices/download] Generated presigned URL for invoice:', {
invoiceId: params.id,
invoiceNumber: invoice.invoice_number,
type: invoice.invoice_type,
expiresIn: `${expireSeconds}s`
});
// Rediriger vers le PDF
return NextResponse.redirect(signedUrl, 307); // 307 = Temporary Redirect
} catch (error) {
console.error('[api/public/invoices/download] Error:', error);
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json(
{ error: 'Erreur lors de la génération du lien de téléchargement', message },
{ status: 500 }
);
}
}

View file

@ -220,6 +220,14 @@ export async function POST(req: Request, { params }: { params: { id: string } })
const amountFormatted = new Intl.NumberFormat("fr-FR", { style: "currency", currency: "EUR" }).format(invoice.amount_ttc || 0);
const customMessage = buildInvoiceCustomMessage(invoice);
// Pour les factures studio, utiliser le lien de téléchargement direct
// Pour les factures paie, utiliser l'Espace Paie
const isStudioInvoice = invoice.invoice_type === 'studio_renouvellement' || invoice.invoice_type === 'studio_site_web';
const ctaUrl = isStudioInvoice
? `${process.env.NEXT_PUBLIC_SITE_URL}/api/public/invoices/${invoice.id}/download`
: `${process.env.NEXT_PUBLIC_SITE_URL}/facturation`;
await sendUniversalInvoiceEmail(
organizationDetails.email_notifs,
organizationDetails.email_notifs_cc,
@ -232,7 +240,8 @@ export async function POST(req: Request, { params }: { params: { id: string } })
customMessage,
invoiceDate: invoice.invoice_date ? new Date(invoice.invoice_date).toLocaleDateString('fr-FR') : undefined,
sepaDate: invoice.sepa_day ? new Date(invoice.sepa_day).toLocaleDateString('fr-FR') : undefined,
ctaUrl: `${process.env.NEXT_PUBLIC_SITE_URL}/facturation`,
ctaUrl,
invoiceType: invoice.invoice_type,
}
);

View file

@ -681,7 +681,7 @@ const EMAIL_TEMPLATES_V2: Record<EmailTypeV2, EmailTemplateV2> = {
greeting: '{{#if firstName}}Bonjour {{firstName}},{{/if}}',
mainMessage: '{{{customMessage}}}',
closingMessage: 'Si vous avez des questions concernant cette facture, <a href="mailto:paie@odentas.fr" style="color:#0B5FFF; text-decoration:none;">contactez-nous à paie@odentas.fr</a>.<br><br>L\'équipe Odentas vous remercie pour votre confiance.',
ctaText: 'Voir la facture',
ctaText: 'Télécharger la facture', // Sera "Voir la facture" pour paie via le template
footerText: 'Vous recevez cet e-mail car vous êtes client de Odentas, pour vous notifier d\'une action sur votre compte.',
preheaderText: 'Nouvelle facture · Accédez à votre espace',
colors: {
@ -1376,18 +1376,31 @@ export async function renderUniversalEmailV2(config: EmailConfigV2): Promise<{ s
greeting: templateConfig.greeting ? replaceVariables(templateConfig.greeting, data) : undefined,
mainMessage: processMainMessage(templateConfig.mainMessage, data),
closingMessage: templateConfig.closingMessage ? replaceVariables(templateConfig.closingMessage, data) : undefined,
ctaText: templateConfig.ctaText ? replaceVariables(templateConfig.ctaText, data) : undefined,
// Pour les factures studio, adapter le texte du bouton
ctaText: (config.type === 'invoice' && (data.invoiceType === 'studio_renouvellement' || data.invoiceType === 'studio_site_web'))
? 'Télécharger la facture'
: (config.type === 'invoice'
? 'Voir la facture'
: (templateConfig.ctaText ? replaceVariables(templateConfig.ctaText, data) : undefined)),
ctaSubtext: templateConfig.ctaSubtext ? replaceVariables(templateConfig.ctaSubtext, data) : undefined,
footerText: replaceVariables(templateConfig.footerText, data),
preheaderText: replaceVariables(templateConfig.preheaderText, data),
textFallback: `${replaceVariables(templateConfig.title, data)} - ${replaceVariables(templateConfig.mainMessage, data)}`,
ctaUrl: templateConfig.ctaUrl || (config.type === 'invoice' ? 'https://paie.odentas.fr/facturation' : data.ctaUrl),
// ctaUrl vient des data pour les factures (défini dans launch/route.ts)
ctaUrl: templateConfig.ctaUrl || data.ctaUrl,
logoUrl: 'https://newstaging.odentas.fr/wp-content/uploads/2025/08/Odentas-Logo-Bleu-Fond-Transparent-4-1.png',
showInfoCard: !!templateConfig.infoCard,
// Pour les factures studio, filtrer le code employeur de l'infoCard
infoCardRows: templateConfig.infoCard?.map(field => ({
label: field.label,
value: data[field.key] ?? '—'
})),
})).filter(row => {
// Exclure le code employeur pour les factures studio
if (config.type === 'invoice' && (data.invoiceType === 'studio_renouvellement' || data.invoiceType === 'studio_site_web')) {
return row.label !== 'Votre code employeur';
}
return true;
}),
showDetailsCard: !!templateConfig.detailsCard,
detailsCardTitle: templateConfig.detailsCard?.title,
detailsCardRows: templateConfig.detailsCard?.rows.map(field => ({