2026-07-02 13:42:34 -04:00
|
|
|
import { and, desc, eq } from "drizzle-orm"
|
|
|
|
|
import { db } from "@/lib/db"
|
|
|
|
|
import { leases, signature_requests } from "@/lib/db/schema"
|
2026-07-03 04:45:24 -04:00
|
|
|
import { readFile, saveBuffer } from "@/lib/storage"
|
|
|
|
|
import { getAdapter } from "./registry"
|
|
|
|
|
import { resolveEsignCreds, markEsignError } from "./credentials"
|
|
|
|
|
import type { ESignProvider } from "./types"
|
2026-07-02 13:42:34 -04:00
|
|
|
|
|
|
|
|
export type { ESignProvider } from "./types"
|
2026-07-03 04:45:24 -04:00
|
|
|
export { getAdapter, listEsignAdapters } from "./registry"
|
|
|
|
|
export {
|
|
|
|
|
resolveEsignCreds,
|
|
|
|
|
listEsignConnections,
|
|
|
|
|
getEsignConnection,
|
|
|
|
|
saveEsignConnection,
|
|
|
|
|
disconnectEsign,
|
|
|
|
|
} from "./credentials"
|
2026-07-02 13:42:34 -04:00
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
const FILES_PREFIX = "/api/files/"
|
2026-07-02 13:42:34 -04:00
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
/** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
|
|
|
|
|
function webhookUrl(provider: ESignProvider): string {
|
|
|
|
|
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
|
|
|
|
return `${base}/api/esign/${provider}/webhook`
|
2026-07-02 13:42:34 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
|
2026-07-02 13:42:34 -04:00
|
|
|
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
|
2026-07-03 04:45:24 -04:00
|
|
|
if (!documentUrl.startsWith(FILES_PREFIX)) {
|
|
|
|
|
throw new Error("Lease document must be an uploaded file")
|
2026-07-02 13:42:34 -04:00
|
|
|
}
|
2026-07-03 04:45:24 -04:00
|
|
|
const key = documentUrl.slice(FILES_PREFIX.length)
|
|
|
|
|
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
|
2026-07-02 13:42:34 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
/**
|
|
|
|
|
* Send a lease for signature through the owner's OWN connected account.
|
|
|
|
|
* Requires the provider to be connected (per-user OAuth / API key).
|
|
|
|
|
*/
|
2026-07-02 13:42:34 -04:00
|
|
|
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
|
|
|
|
|
const adapter = getAdapter(provider)
|
|
|
|
|
if (!adapter) throw new Error("Unknown provider")
|
2026-07-03 04:45:24 -04:00
|
|
|
|
|
|
|
|
const creds = await resolveEsignCreds(ownerId, provider)
|
|
|
|
|
if (!creds) throw new Error(`Connect your ${adapter.label} account in Settings → Integrations first`)
|
2026-07-02 13:42:34 -04:00
|
|
|
|
|
|
|
|
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 {
|
2026-07-03 04:45:24 -04:00
|
|
|
const { externalId } = await adapter.send(creds, {
|
2026-07-02 13:42:34 -04:00
|
|
|
document: bytes,
|
|
|
|
|
documentName: name,
|
|
|
|
|
signerEmail: email,
|
|
|
|
|
signerName,
|
|
|
|
|
subject: "Please sign your lease agreement",
|
|
|
|
|
message: "Your landlord has sent your lease agreement for electronic signature.",
|
2026-07-03 04:45:24 -04:00
|
|
|
webhookUrl: webhookUrl(provider),
|
2026-07-02 13:42:34 -04:00
|
|
|
})
|
|
|
|
|
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)
|
2026-07-03 04:45:24 -04:00
|
|
|
await markEsignError(ownerId, provider, msg)
|
2026-07-02 13:42:34 -04:00
|
|
|
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),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-03 04:45:24 -04:00
|
|
|
/**
|
|
|
|
|
* Process an inbound provider webhook. The body is UNTRUSTED: we use it only to
|
|
|
|
|
* find which signature request (and therefore which owner + credentials) it
|
|
|
|
|
* concerns, then authenticate the event via the adapter (DocuSign pull-verify /
|
|
|
|
|
* Dropbox HMAC) before updating status and archiving the signed document.
|
|
|
|
|
*/
|
2026-07-02 13:42:34 -04:00
|
|
|
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
|
|
|
|
|
const adapter = getAdapter(provider)
|
|
|
|
|
if (!adapter) return
|
2026-07-03 04:45:24 -04:00
|
|
|
|
|
|
|
|
const externalId = adapter.peekExternalId(body)
|
|
|
|
|
if (!externalId) return
|
|
|
|
|
|
|
|
|
|
const reqRow = await db.query.signature_requests.findFirst({
|
|
|
|
|
where: eq(signature_requests.external_id, externalId),
|
|
|
|
|
columns: { id: true, user_id: true, status: true },
|
|
|
|
|
})
|
|
|
|
|
if (!reqRow) return
|
|
|
|
|
|
|
|
|
|
const creds = await resolveEsignCreds(reqRow.user_id, provider as ESignProvider)
|
|
|
|
|
if (!creds) return
|
|
|
|
|
|
|
|
|
|
const status = await adapter.verifyAndGetStatus(creds, externalId, body, headers)
|
|
|
|
|
if (!status) return
|
|
|
|
|
|
2026-07-02 13:42:34 -04:00
|
|
|
await db
|
|
|
|
|
.update(signature_requests)
|
|
|
|
|
.set({
|
2026-07-03 04:45:24 -04:00
|
|
|
status,
|
|
|
|
|
completed_at: status === "signed" ? new Date().toISOString() : null,
|
2026-07-02 13:42:34 -04:00
|
|
|
updated_at: new Date().toISOString(),
|
|
|
|
|
})
|
2026-07-03 04:45:24 -04:00
|
|
|
.where(eq(signature_requests.id, reqRow.id))
|
|
|
|
|
|
|
|
|
|
// Archive the executed document so the landlord can download the signed copy.
|
|
|
|
|
if (status === "signed") {
|
|
|
|
|
try {
|
|
|
|
|
const bytes = await adapter.getSignedDocument(creds, externalId)
|
|
|
|
|
if (bytes && bytes.length) {
|
|
|
|
|
const { key } = await saveBuffer(bytes, { userId: reqRow.user_id, scope: "esign", ext: "pdf" })
|
|
|
|
|
await db
|
|
|
|
|
.update(signature_requests)
|
|
|
|
|
.set({ signed_document_url: `${FILES_PREFIX}${key}` })
|
|
|
|
|
.where(eq(signature_requests.id, reqRow.id))
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// Best-effort — status is already recorded.
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-02 13:42:34 -04:00
|
|
|
}
|