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.
59 lines
3.4 KiB
TypeScript
59 lines
3.4 KiB
TypeScript
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
import { users } from 'officerdb/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 invoicesAccounts = pgTable(
|
|
'invoices_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_invoices_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_invoices_accounts_one_active')
|
|
.on(t.userId)
|
|
.where(sql`${t.isActive}`),
|
|
],
|
|
);
|