For developers
Architecture
How Harbor TMS is put together: the apps, the packages, the stack, and how a request flows from the browser to the database.
The big picture
Browser One VPS · Docker Compose
─────── ────────────────────────
Website (apps/web) ─┐
Panel (apps/app) ─┼─▶ Caddy (TLS, headers) ─▶ Next.js 15 ──withOrg(orgId)──▶ PostgreSQL 16
Portals (apps/app) ─┘ │ (row-level security)
├──▶ S3 file storage (SeaweedFS locally)
└──▶ Mail · Stripe · FMCSA (behind interfaces)
Worker (pg-boss) ─────────────────────────────▶ PostgreSQL
The website and the panel are two separate Next.js applications that share one public address:
the website proxies /app to the panel, and Caddy does the same in production. A visitor never
sees a second port or host.
The repository
| Path | What it is |
|---|---|
apps/web | The public website (one page) and these docs at /docs. Content in apps/web/content/docs |
apps/app | The panel: (auth) sign-in · (app) staff screens · (portal) customer and carrier portals · (platform) super-admin · (setup) onboarding and closing |
apps/app/worker.ts | The background worker process (pg-boss) |
packages/core | All business rules: money, dates, gates (R1–R14), RBAC, risk, margin, lanes, RFQ, carrier score… Pure TypeScript, unit-tested |
packages/db | Prisma schema, migrations, seed, and withOrg(), the only door to tenant data |
packages/integrations | Outside services behind interfaces: mailer, file storage, FMCSA registry, billing |
packages/ui | Design tokens: panel.css for the panel, tokens.css for the website |
packages/config | brand.ts: the product name and legal name. Never hard-code either |
doc/ | The old app (Boatium_CRM_v67.html) and the scope PDF. Read-only reference for "how should this behave?" |
ops/ | Caddy config, storage entrypoint, the nightly backup script |
The stack
Do not add alternatives to these.
| Layer | Choice |
|---|---|
| App | Next.js 15 App Router · React 19 · TypeScript strict |
| Data | PostgreSQL 16 · Prisma · row-level security |
| Auth | Auth.js v5 · argon2id · TOTP |
| Forms | react-hook-form · Zod |
| Jobs | pg-boss |
| Files and documents | S3 (SeaweedFS locally) · @react-pdf/renderer · ExcelJS |
| Mail and billing | Nodemailer behind a Mailer interface · Stripe (subscription billing only) |
| UI | Material Design 3 on pure white · Roboto Flex (panel) |
| Tests | Vitest · Playwright · axe-core |
| Hosting | Docker Compose + Caddy on one VPS |
How a page reads
export default async function Page() {
const access = await guardPage('loads'); // permission check + the org's dataset
const rows = await loadBoard(access); // apps/app/lib/data/<module>.ts
return <LoadBoard rows={rows} />;
}
guardPage(module) checks the signed-in user's permission on the server (and writes
security.access_denied if refused), then loads the org's data for the request
(lib/data/dataset.ts → lib/demo/store.ts ds()). The calculations in lib/demo/* (risk,
aging, desks, reports) read that dataset, so screens get real data in the shape they already use.
How a save writes
Every write is a server action in _actions.ts next to its page, wrapped in runAction(), always
in this order:
requireAccess(module, action) // permission, on the server
→ schema.parse(input) // Zod
→ rule from packages/core // throw RuleError with a plain-words message
→ withOrg(orgId, async (db) => {
...writes
await writeAudit(db, actor, {...})
await notify(db, to, {...})
})
→ revalidatePath(...)
runAction() turns a refusal into a message the form shows. Details on
Building a module.
Tenancy
Each brokerage is an Org. Every tenant table has an orgId column. withOrg(orgId, fn) opens
a transaction that sets the org for Postgres row-level security and adds orgId to every Prisma
query; creates never pass orgId (it defaults from the session setting). See
Your data for the user-facing version.
The database
About 50 Prisma models, including Org, User, Role, PortalUser, Customer, Carrier,
FactoringCompany, Load, LoadStop, LoadLeg, Charge, Quote, Appointment, LoadFeedEntry, Invoice,
Advance, DebitCreditNote, CreditRequest, PayableRequest, ExpenseRow, Commission, Document,
DeliveryOrder, ArrivalNotice, Attachment, AuditEvent, Notification, PortalSubmission,
PortalPermission, Integration, UserPreference, Draft, and the SaaS tables (plans, billing events,
platform audit). The schema is in packages/db/prisma/schema.prisma.