Files
property-management-network/lib/esign/index.ts
T
Leon SerfatyandClaude Opus 4.8 c9968531e4 Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07.
Major areas:
- Outbound webhooks / Zapier: schema + signed delivery with retries, public
  v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain.
- Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS
  (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when
  Spaces is unconfigured instead of silently using ephemeral disk.
- Integrations & features (concurrent work): accounting (QuickBooks/Xero),
  e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding,
  expanded legal pages.
- DB migrations 0006–0009.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:42:34 -04:00

98 lines
4.0 KiB
TypeScript

import { and, desc, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { leases, signature_requests } from "@/lib/db/schema"
import { readFile } from "@/lib/storage"
import { docusign } from "./docusign"
import { dropboxSign } from "./dropbox-sign"
import type { ESignAdapter, ESignProvider } from "./types"
export type { ESignProvider } from "./types"
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
export function getAdapter(id: string): ESignAdapter | null {
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
}
export function listAdapters() {
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
}
export function anyEsignConfigured(): boolean {
return listAdapters().some((a) => a.configured)
}
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
const prefix = "/api/files/"
if (documentUrl.startsWith(prefix)) {
const key = documentUrl.slice(prefix.length)
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
}
const res = await fetch(documentUrl)
if (!res.ok) throw new Error("Could not fetch the lease document")
return { bytes: Buffer.from(await res.arrayBuffer()), name: documentUrl.split("/").pop()?.split("?")[0] ?? "lease.pdf" }
}
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
const adapter = getAdapter(provider)
if (!adapter) throw new Error("Unknown provider")
if (!adapter.configured()) throw new Error(`${adapter.label} is not configured`)
const lease = await db.query.leases.findFirst({
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
with: { tenant: { columns: { first_name: true, last_name: true, email: true } } },
})
if (!lease) throw new Error("Lease not found")
if (!lease.document_url) throw new Error("Upload a lease document before sending it for signature")
const email = lease.tenant?.email
if (!email) throw new Error("The tenant has no email address on file")
const signerName = `${lease.tenant?.first_name ?? ""} ${lease.tenant?.last_name ?? ""}`.trim() || "Tenant"
const { bytes, name } = await getDocumentBytes(lease.document_url)
try {
const { externalId } = await adapter.send({
document: bytes,
documentName: name,
signerEmail: email,
signerName,
subject: "Please sign your lease agreement",
message: "Your landlord has sent your lease agreement for electronic signature.",
})
const [row] = await db
.insert(signature_requests)
.values({ user_id: ownerId, lease_id: leaseId, provider, external_id: externalId, status: "sent", signer_email: email, signer_name: signerName, document_name: name })
.returning()
return row
} catch (e) {
const msg = (e as Error).message.slice(0, 500)
await db
.insert(signature_requests)
.values({ user_id: ownerId, lease_id: leaseId, provider, status: "error", signer_email: email, signer_name: signerName, document_name: name, last_error: msg })
throw new Error(msg)
}
}
export async function listRequestsForLease(ownerId: string, leaseId: string) {
return db.query.signature_requests.findMany({
where: and(eq(signature_requests.user_id, ownerId), eq(signature_requests.lease_id, leaseId)),
orderBy: desc(signature_requests.sent_at),
})
}
/** Update a request's status from an inbound provider webhook. */
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
const adapter = getAdapter(provider)
if (!adapter) return
const result = adapter.parseWebhook(body, headers)
if (!result) return
await db
.update(signature_requests)
.set({
status: result.status,
completed_at: result.status === "signed" ? new Date().toISOString() : null,
updated_at: new Date().toISOString(),
})
.where(eq(signature_requests.external_id, result.externalId))
}