import { eq, and, desc } from 'drizzle-orm'; import { db } from 'officerdb/db'; import { invoicesAccounts } from './schema'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; // InvoiceShelf account registry for the officer-invoiceshelf sidecar. Callers deal in PLAINTEXT — encryption // to and from at-rest ciphertext happens here. See ../crypto.ts and ../schema/invoiceshelf.ts. // // Two return types, and the split is the safety property: // InvoiceshelfAccount — safe to serialize to the browser. Has NO token field at all, not even masked. // InvoiceshelfCredentials — url + decrypted token + company, for the sidecar's own upstream calls. // `accountCols` is what enforces it: a bare `select()` would put the ciphertext column into every list // response the moment someone forgot to strip it. export type InvoiceshelfAccount = { id: number; label: string; url: string; companyId: number | null; companyName: string | null; version: string | null; isActive: boolean; lastSeenAt: Date | null; createdAt: Date; }; export type InvoiceshelfCredentials = { id: number; label: string; url: string; token: string; companyId: number | null; }; const accountCols = { id: invoicesAccounts.id, label: invoicesAccounts.label, url: invoicesAccounts.url, companyId: invoicesAccounts.companyId, companyName: invoicesAccounts.companyName, version: invoicesAccounts.version, isActive: invoicesAccounts.isActive, lastSeenAt: invoicesAccounts.lastSeenAt, createdAt: invoicesAccounts.createdAt, }; /** Every account the owner has added, active first then newest. Never includes the token. */ export async function listInvoiceshelfAccounts(userId: number): Promise { return db .select(accountCols) .from(invoicesAccounts) .where(eq(invoicesAccounts.userId, userId)) .orderBy(desc(invoicesAccounts.isActive), desc(invoicesAccounts.createdAt)); } /** The selected account with its token decrypted, or null when none is added. */ export async function getActiveInvoiceshelfCredentials(userId: number): Promise { const [row] = await db .select() .from(invoicesAccounts) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.isActive, true))); if (!row) return null; return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId, }; } /** One account's credentials by id — for probing a specific account rather than the active one. */ export async function getInvoiceshelfCredentials(userId: number, id: number): Promise { const [row] = await db .select() .from(invoicesAccounts) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))); if (!row) return null; return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId, }; } type CreateInvoiceshelfAccountParams = { userId: number; label: string; url: string; token: string; companyId: number | null; companyName: string | null; version: string | null; /** Select it. True for the first account, so the UI is never left with accounts added but none chosen. */ activate: boolean; }; /** Add an account. The token is encrypted before write; the returned row carries no token. */ export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccountParams): Promise { const { userId, label, url, token, companyId, companyName, version, activate } = params; return db.transaction(async (tx) => { if (activate) { await tx .update(invoicesAccounts) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.isActive, true))); } const [row] = await tx .insert(invoicesAccounts) .values({ userId, label, url, token: encryptSecret('invoiceshelf', token), companyId, companyName, version, isActive: activate, lastSeenAt: version ? new Date() : null, }) .returning(accountCols); return row!; }); } type UpdateInvoiceshelfAccountParams = { label?: string; url?: string; token?: string; companyId?: number | null; companyName?: string | null; version?: string | null; }; /** Edit an account. Omitted fields are left alone; a supplied token is re-encrypted. */ export async function updateInvoiceshelfAccount( userId: number, id: number, params: UpdateInvoiceshelfAccountParams, ): Promise { const set: Record = { updatedAt: new Date() }; if (params.label !== undefined) set.label = params.label; if (params.url !== undefined) set.url = params.url; if (params.token !== undefined) set.token = encryptSecret('invoiceshelf', params.token); if (params.companyId !== undefined) set.companyId = params.companyId; if (params.companyName !== undefined) set.companyName = params.companyName; if (params.version !== undefined) set.version = params.version; const [row] = await db .update(invoicesAccounts) .set(set) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))) .returning(accountCols); return row ?? null; } /** Switch accounts. Clearing the others first keeps the one-active partial index satisfied. */ export async function setActiveInvoiceshelfAccount(userId: number, id: number): Promise { return db.transaction(async (tx) => { await tx .update(invoicesAccounts) .set({ isActive: false, updatedAt: new Date() }) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.isActive, true))); const [row] = await tx .update(invoicesAccounts) .set({ isActive: true, updatedAt: new Date() }) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))) .returning(accountCols); return row ?? null; }); } /** * Remove an account. If it was the active one the newest survivor is promoted — otherwise removing the * account in use would leave the owner with accounts added but none selected, which reads as "not connected" * and is a confusing place to land. */ export async function deleteInvoiceshelfAccount(userId: number, id: number): Promise { return db.transaction(async (tx) => { const [deleted] = await tx .delete(invoicesAccounts) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))) .returning({ id: invoicesAccounts.id, wasActive: invoicesAccounts.isActive }); if (!deleted) return false; if (deleted.wasActive) { const [next] = await tx .select({ id: invoicesAccounts.id }) .from(invoicesAccounts) .where(eq(invoicesAccounts.userId, userId)) .orderBy(desc(invoicesAccounts.createdAt)) .limit(1); if (next) { await tx .update(invoicesAccounts) .set({ isActive: true, updatedAt: new Date() }) .where(eq(invoicesAccounts.id, next.id)); } } return true; }); } /** Stamp a successful probe, so the UI can tell "never reached" from "was reachable, now isn't". */ export async function recordInvoiceshelfProbe(userId: number, id: number, version: string | null): Promise { await db .update(invoicesAccounts) .set({ version, lastSeenAt: new Date() }) .where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id))); }