15 KiB
Beenvoice web architecture
Dense reference for the Next.js web application and API. Package manager: Bun. Database: PostgreSQL via Drizzle ORM.
This application is the server and browser workspace in the Beenvoice monorepo.
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 |
| Resend | |
@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; due generation runs through the worker |
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)whereendedAt IS NULL. clockIn— optional client, invoice, rate, backdatedstartedAt; resolves rate from input → client default → business default.clockOut— optional description update; computes hours; ifinvoiceIdset, 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(), optionalgenericOAuth(Authentik) - Email/password with bcrypt (12 rounds);
DISABLE_SIGNUPS=trueblocks registration (custom/api/auth/registerand better-authdisableSignUp) trustedOrigins:BETTER_AUTH_URL,NEXT_PUBLIC_APP_URL,beenvoice://,exp://, plus Authentik origin when configured
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 (apps/mobile) does not use API keys. It:
- Calls the same tRPC endpoints with
Authorizationcookie header fromauthClient.getCookie(). - Stores session per account in SecureStore via
@better-auth/expo(storagePrefix:beenvoice:guestorbeenvoice:auth:{accountId}). - Requires
trustedOriginsand matchingBETTER_AUTH_URLfor 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_…orx-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 tocreateCaller(createContext).
API keys: format bv_<base64url>; 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; use string true/false (parsed in src/env.js) |
CRON_SECRET |
worker / cron routes | Protects worker delivery and /api/cron/generate-recurring |
NEXT_PUBLIC_BRAND_* |
optional | Build-time white-label defaults |
Docker
| File | Use |
|---|---|
Root docker-compose.yml |
Deploy: app + worker + db + Garage; use apps/web/.env |
Root docker-compose.dev.yml |
Local dev: Postgres + Garage |
The root Dockerfile has separate web and worker targets. The web container runs migrations and then next start on port 3000. The Bun worker uses PostgreSQL as its durable queue, polls with row locking, generates recurring invoices, and triggers idempotent scheduled invoice delivery without Redis or an external cron. User-selected wall times are converted by the client to absolute instants and stored with their IANA timezone for consistent cross-device display. Docker builds run next build on Node 22 (not Bun) to avoid arm64 worker crashes; runtime stays on Bun.
Set BETTER_AUTH_URL and NEXT_PUBLIC_APP_URL to the public hostname before deploy. Rebuild the image when changing NEXT_PUBLIC_* build-time vars.
Deploy / update from the repository root: git pull && ./scripts/docker-deploy.sh (or docker compose up -d --build). Plain docker compose up -d reuses the local beenvoice:local image and does not include pulled code.
Scripts
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, UI_UNIFORMITY_GUIDE.md
- data-table-responsive-guide.md
- email-features.md
- Mobile companion:
../../mobile/docs/ARCHITECTURE.md