Files
2026-06-01 20:19:26 -04:00

234 lines
8.7 KiB
TypeScript

// Avria Sign — send envelope (in-house e-signature)
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface Recipient { name: string; email: string }
interface FieldPayload {
recipient_index: number;
field_type: "signature" | "date" | "name";
page_number: number;
x_ratio: number;
y_ratio: number;
width_ratio: number;
height_ratio: number;
}
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 });
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const serviceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const authHeader = req.headers.get("Authorization") || "";
const token = authHeader.replace("Bearer ", "");
const userClient = createClient(supabaseUrl, Deno.env.get("SUPABASE_ANON_KEY")!, {
global: { headers: { Authorization: authHeader } },
});
const { data: userData } = await userClient.auth.getUser(token);
if (!userData?.user) {
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
const admin = createClient(supabaseUrl, serviceKey);
const body = await req.json();
const {
association_id,
document_name,
document_url,
document_base64,
file_extension = "pdf",
recipients,
email_subject,
email_body,
fields = [],
}: {
association_id?: string;
document_name: string;
document_url?: string;
document_base64?: string;
file_extension?: string;
recipients: Recipient[];
email_subject?: string;
email_body?: string;
fields?: FieldPayload[];
} = body;
if (!document_name || !recipients?.length) {
return new Response(JSON.stringify({ error: "missing document_name or recipients" }), { status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
// 1) Insert envelope
const { data: env, error: envErr } = await admin.from("signature_envelopes").insert({
association_id: association_id || null,
document_name,
document_url: document_url || "",
email_subject,
email_body,
status: "sent",
created_by: userData.user.id,
sent_at: new Date().toISOString(),
}).select().single();
if (envErr) throw envErr;
// 2) If raw upload, store
let finalDocUrl = document_url || "";
if (document_base64) {
const binary = Uint8Array.from(atob(document_base64), c => c.charCodeAt(0));
const path = `signature-envelopes/${env.id}/original.${file_extension}`;
const { error: upErr } = await admin.storage.from("files").upload(path, binary, {
contentType: file_extension === "pdf" ? "application/pdf" : "application/octet-stream",
upsert: true,
});
if (upErr) throw upErr;
const { data: pub } = admin.storage.from("files").getPublicUrl(path);
finalDocUrl = pub.publicUrl;
await admin.from("signature_envelopes").update({ document_url: finalDocUrl }).eq("id", env.id);
}
// 3) Insert recipients
const recipientRows = recipients.map((r, idx) => ({
envelope_id: env.id,
name: r.name.trim(),
email: r.email.trim().toLowerCase(),
signing_order: idx + 1,
status: "pending",
}));
const { data: insertedRecips, error: rErr } = await admin.from("signature_recipients").insert(recipientRows).select();
if (rErr) throw rErr;
// 3b) Insert placed fields, mapping recipient_index -> inserted recipient id
if (fields.length > 0 && insertedRecips) {
const fieldRows = fields
.filter(f => insertedRecips[f.recipient_index])
.map(f => ({
envelope_id: env.id,
recipient_id: insertedRecips[f.recipient_index].id,
field_type: f.field_type,
page_number: f.page_number,
x_ratio: f.x_ratio,
y_ratio: f.y_ratio,
width_ratio: f.width_ratio,
height_ratio: f.height_ratio,
}));
if (fieldRows.length > 0) {
const { error: fErr } = await admin.from("signature_fields").insert(fieldRows);
if (fErr) console.warn("field insert error:", fErr);
}
}
// 4) Audit
await admin.from("signature_events").insert({
envelope_id: env.id,
event_type: "sent",
details: { recipient_count: recipients.length, document_name, field_count: fields.length },
});
// 5) Default sender
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;
// 6) Email + notifications
const appOrigin = req.headers.get("origin") || "https://avria.cloud";
const subject = email_subject || `Please sign: ${document_name}`;
for (const recip of insertedRecips || []) {
const signUrl = `${appOrigin}/sign/${recip.signing_token}`;
const html = `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#1e3a8a;">Signature Requested</h2>
<p>Hi ${recip.name},</p>
<p>You have been requested to electronically sign the following document:</p>
<p style="font-size:16px;font-weight:bold;color:#1e3a8a;">${document_name}</p>
${email_body ? `<p style="white-space:pre-wrap;">${email_body}</p>` : ""}
<p style="margin:30px 0;">
<a href="${signUrl}" style="background:#1e3a8a;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Review &amp; Sign Document</a>
</p>
<p style="font-size:12px;color:#666;">Or copy this link into your browser:<br/>${signUrl}</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>`;
if (smtpSender) {
try {
await admin.functions.invoke("send-smtp-email", {
body: {
sender: smtpSender,
recipient: recip.email,
subject,
html,
body: html,
fallback_user_id: userData.user.id,
feature_type: "signature_request",
},
});
} catch (e) {
console.warn(`SMTP email failed for ${recip.email}:`, e);
}
}
try {
const { data: existingUser } = await admin
.from("profiles").select("user_id").ilike("email", recip.email).maybeSingle();
const targetUserId = existingUser?.user_id || recip.user_id;
if (targetUserId) {
await admin.rpc("insert_notification", {
p_user_id: targetUserId,
p_type: "signature_request",
p_title: "Signature requested",
p_message: `Please sign: ${document_name}`,
p_related_item_id: env.id,
p_related_item_type: "signature_envelope",
p_link: `/sign/${recip.signing_token}`,
});
}
} catch (e) {
console.warn(`Notification failed for ${recip.email}:`, e);
}
}
return new Response(JSON.stringify({
envelope_id: env.id,
recipient_count: insertedRecips?.length || 0,
field_count: fields.length,
}), { headers: { ...corsHeaders, "Content-Type": "application/json" } });
} catch (err: any) {
console.error("avria-sign-send error:", err);
return new Response(JSON.stringify({ error: err.message || String(err) }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } });
}
});