105 lines
4.0 KiB
TypeScript
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)))
|
||
|
|
}
|