mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
263 lines
12 KiB
TypeScript
263 lines
12 KiB
TypeScript
// Avria Sign — stamp signed PDF (using placed fields when available) and append validation certificate
|
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
|
|
import { PDFDocument, StandardFonts, rgb } from "https://esm.sh/pdf-lib@1.17.1";
|
|
|
|
const corsHeaders = {
|
|
"Access-Control-Allow-Origin": "*",
|
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
|
};
|
|
|
|
function toSmtpSenderConfig(sender: any) {
|
|
const port = Number(sender?.smtp_port ?? 587);
|
|
const isImplicitSslPort = port === 465;
|
|
const isStartTlsPort = port === 587;
|
|
|
|
return {
|
|
host: sender.smtp_host,
|
|
port,
|
|
username: sender.smtp_username,
|
|
password: sender.smtp_password,
|
|
use_ssl: isImplicitSslPort ? true : sender.use_ssl ?? false,
|
|
use_tls: isImplicitSslPort ? false : sender.use_tls ?? isStartTlsPort,
|
|
from: sender.sender_name
|
|
? `${sender.sender_name} <${sender.email_address}>`
|
|
: sender.email_address,
|
|
fromEmail: sender.email_address,
|
|
fromName: sender.sender_name,
|
|
envelopeFrom: sender.smtp_username || sender.email_address,
|
|
signature_html: sender.signature_html || "",
|
|
};
|
|
}
|
|
|
|
Deno.serve(async (req) => {
|
|
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
|
|
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
|
|
|
|
try {
|
|
const { envelope_id } = await req.json();
|
|
if (!envelope_id) return json({ error: "missing envelope_id" }, 400);
|
|
|
|
const { data: env } = await admin
|
|
.from("signature_envelopes").select("*").eq("id", envelope_id).maybeSingle();
|
|
if (!env) return json({ error: "envelope not found" }, 404);
|
|
|
|
const { data: recipients } = await admin
|
|
.from("signature_recipients").select("*").eq("envelope_id", envelope_id).order("signing_order");
|
|
|
|
const { data: allFields } = await admin
|
|
.from("signature_fields").select("*").eq("envelope_id", envelope_id);
|
|
|
|
const pdfRes = await fetch(env.document_url);
|
|
if (!pdfRes.ok) throw new Error("Could not fetch original document");
|
|
const pdfBytes = new Uint8Array(await pdfRes.arrayBuffer());
|
|
|
|
let pdfDoc: PDFDocument;
|
|
try {
|
|
pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
|
|
} catch {
|
|
pdfDoc = await PDFDocument.create();
|
|
const p = pdfDoc.addPage();
|
|
p.drawText("Original document could not be embedded.", { x: 50, y: 700, size: 12 });
|
|
}
|
|
|
|
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
const helvBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
|
|
const pages = pdfDoc.getPages();
|
|
|
|
// Cache embedded signature images per recipient
|
|
const sigImageCache = new Map<string, any>();
|
|
for (const r of recipients || []) {
|
|
if (!r.signature_data_url) continue;
|
|
try {
|
|
const isPng = r.signature_data_url.includes("image/png");
|
|
const base64 = r.signature_data_url.split(",")[1] || "";
|
|
const sigBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
|
|
const img = isPng ? await pdfDoc.embedPng(sigBytes) : await pdfDoc.embedJpg(sigBytes);
|
|
sigImageCache.set(r.id, img);
|
|
} catch (e) { console.warn("sig embed failed:", r.id, e); }
|
|
}
|
|
|
|
const fieldsByRecipient = new Map<string, any[]>();
|
|
(allFields || []).forEach(f => {
|
|
const arr = fieldsByRecipient.get(f.recipient_id) || [];
|
|
arr.push(f); fieldsByRecipient.set(f.recipient_id, arr);
|
|
});
|
|
|
|
const hasPlacedFields = (allFields || []).length > 0;
|
|
|
|
if (hasPlacedFields) {
|
|
// Stamp at each placed field location
|
|
for (const r of recipients || []) {
|
|
const fields = fieldsByRecipient.get(r.id) || [];
|
|
const sigImg = sigImageCache.get(r.id);
|
|
const signedDate = r.signed_at ? new Date(r.signed_at) : new Date();
|
|
const dateStr = signedDate.toLocaleDateString("en-US", { timeZone: "America/New_York" });
|
|
|
|
for (const f of fields) {
|
|
const pageIdx = Math.min((f.page_number || 1) - 1, pages.length - 1);
|
|
const page = pages[pageIdx];
|
|
const { width: pw, height: ph } = page.getSize();
|
|
// UI uses top-left origin; pdf-lib uses bottom-left
|
|
const x = f.x_ratio * pw;
|
|
const y = ph - (f.y_ratio + f.height_ratio) * ph;
|
|
const w = f.width_ratio * pw;
|
|
const h = f.height_ratio * ph;
|
|
|
|
if (f.field_type === "signature" && sigImg) {
|
|
// Fit signature into box preserving aspect ratio
|
|
const ratio = Math.min(w / sigImg.width, h / sigImg.height);
|
|
const drawW = sigImg.width * ratio;
|
|
const drawH = sigImg.height * ratio;
|
|
page.drawImage(sigImg, {
|
|
x: x + (w - drawW) / 2,
|
|
y: y + (h - drawH) / 2,
|
|
width: drawW, height: drawH,
|
|
});
|
|
} else if (f.field_type === "date") {
|
|
page.drawText(dateStr, { x: x + 4, y: y + h / 2 - 4, size: Math.min(11, h * 0.7), font: helv, color: rgb(0, 0, 0) });
|
|
} else if (f.field_type === "name") {
|
|
page.drawText(r.name, { x: x + 4, y: y + h / 2 - 4, size: Math.min(11, h * 0.7), font: helvBold, color: rgb(0, 0, 0) });
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Fallback: stamp on bottom of last page
|
|
const lastPage = pages[pages.length - 1];
|
|
let stampY = 80;
|
|
for (const r of recipients || []) {
|
|
const sigImg = sigImageCache.get(r.id);
|
|
if (!sigImg) continue;
|
|
const sigDims = sigImg.scale(120 / sigImg.width);
|
|
lastPage.drawImage(sigImg, { x: 50, y: stampY, width: sigDims.width, height: sigDims.height });
|
|
lastPage.drawText(`${r.name}`, { x: 180, y: stampY + 25, size: 9, font: helvBold, color: rgb(0, 0, 0) });
|
|
lastPage.drawText(`Signed: ${new Date(r.signed_at).toLocaleString("en-US", { timeZone: "America/New_York" })} EST`, {
|
|
x: 180, y: stampY + 12, size: 8, font: helv, color: rgb(0.3, 0.3, 0.3),
|
|
});
|
|
lastPage.drawText(`ID: ${r.id.slice(0, 8).toUpperCase()}`, {
|
|
x: 180, y: stampY, size: 7, font: helv, color: rgb(0.5, 0.5, 0.5),
|
|
});
|
|
stampY += 60;
|
|
if (stampY > 250) break;
|
|
}
|
|
}
|
|
|
|
// Append Certificate of Validation page
|
|
const cert = pdfDoc.addPage();
|
|
const { width, height } = cert.getSize();
|
|
|
|
cert.drawRectangle({ x: 30, y: 30, width: width - 60, height: height - 60, borderColor: rgb(0, 0, 0), borderWidth: 2 });
|
|
cert.drawRectangle({ x: 40, y: 40, width: width - 80, height: height - 80, borderColor: rgb(0, 0, 0), borderWidth: 0.5 });
|
|
|
|
cert.drawText("CERTIFICATE OF VALIDATION", { x: width / 2 - 165, y: height - 110, size: 22, font: helvBold });
|
|
cert.drawText("Cryptographic proof of document signing and integrity.", {
|
|
x: width / 2 - 175, y: height - 135, size: 10, font: helv, color: rgb(0.4, 0.4, 0.4),
|
|
});
|
|
|
|
let y = height - 200;
|
|
const drawRow = (label: string, value: string) => {
|
|
cert.drawText(label, { x: 70, y, size: 11, font: helvBold });
|
|
cert.drawText(value, { x: 230, y, size: 11, font: helv });
|
|
cert.drawLine({ start: { x: 70, y: y - 5 }, end: { x: width - 70, y: y - 5 }, thickness: 0.3, color: rgb(0.7, 0.7, 0.7) });
|
|
y -= 30;
|
|
};
|
|
|
|
drawRow("Document Name:", env.document_name);
|
|
drawRow("Envelope ID:", env.id);
|
|
drawRow("Issued By:", "Avria Community Management");
|
|
drawRow("Completed:", new Date(env.completed_at || Date.now()).toLocaleString("en-US", { timeZone: "America/New_York" }) + " EST");
|
|
drawRow("Status:", "COMPLETED");
|
|
|
|
y -= 10;
|
|
cert.drawText("SIGNERS", { x: 70, y, size: 12, font: helvBold });
|
|
y -= 20;
|
|
|
|
for (const r of recipients || []) {
|
|
cert.drawText(`• ${r.name} <${r.email}>`, { x: 80, y, size: 10, font: helv });
|
|
y -= 14;
|
|
if (r.signed_at) {
|
|
cert.drawText(
|
|
` Signed ${new Date(r.signed_at).toLocaleString("en-US", { timeZone: "America/New_York" })} EST` +
|
|
(r.signed_ip ? ` from ${r.signed_ip}` : "") +
|
|
` (${r.signature_method || "draw"})`,
|
|
{ x: 80, y, size: 8, font: helv, color: rgb(0.4, 0.4, 0.4) }
|
|
);
|
|
y -= 12;
|
|
}
|
|
cert.drawText(` Signature ID: ${r.id}`, { x: 80, y, size: 7, font: helv, color: rgb(0.5, 0.5, 0.5) });
|
|
y -= 16;
|
|
}
|
|
|
|
cert.drawCircle({ x: width - 110, y: 110, size: 45, borderColor: rgb(0.2, 0.4, 0.7), borderWidth: 3, opacity: 0.6 });
|
|
cert.drawText("OFFICIAL", { x: width - 130, y: 110, size: 9, font: helvBold, color: rgb(0.2, 0.4, 0.7) });
|
|
cert.drawText("SEAL", { x: width - 122, y: 98, size: 9, font: helvBold, color: rgb(0.2, 0.4, 0.7) });
|
|
|
|
const finalBytes = await pdfDoc.save();
|
|
const path = `signature-envelopes/${envelope_id}/signed.pdf`;
|
|
const { error: upErr } = await admin.storage.from("files").upload(path, finalBytes, {
|
|
contentType: "application/pdf", upsert: true,
|
|
});
|
|
if (upErr) throw upErr;
|
|
const { data: pub } = admin.storage.from("files").getPublicUrl(path);
|
|
|
|
await admin.from("signature_envelopes").update({ signed_document_url: pub.publicUrl }).eq("id", envelope_id);
|
|
|
|
// Notify all signers + creator
|
|
const { data: envFull } = await admin.from("signature_envelopes").select("created_by, document_name").eq("id", envelope_id).single();
|
|
const recipientEmails = (recipients || []).map(r => r.email);
|
|
let creatorEmail: string | null = null;
|
|
if (envFull?.created_by) {
|
|
const { data: creator } = await admin.auth.admin.getUserById(envFull.created_by);
|
|
creatorEmail = creator?.user?.email || null;
|
|
}
|
|
const allEmails = Array.from(new Set([...recipientEmails, ...(creatorEmail ? [creatorEmail] : [])]));
|
|
|
|
const { data: defaultSender } = await admin
|
|
.from("email_senders").select("smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl, email_address, sender_name, signature_html").eq("is_active", true).eq("is_default", true)
|
|
.order("updated_at", { ascending: false }).limit(1).maybeSingle();
|
|
|
|
const smtpSender = defaultSender ? toSmtpSenderConfig(defaultSender) : null;
|
|
|
|
for (const email of allEmails) {
|
|
try {
|
|
if (smtpSender) {
|
|
const html = `
|
|
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
|
|
<h2 style="color:#059669;">Document Signed</h2>
|
|
<p>The document <strong>${envFull?.document_name || "Document"}</strong> has been signed by all parties.</p>
|
|
<p style="margin:20px 0;">
|
|
<a href="${pub.publicUrl}" style="background:#1e3a8a;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Download Signed Copy</a>
|
|
</p>
|
|
<hr style="margin-top:30px;border:none;border-top:1px solid #eee;"/>
|
|
<p style="font-size:11px;color:#999;">Avria Sign — Secure Electronic Signatures</p>
|
|
</div>`;
|
|
await admin.functions.invoke("send-smtp-email", {
|
|
body: {
|
|
sender: smtpSender,
|
|
recipient: email,
|
|
subject: `Signed: ${envFull?.document_name || "Document"}`,
|
|
html,
|
|
body: html,
|
|
fallback_user_id: envFull?.created_by || null,
|
|
feature_type: "signature_completed",
|
|
},
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn("Completion email failed for", email, e);
|
|
}
|
|
}
|
|
|
|
return json({ ok: true, signed_document_url: pub.publicUrl });
|
|
} catch (err: any) {
|
|
console.error("avria-sign-stamp error:", err);
|
|
return json({ error: err.message || String(err) }, 500);
|
|
}
|
|
});
|
|
|
|
function json(payload: unknown, status = 200) {
|
|
return new Response(JSON.stringify(payload), {
|
|
status,
|
|
headers: { ...corsHeaders, "Content-Type": "application/json" },
|
|
});
|
|
}
|