Files
cachetdeco/src/pages/api/submission.ts
mouad bouras 067a151be1
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
1.01 audrey's edits
2026-03-28 15:37:45 -04:00

247 lines
9.7 KiB
TypeScript

import { Buffer } from 'node:buffer';
import type { APIRoute } from 'astro';
import type { EmailAttachment } from '@/lib/email';
import { sendEmail } from '@/lib/email';
import general from '@/content/settings/general.json';
export const prerender = false;
const MAX_TOTAL_BYTES = 20 * 1024 * 1024;
const ACCEPTED_MIME = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
]);
interface SubmissionPayload {
fullName: string;
email: string;
phone: string;
workAddress?: string;
serviceType: string;
message: string;
}
const SERVICE_TYPE_LABELS: Record<string, string> = {
interior: 'Peinture intérieure',
exterior: 'Peinture extérieure',
commercial: 'Peinture commerciale',
decoration: 'Décoration intérieure',
other: 'Autre',
};
function isValidPayload(body: unknown): body is SubmissionPayload {
if (typeof body !== 'object' || body === null) return false;
const b = body as Record<string, unknown>;
return (
typeof b.fullName === 'string' && b.fullName.trim().length > 0 &&
typeof b.email === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(b.email) &&
typeof b.phone === 'string' && b.phone.trim().length > 0 &&
typeof b.serviceType === 'string' && b.serviceType.length > 0 &&
typeof b.message === 'string' && b.message.trim().length > 0
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function sanitizeFilename(name: string): string {
const base = name.replace(/[/\\]/g, '_').trim() || 'file';
return base.length > 200 ? base.slice(0, 200) : base;
}
function isFileLike(v: unknown): v is File {
return (
typeof v === 'object' &&
v !== null &&
'arrayBuffer' in v &&
typeof (v as File).name === 'string' &&
typeof (v as File).size === 'number' &&
typeof (v as File).type === 'string'
);
}
function isAcceptedFile(file: File): boolean {
if (ACCEPTED_MIME.has(file.type)) return true;
if (file.type) return false;
return /\.(jpe?g|png|gif|webp|svg|pdf|doc|docx)$/i.test(file.name);
}
function buildNotificationHtml(data: SubmissionPayload, attachmentNames: string[]): string {
const serviceLabel = SERVICE_TYPE_LABELS[data.serviceType] ?? data.serviceType;
const workAddressRow = data.workAddress
? `<tr><td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee;width:40%">Adresse des travaux</td><td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee">${escapeHtml(data.workAddress)}</td></tr>`
: '';
const attachmentsSection =
attachmentNames.length > 0
? `<div style="padding:16px 20px;background:#fafafa;border-top:1px solid #eee">
<p style="margin:0;font-size:13px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:0.05em">Fichiers joints</p>
<ul style="margin:8px 0 0;padding-left:20px;font-size:14px;color:#374151">${attachmentNames.map((n) => `<li>${escapeHtml(n)}</li>`).join('')}</ul>
</div>`
: '';
return `<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0;padding:0;background:#f5f5f5;font-family:system-ui,sans-serif">
<div style="max-width:600px;margin:0 auto;padding:40px 20px">
<div style="background:#314732;padding:32px;border-radius:8px 8px 0 0;text-align:center">
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">Nouvelle demande de soumission</h1>
<p style="margin:8px 0 0;color:rgba(255,255,255,0.7);font-size:14px">Cachet Peintres Décorateurs</p>
</div>
<div style="background:#ffffff;border-radius:0 0 8px 8px;overflow:hidden">
<div style="padding:24px;background:#f0f4ef;border-bottom:2px solid #d6e2d4">
<p style="margin:0;font-size:15px;color:#314732;font-weight:600">
Vous avez reçu une nouvelle demande de soumission de la part de <strong>${escapeHtml(data.fullName)}</strong>.
</p>
</div>
<table style="width:100%;border-collapse:collapse">
<tbody>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee;width:40%">Nom</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee;font-weight:600">${escapeHtml(data.fullName)}</td>
</tr>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Courriel</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee"><a href="mailto:${data.email}" style="color:#314732;font-weight:600">${escapeHtml(data.email)}</a></td>
</tr>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Téléphone</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee"><a href="tel:${data.phone.replace(/\D/g, '')}" style="color:#314732;font-weight:600">${escapeHtml(data.phone)}</a></td>
</tr>
<tr>
<td style="padding:8px 12px;color:#666;font-size:14px;border-bottom:1px solid #eee">Type de service</td>
<td style="padding:8px 12px;font-size:14px;border-bottom:1px solid #eee">${serviceLabel}</td>
</tr>
${workAddressRow}
</tbody>
</table>
<div style="padding:20px">
<p style="margin:0 0 8px;font-size:13px;font-weight:700;color:#666;text-transform:uppercase;letter-spacing:0.05em">Message</p>
<div style="background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;padding:16px;font-size:14px;color:#374151;line-height:1.6;white-space:pre-wrap">${escapeHtml(data.message)}</div>
</div>
${attachmentsSection}
<div style="padding:16px 20px 24px">
<a href="mailto:${data.email}?subject=Re: Soumission — Cachet Peintres Décorateurs" style="display:inline-block;padding:10px 24px;background:#314732;color:#fff;text-decoration:none;border-radius:4px;font-size:14px;font-weight:700">Répondre à ${escapeHtml(data.fullName)}</a>
</div>
</div>
<p style="margin:24px 0 0;text-align:center;font-size:11px;color:#999">
Cachet Peintres Décorateurs · RBQ 5839 8736 01
</p>
</div>
</body>
</html>`;
}
export const POST: APIRoute = async ({ request }) => {
const contentType = request.headers.get('content-type') ?? '';
if (!contentType.includes('multipart/form-data')) {
return new Response(JSON.stringify({ success: false, error: 'Invalid Content-Type.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
let formData: FormData;
try {
formData = await request.formData();
} catch {
return new Response(JSON.stringify({ success: false, error: 'Invalid request body.' }), {
status: 400,
headers: { 'Content-Type': 'application/json' },
});
}
const payload: SubmissionPayload = {
fullName: String(formData.get('fullName') ?? '').trim(),
email: String(formData.get('email') ?? '').trim(),
phone: String(formData.get('phone') ?? '').trim(),
workAddress: String(formData.get('workAddress') ?? '').trim() || undefined,
serviceType: String(formData.get('serviceType') ?? '').trim(),
message: String(formData.get('message') ?? '').trim(),
};
if (!isValidPayload(payload)) {
return new Response(JSON.stringify({ success: false, error: 'Missing or invalid fields.' }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
});
}
const rawFiles = formData.getAll('files');
const files: File[] = [];
for (const item of rawFiles) {
if (!isFileLike(item) || item.size === 0) continue;
if (!isAcceptedFile(item)) {
return new Response(JSON.stringify({ success: false, error: 'Unsupported file type.' }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
});
}
files.push(item);
}
let totalBytes = 0;
const attachments: EmailAttachment[] = [];
const attachmentNames: string[] = [];
for (const file of files) {
totalBytes += file.size;
if (totalBytes > MAX_TOTAL_BYTES) {
return new Response(JSON.stringify({ success: false, error: 'Total upload size exceeds 20 MB.' }), {
status: 422,
headers: { 'Content-Type': 'application/json' },
});
}
const safeName = sanitizeFilename(file.name);
attachmentNames.push(safeName);
const buf = await file.arrayBuffer();
attachments.push({
filename: safeName,
content: Buffer.from(buf).toString('base64'),
});
}
const apiKey = process.env.RESEND_API_KEY;
const fromAddress = general.fromAddressSubmission?.trim();
const toAddress = general.toAddressSubmission?.trim();
if (!apiKey || !fromAddress || !toAddress) {
return new Response(JSON.stringify({ success: false, error: 'Server configuration missing.' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
try {
await sendEmail(apiKey, {
from: fromAddress,
to: toAddress,
replyTo: payload.email,
subject: `Soumission — ${SERVICE_TYPE_LABELS[payload.serviceType] ?? payload.serviceType}${payload.fullName}`,
html: buildNotificationHtml(payload, attachmentNames),
attachments: attachments.length ? attachments : undefined,
});
} catch {
return new Response(JSON.stringify({ success: false, error: 'Failed to send email.' }), {
status: 502,
headers: { 'Content-Type': 'application/json' },
});
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
};