Files
invoices/db/queries.ts
T
officer 6c70eb8eb3 invoices, extracted from the platform as a plugin
Ninth plugin out, third resurrection: switched off since 2026-08-13 with the
permission reduced to api: [], so the server booted clean and every panel
request 404'd. invoiceshelf/schema was also commented out of the drizzle
barrel.

Named `invoices`, not `invoiceshelf`. The appName drives the API prefix, the
frontend route, the table prefix and the install key all at once, and before
extraction those had drifted: the API was /api/invoiceshelf while the screen
was /invoices. The URL is what a person sees and should name what the plugin
shows rather than the daemon behind it — the permission key and label were
already `invoices`, so the API prefix was the odd one out.

Cost: three call sites, and renaming invoiceshelf_accounts to invoices_accounts
to satisfy the table-prefix rule. That rename was free ONLY because the table
existed on no server, which was checked before deciding. With rows in it,
keeping the vendor name would have been the better trade: db:push sees a rename
as a drop plus a create and this project has no migration mechanism.

The sidecar keeps its Invoiceshelf-named functions deliberately — they name the
upstream API it actually speaks.

91 palette classes converted, 52 of them in STATUS_TONE, which was already the
single source of status colour but held raw palette strings. SENT and VIEWED
now share `info`: the closed six-tone set has no second shade of "in flight,
not yet actioned". Recorded in PLUGIN.md as a real loss.
2026-08-19 03:47:44 +00:00

213 lines
7.5 KiB
TypeScript

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<InvoiceshelfAccount[]> {
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<InvoiceshelfCredentials | null> {
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<InvoiceshelfCredentials | null> {
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<InvoiceshelfAccount> {
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<InvoiceshelfAccount | null> {
const set: Record<string, unknown> = { 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<InvoiceshelfAccount | null> {
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<boolean> {
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<void> {
await db
.update(invoicesAccounts)
.set({ version, lastSeenAt: new Date() })
.where(and(eq(invoicesAccounts.userId, userId), eq(invoicesAccounts.id, id)));
}