# beenvoice server architecture Dense reference for the Next.js web application and API in `beenvoice/`. Package manager: **Bun**. Database: **PostgreSQL** via Drizzle ORM. ## Stack | Layer | Technology | |-------|------------| | Framework | Next.js 16 App Router (`src/app/`) | | API | tRPC 11 (`/api/trpc`), SuperJSON transformer | | ORM | Drizzle + `pg` pool | | Auth | better-auth (email/password, optional Authentik OIDC, Expo plugin for mobile) | | UI | shadcn/ui, Tailwind CSS v4, Radix primitives | | Email | Resend | | PDF | `@react-pdf/renderer` | ## Request flow ``` Browser / Mobile / MCP client │ ├─► /api/auth/* → better-auth handler (session cookies) ├─► /api/trpc/* → createContext() → appRouter │ ├─ Bearer / x-api-key → api-key auth │ └─ else → better-auth session ├─► /api/mcp → API key only → JSON-RPC tools → tRPC caller ├─► /api/i/[token]/pdf → public invoice PDF └─► /dashboard/* → RSC + client components (session required in UI) ``` **Context** (`src/server/api/trpc.ts`): `protectedProcedure` requires `ctx.session.user`. API-key auth sets `authSource: "api-key"`; `apiKeys.*` mutations require session (cannot manage keys with a key). ## Directory layout ``` src/ ├── app/ # Routes (pages + route handlers) │ ├── api/ │ │ ├── auth/ # better-auth catch-all + custom register/reset REST │ │ ├── trpc/[trpc]/ # tRPC HTTP adapter │ │ ├── mcp/ # MCP over HTTP (API key) │ │ ├── i/[token]/pdf/ # Public PDF │ │ └── cron/ # Recurring invoice generation (CRON_SECRET) │ ├── auth/ # sign-in, register, forgot/reset password │ ├── dashboard/ # Authenticated app shell │ └── i/[token]/ # Public invoice view ├── components/ # Shared UI (ui/, forms/, layout/, data/) ├── hooks/ ├── lib/ # auth.ts, pdf-export, email templates, branding ├── server/ │ ├── api/ │ │ ├── root.ts # appRouter composition │ │ ├── trpc.ts # procedures, context, timing middleware (dev) │ │ ├── api-keys.ts │ │ └── routers/ # one file per domain │ └── db/ │ ├── schema.ts # all tables (prefix beenvoice_) │ ├── index.ts # drizzle + pool │ └── migrate.ts ├── trpc/ # react.tsx (client), server.ts (RSC) ├── env.js # @t3-oss/env-nextjs validation └── styles/globals.css drizzle/ # SQL migrations (0000–0014+) ``` ## tRPC routers Root: `src/server/api/root.ts`. All routers use Zod input validation. | Namespace | File | Key procedures | |-----------|------|----------------| | `clients` | `routers/clients.ts` | getAll, getById, create, update, delete | | `businesses` | `routers/businesses.ts` | getAll, getById, getDefault, create, update, delete, setDefault, getEmailConfig, updateEmailConfig | | `invoices` | `routers/invoices.ts` | getAll, getBillable, getById, create, update, delete, updateStatus, bulk*, previewPdf, public token, **getByPublicToken** (public), sendReminder | | `payments` | `routers/payments.ts` | getByInvoice, create, delete | | `expenses` | `routers/expenses.ts` | getAll, getById, create, update, delete | | `invoiceTemplates` | `routers/invoiceTemplates.ts` | CRUD by template type | | `recurringInvoices` | `routers/recurring-invoices.ts` | CRUD, pause/resume, generateNow; cron helper `generateDueRecurringInvoices` | | `timeEntries` | `routers/time-entries.ts` | getAll, getRunning, clockIn, updateRunning, clockOut, create, update, delete, getSummary | | `dashboard` | `routers/dashboard.ts` | getStats | | `email` | `routers/email.ts` | sendInvoice | | `settings` | `routers/settings.ts` | profile, theme, animation prefs, export/import data, admin account roles | | `apiKeys` | `routers/apiKeys.ts` | list, create, revoke (session-only) | ### Time clock semantics - **One running entry per user** — partial unique index on `(createdById)` where `endedAt IS NULL`. - `clockIn` — optional client, invoice, rate, backdated `startedAt`; resolves rate from input → client default → business default. - `clockOut` — optional description update; computes hours; if `invoiceId` set, appends line item; else tries latest open invoice for client. - Outcomes: `linked_to_invoice`, `saved_no_invoice`, `saved_no_client`, `zero_hours`. ## Database schema Single file: `src/server/db/schema.ts`. Table names use `pgTableCreator` → prefix `beenvoice_`. ### Auth & platform | Table | Notes | |-------|-------| | `beenvoice_user` | Core user; role for admin features | | `beenvoice_account` | OAuth/credential accounts (better-auth) | | `beenvoice_session` | Sessions; unique token | | `beenvoice_verification_token` | Email verification / reset | | `beenvoice_api_key` | `bv_` prefix keys; SHA-256 hash stored | | `beenvoice_sso_provider` | OIDC/SAML config per user | | `beenvoice_platform_setting` | Singleton (`id = global`) branding/PDF/appearance | ### Domain | Table | FKs | Notes | |-------|-----|-------| | `beenvoice_client` | `createdById` → user | defaultHourlyRate, currency | | `beenvoice_business` | `createdById` | Resend config, `isDefault` | | `beenvoice_invoice` | client, business?, user | status draft/sent/paid; `publicToken` | | `beenvoice_invoice_item` | invoice (cascade) | position ordering | | `beenvoice_invoice_payment` | invoice, user | payment method enum | | `beenvoice_expense` | business?, client?, invoice? | billable flags | | `beenvoice_invoice_template` | user | notes/terms templates | | `beenvoice_recurring_invoice` | client, business?, user | schedule, `nextDueAt` | | `beenvoice_recurring_invoice_item` | recurring (cascade) | | | `beenvoice_time_entry` | client?, invoice?, user | `endedAt` null = running | Migrations: `bun run db:generate` → `drizzle/`; apply with `db:push` (dev) or `db:migrate` (prod script). ## Authentication **Server** — `src/lib/auth.ts`: - `betterAuth` + `drizzleAdapter` (users, sessions, accounts, verification) - Plugins: `@better-auth/expo` (mobile SecureStore cookies), `nextCookies()`, optional `genericOAuth` (Authentik) - Email/password with bcrypt (12 rounds); `DISABLE_SIGNUPS=true` blocks registration - `trustedOrigins`: production URL, `beenvoice://`, `exp://` (Expo) **Web client** — `src/lib/auth-client.ts`: `createAuthClient` + `genericOAuthClient`. **Routes**: - `src/app/api/auth/[...all]/route.ts` — better-auth handler - Custom REST: `register`, `forgot-password`, `reset-password`, `validate-reset-token` (used by mobile and legacy flows) **Session cookies**: `better-auth.session_token` or `__Secure-better-auth.session_token` in production. ## Mobile API contract The Expo app (`beenvoice-app`) does **not** use API keys. It: 1. Calls the same tRPC endpoints with `Authorization` cookie header from `authClient.getCookie()`. 2. Stores session per account in SecureStore via `@better-auth/expo` (`storagePrefix`: `beenvoice:guest` or `beenvoice:auth:{accountId}`). 3. Requires `trustedOrigins` and matching `BETTER_AUTH_URL` for the host the device can reach. Ensure `src/lib/auth.ts` keeps the `expo()` plugin enabled. ## MCP (machine clients) `POST /api/mcp` — JSON-RPC 2.0, protocol `2025-11-25`. - **Auth**: API key only (`Authorization: Bearer bv_…` or `x-api-key`). Session cookies rejected. - **Tools**: ~50 tools mirroring tRPC (invoices, clients, time clock, expenses, etc.) - Implemented in `src/app/api/mcp/route.ts`; delegates to `createCaller(createContext)`. API keys: format `bv_`; stored as SHA-256 hash (`src/server/api/api-keys.ts`). ## Environment variables Validated in `src/env.js`. See `.env.example`. | Variable | Required | Notes | |----------|----------|-------| | `DATABASE_URL` | yes | PostgreSQL connection string | | `AUTH_SECRET` | prod | `openssl rand -base64 32` | | `BETTER_AUTH_URL` | yes | Public URL of API (no trailing path) | | `NEXT_PUBLIC_APP_URL` | yes | Browser-facing URL | | `DB_DISABLE_SSL` | local | `true` for Docker dev DB | | `RESEND_API_KEY`, `RESEND_DOMAIN` | optional | Email; blank disables send | | `AUTHENTIK_*` | optional | OIDC SSO | | `DISABLE_SIGNUPS` | optional | `true` blocks registration | | `CRON_SECRET` | cron route | Protects `/api/cron/generate-recurring` | | `NEXT_PUBLIC_BRAND_*` | optional | Build-time white-label defaults | ## Docker | File | Use | |------|-----| | `docker-compose.yml` | Production: `app` + `db` (Postgres internal) | | `docker-compose.dev.yml` | Dev: Postgres only, port `${POSTGRES_PORT:-5432}` | App image built from `Dockerfile`; runs `next start` on port 3000. ## Scripts ```bash bun run dev # next dev --turbo bun run build # production build bun run db:push # push schema (dev) bun run db:migrate # run migrations bun run db:studio # Drizzle Studio bun run check # eslint + tsc ``` ## Public / unauthenticated surfaces - `invoices.getByPublicToken` (tRPC publicProcedure) - `/i/[token]` page and `/api/i/[token]/pdf` - Auth REST endpoints for register/reset ## Related docs - [forms-guide.md](./forms-guide.md), [UI_UNIFORMITY_GUIDE.md](./UI_UNIFORMITY_GUIDE.md) - [data-table-responsive-guide.md](./data-table-responsive-guide.md) - [email-features.md](./email-features.md) - Mobile companion: `../beenvoice-app/docs/ARCHITECTURE.md`