Files
Leon SerfatyandClaude Opus 4.8 5495b94924 Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 04:45:24 -04:00

105 lines
4.0 KiB
TypeScript

import { and, eq } from "drizzle-orm"
import { db } from "@/lib/db"
import { esign_connections } from "@/lib/db/schema"
import { encrypt, decrypt } from "@/lib/crypto"
import { getAdapter } from "./registry"
import type { ESignCredentials, ESignProvider, ESignTokens } from "./types"
// Per-owner e-sign connection storage + credential resolution. Mirrors
// lib/accounting/index.ts: tokens are AES-256-GCM encrypted at rest, decrypted
// on demand, and DocuSign access tokens are transparently refreshed near expiry.
/** Upsert an encrypted connection for (owner, provider). */
export async function saveEsignConnection(ownerId: string, provider: ESignProvider, tokens: ESignTokens) {
const values = {
user_id: ownerId,
provider,
access_token: encrypt(tokens.accessToken),
refresh_token: tokens.refreshToken ? encrypt(tokens.refreshToken) : null,
expires_at: tokens.expiresAt,
account_id: tokens.accountId,
base_uri: tokens.baseUri,
account_name: tokens.accountName,
status: "active" as const,
last_error: null,
}
const existing = await db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
columns: { id: true },
})
if (existing) {
await db
.update(esign_connections)
.set({ ...values, updated_at: new Date().toISOString() })
.where(eq(esign_connections.id, existing.id))
} else {
await db.insert(esign_connections).values(values)
}
}
export async function getEsignConnection(ownerId: string, provider: ESignProvider) {
return db.query.esign_connections.findFirst({
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
})
}
/** Owner-facing list — never leaks tokens. */
export async function listEsignConnections(ownerId: string) {
const rows = await db.query.esign_connections.findMany({ where: eq(esign_connections.user_id, ownerId) })
return rows.map((r) => ({
provider: r.provider,
accountName: r.account_name,
status: r.status,
lastError: r.last_error,
}))
}
export async function disconnectEsign(ownerId: string, provider: ESignProvider) {
await db
.delete(esign_connections)
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}
/**
* Resolve ready-to-use credentials for a connected account, refreshing the
* DocuSign access token first if it's near expiry. Returns null when the owner
* hasn't connected this provider.
*/
export async function resolveEsignCreds(ownerId: string, provider: ESignProvider): Promise<ESignCredentials | null> {
const conn = await getEsignConnection(ownerId, provider)
if (!conn || conn.status === "revoked") return null
let accessToken = decrypt(conn.access_token)
const refreshToken = conn.refresh_token ? decrypt(conn.refresh_token) : null
let accountId = conn.account_id
let baseUri = conn.base_uri
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 5 * 60_000
if (nearExpiry && refreshToken) {
const adapter = getAdapter(provider)
if (adapter) {
const next = await adapter.refresh(refreshToken)
// Account id / base uri are stable across refresh — keep the stored ones.
await saveEsignConnection(ownerId, provider, {
...next,
accountId: conn.account_id,
baseUri: conn.base_uri,
accountName: conn.account_name,
})
accessToken = next.accessToken
accountId = conn.account_id
baseUri = conn.base_uri
}
}
return { provider, accessToken, refreshToken, accountId, baseUri }
}
/** Flag a connection as errored (e.g. after a failed send/refresh). */
export async function markEsignError(ownerId: string, provider: ESignProvider, message: string) {
await db
.update(esign_connections)
.set({ status: "error", last_error: message.slice(0, 500), updated_at: new Date().toISOString() })
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
}