invoices, extracted into plugins/invoices — the platform half
Ninth extraction, third resurrection. Switched off since 2026-08-13 with the permission reduced to api: [], so assertPermissionTotality was satisfied, the server booted clean, and every panel request 404'd. Closes the same data hazard as jellyfin and soulseek: invoiceshelf/schema was commented out of the drizzle barrel. Named `invoices`, not `invoiceshelf`. The appName is THE id — API prefix, frontend route, table prefix, sidecar suffix, install key — and before extraction the API (/api/invoiceshelf) and the screen (/invoices) had drifted apart, which was harmless only while they were separate hardcoded strings. The route is the part a person sees, and the permission key and label were already `invoices`, so the API prefix was the odd one out. Cost three call sites and a table rename to invoices_accounts, which was free only because the table existed on no server — verified before deciding, not after. members.test.ts re-anchored again, this time to caldav and memos: services with no extraction planned. That assertion has now been re-pointed four times by services leaving for plugins/, which is the whole reason installer.test.ts was made literal earlier tonight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -46,7 +46,6 @@ export * from './user-data';
|
||||
// ── Plugins — exported only so tsgo stays clean; nothing mounts them ──────────────────────────────
|
||||
|
||||
export * from './dav';
|
||||
export * from './invoiceshelf';
|
||||
export * from './notify';
|
||||
export * from './wallet';
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
export {
|
||||
listInvoiceshelfAccounts,
|
||||
getActiveInvoiceshelfCredentials,
|
||||
getInvoiceshelfCredentials,
|
||||
createInvoiceshelfAccount,
|
||||
updateInvoiceshelfAccount,
|
||||
setActiveInvoiceshelfAccount,
|
||||
deleteInvoiceshelfAccount,
|
||||
recordInvoiceshelfProbe,
|
||||
} from './queries';
|
||||
|
||||
export type { InvoiceshelfAccount, InvoiceshelfCredentials } from './queries';
|
||||
@@ -1,212 +0,0 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { invoiceshelfAccounts } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../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: invoiceshelfAccounts.id,
|
||||
label: invoiceshelfAccounts.label,
|
||||
url: invoiceshelfAccounts.url,
|
||||
companyId: invoiceshelfAccounts.companyId,
|
||||
companyName: invoiceshelfAccounts.companyName,
|
||||
version: invoiceshelfAccounts.version,
|
||||
isActive: invoiceshelfAccounts.isActive,
|
||||
lastSeenAt: invoiceshelfAccounts.lastSeenAt,
|
||||
createdAt: invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(eq(invoiceshelfAccounts.userId, userId))
|
||||
.orderBy(desc(invoiceshelfAccounts.isActive), desc(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||
}
|
||||
const [row] = await tx
|
||||
.insert(invoiceshelfAccounts)
|
||||
.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(invoiceshelfAccounts)
|
||||
.set(set)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.set({ isActive: false, updatedAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||
const [row] = await tx
|
||||
.update(invoiceshelfAccounts)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)))
|
||||
.returning({ id: invoiceshelfAccounts.id, wasActive: invoiceshelfAccounts.isActive });
|
||||
if (!deleted) return false;
|
||||
|
||||
if (deleted.wasActive) {
|
||||
const [next] = await tx
|
||||
.select({ id: invoiceshelfAccounts.id })
|
||||
.from(invoiceshelfAccounts)
|
||||
.where(eq(invoiceshelfAccounts.userId, userId))
|
||||
.orderBy(desc(invoiceshelfAccounts.createdAt))
|
||||
.limit(1);
|
||||
if (next) {
|
||||
await tx
|
||||
.update(invoiceshelfAccounts)
|
||||
.set({ isActive: true, updatedAt: new Date() })
|
||||
.where(eq(invoiceshelfAccounts.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(invoiceshelfAccounts)
|
||||
.set({ version, lastSeenAt: new Date() })
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../auth/schema';
|
||||
|
||||
// The InvoiceShelf accounts behind /invoices, for the officer-invoiceshelf sidecar.
|
||||
//
|
||||
// This used to be INVOICESHELF_URL + INVOICESHELF_TOKEN + INVOICESHELF_COMPANY_ID in the platform-wide
|
||||
// `.env`, which was wrong twice over: Bun auto-loads `.env` into EVERY process started in the platform
|
||||
// directory, so `officer` itself held a Sanctum token it has no code to use — and connecting a books
|
||||
// instance was a shell task on the server rather than something the owner could do from the app.
|
||||
//
|
||||
// It is a REGISTRY, not a single row: the owner adds any number of accounts and switches between them, the
|
||||
// same shape offscale_servers and photos_config use. Uniqueness is on the label rather than the URL,
|
||||
// because the same instance with a different company is a different account here, and two of those share
|
||||
// a URL AND a token.
|
||||
//
|
||||
// `token` is encrypted at rest via ../crypto.ts. A Sanctum token can read and write the entire books — every
|
||||
// invoice, customer and payment — so a DB dump must not hand it over. Encryption is confined to
|
||||
// queries/invoiceshelf.ts; nothing outside that file sees ciphertext, and no route ever returns the token.
|
||||
export const invoiceshelfAccounts = pgTable(
|
||||
'invoiceshelf_accounts',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
/** What the owner calls this account. The switcher shows nothing else, so it has to be theirs to set. */
|
||||
label: text('label').notNull(),
|
||||
// Normalized without a trailing slash before write, so `${url}/api/v1/...` never doubles the separator.
|
||||
url: text('url').notNull(),
|
||||
token: text('token').notNull(), // encrypted
|
||||
/**
|
||||
* The company every request is scoped to, pinned explicitly.
|
||||
*
|
||||
* This is the sharpest edge in InvoiceShelf's API: the `company` header scopes almost every route, and a
|
||||
* wrong or missing value does NOT error — it silently returns another company's books. Storing the
|
||||
* choice means it is made once, visibly, instead of falling back per request.
|
||||
*/
|
||||
companyId: integer('company_id'),
|
||||
/** Shown in the switcher next to the label, so two companies on one token are told apart at a glance. */
|
||||
companyName: text('company_name'),
|
||||
/** InvoiceShelf version seen at the last successful probe — shown in the UI, never used for behaviour. */
|
||||
version: text('version'),
|
||||
isActive: boolean('is_active').notNull().default(false),
|
||||
lastSeenAt: timestamp('last_seen_at', { withTimezone: true }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('uq_invoiceshelf_accounts_user_label').on(t.userId, t.label),
|
||||
// At most one active account per owner, enforced by the DB rather than by convention: a partial unique
|
||||
// index over the active rows only. setActiveInvoiceshelfAccount still clears the others in a transaction,
|
||||
// but a bug there fails loudly here instead of silently leaving two active and the UI picking one.
|
||||
uniqueIndex('uq_invoiceshelf_accounts_one_active')
|
||||
.on(t.userId)
|
||||
.where(sql`${t.isActive}`),
|
||||
],
|
||||
);
|
||||
@@ -48,7 +48,6 @@ export * from './service-connections/schema'; // service_connections
|
||||
|
||||
// export * from './notify/schema'; // push_devices officer-notify
|
||||
// export * from './dav/schema'; // dav_app_passwords officer-caldav
|
||||
// export * from './invoiceshelf/schema'; // invoiceshelf_accounts officer-invoiceshelf
|
||||
// export * from './wallet/schema'; // wallet_wallets, _labels, _frozen_utxos, _chain_cache
|
||||
|
||||
// ── Plugin tables are NOT here ───────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user