Skip to main contentLuca Imbalzano's logo

Tranqui

Multi-tenant Transport CRM — NestJS Clean Architecture, Next.js ops UI, Stripe-ready SaaS tenancy, and staging on EC2/ECR/Caddy.

Tranqui

Preface

Tranqui is a multi-tenant Transport CRM I designed and built end to end — backend and frontend — for fleet operators who need one place to run vehicles, clients, rents (noleggi), teams, documents, and billing.

The product is not a CRUD demo. It is a SaaS-shaped system: companies (tenants) with optional sub-companies, role-based access, team-scoped data, subscription lifecycle via Stripe, and a staging environment that mirrors how I would ship a real startup.

I treated the role as startup CTO: decide the boundaries first, keep the domain honest, and make the boring reliability pieces (auth refresh, webhook idempotency, tenant isolation) non-negotiable.

The problem

Transport operators already live in spreadsheets, WhatsApp threads, and a pile of single-purpose tools. What they need looks simple on a slide — “manage the fleet” — and gets hard in production:

  • Many companies, one product — isolation between tenants, plus parent companies that must see child companies
  • People are not flat roles — admins, supervisors, drivers, secretaries; permissions must change without forcing everyone to re-login
  • Teams matter — not every user should see every vehicle or rent
  • Money fails loudly — Stripe webhooks retry; you cannot double-apply an invoice event
  • Ops UI must stay fast — stale JWTs kill trust; refresh has to be invisible

Tranqui is my answer to that shape: a Clean Architecture API and a Next.js ops console, wired for tenancy, RBAC, and billing from day one.

Product preview

Ops console for modern transport businesses — rents on a vehicle timeline, fleet cards, and mobile surfaces for backoffice + drivers. Staging auth is the door in; the product is what happens after.

Tranqui — Gestione Noleggi timeline across the fleet

Design decisions

I did not optimize for “most frameworks.” I optimized for clear ownership and safe defaults.

Clean Architecture API

Controllers stay thin. Use-cases own behavior. Repositories and an IDataServices unit-of-work sit behind abstractions — so Stripe, S3, and email are replaceable without rewriting domain rules.

App-level multi-tenancy

Every request carries tenant context. Guards verify membership, expand parent→child visibility, and keep queries scoped. Isolation is enforced in the application layer where the product rules live.

RBAC + team scope

Permissions are resource/action pairs. The DB is authoritative so grants take effect immediately; JWT stays a cache. TeamScopeGuard narrows what drivers and secretaries can see.

Billing that survives retries

Stripe webhooks hit a dedicated raw-body route. Events are persisted with find-or-create on stripe_event_id before any side effect — so retries are safe.

Documents & infra reality

Entity documents go to S3 with presigned downloads. Email goes through Resend. Staging ships as ECR images on EC2 behind Caddy TLS — the same path I would use for a first production cut.

Ops UI with honest boundaries

Next.js App Router, feature folders, Zustand, RHF+Zod, next-intl. Middleware refreshes httpOnly cookies before protected routes. Invoices/payslips stay UI placeholders until the domain is ready — no fake backend.

Architecture

Two repositories, one system. The API owns invariants; the web app owns workflows and presentation.

Diagram authored in Eraser — browser → edge/staging → NestJS Clean Architecture → Postgres, with Stripe / S3 / Resend / Mapbox on the side.

Tranqui system architecture — clients, edge, NestJS layers, and external services
Tranqui system architecture — clients, edge, NestJS layers, and external services

Backend layout

Controllers never hide business rules. Use-cases are the product language: auth, tenants, teams, vehicles, insurance, maintenances, clients, expenses, cards, rents, documents, violation notices, presences, dashboard, settings, Stripe.

Frontend layout

Route groups for auth vs dashboard, Italian path aliases where the operators live (veicoli, noleggi, …), and feature modules that match the API language.

Documentation graph

Code trees show where things live. For Tranqui I also kept an Obsidian vault — a linked-note graph of why they live that way: tenancy rules, Stripe idempotency, auth refresh, use-case boundaries, and the decisions that should not evaporate into Slack.

Obsidian fits this shape better than a static wiki: every note can [[wikilink]] the next, the graph view exposes orphans and dense clusters, and markdown stays diffable next to the repos.

Obsidian graph · tranqui-docslive

Why auto-doc with Obsidian is attractive here. A Transport CRM accumulates product language faster than READMEs keep up. Generating or syncing notes from use-case folders, Swagger, and ADR stubs into an Obsidian vault would keep the graph close to the code: new modules get a seed note, [[links]] surface coupling, and onboarding becomes “open the graph” instead of “ask whoever remembers.” Human editing stays for judgment; automation stays for structure and freshness.

Tenancy and access

Multi-tenancy is a product decision, not a database checkbox. I chose application-level isolation: tenant_id on rows, tenant IDs on the request, and a guard pipeline that fails closed.

Why not lean on RLS alone? Product rules here are richer than “same tenant id” — parent companies expand into sub-companies, supervisors see all teams, drivers do not. Those rules belong next to the use-cases, where they stay testable and explicit.

The access path is layered on purpose:

  1. JWT — who is calling
  2. Tenant verification — are they allowed in this company (with a subscription-lenient variant where onboarding needs it)
  3. Expand sub-companies — parent visibility without duplicating data models
  4. @RequirePermission + PermissionGuard — resource/action checks; DB permissions win, JWT is fallback for unseeded tenants
  5. TeamScopeGuardteamIds for membership-scoped reads; admins/supervisors keep a wide lens
// PermissionGuard — DB is source of truth; JWT is cache
const dbPerm = await this.tenantRolePermissions.resolvePermission(
  Number(tu.tenantId),
  null,
  Number(tu.roleId),
  resource,
)

if (dbPerm) {
  // DB record exists — authoritative. No JWT fallback.
  if (allowed) return true
  continue
}

// No DB record yet → fall back to JWT cache
if (hasPermissionFromJwt(tu, resource, action)) return true

That single choice — permissions effective immediately without re-login — is the kind of detail that separates a polished CRM from a brittle admin panel.

Auth at the edge

Tokens are short-lived for a reason. The frontend stores access + refresh in httpOnly cookies (≈15m / 7d), syncs them through a small Next route, and lets middleware refresh before protected pages.

When access is missing or expired, middleware rotates with the refresh token and redirects to the same URL so Server Components never see a half-updated cookie jar. Stateless refresh with rotation keeps stolen access tokens boring and refresh theft detectable.

Billing that survives retries

Stripe will call you more than once. I designed the webhook path around that fact:

  • Dedicated raw-body route for signature verification
  • Persist the event with createIfNotExists / findOrCreate on stripe_event_id
  • If the row already exists → return; never re-run side effects
  • Tenant resolved from metadata.tenant_id
  • Dunning / subscription mail through Resend when payment fails or resumes
const { created } = await this.dataServices.stripeEvents.createIfNotExists({
  stripe_event_id: event.id,
  type: event.type,
  payload: event as unknown as Record<string, unknown>,
  processed_at: new Date(),
})
if (!created) {
  return
}

Checkout and customer portal sit in the same Stripe use-case family as the webhook — one billing vocabulary across API and UI.

Under the hood

A few more choices that shaped the system:

DecisionWhat I choseWhy
API shapeUse-cases over fat controllersDomain language stays readable as the CRM grows
TenancyApp-level guards + tenant filtersParent/child and team rules are product logic
PermissionsDB-first RBACAdmins can change access without forcing re-auth
Auth UXhttpOnly cookies + edge refreshXSS-resistant tokens; no silent 401 loops
FilesS3 + presigned downloadsDocuments stay off the API disk
Maps / addressNominatim then Mapbox fallbackGood defaults, paid accuracy when needed
StagingGHA → ECR → EC2 + CaddyReal TLS and compose path before production
DocsObsidian knowledge graphWikilinks + graph view keep domain decisions discoverable
UI honestyMock invoices/payslips onlyNo pretend ledgers until the domain is ready

Tech stack

LayerChoices
APINestJS 11, Sequelize, PostgreSQL, Passport JWT, Swagger, Schedule
WebNext.js 14 App Router, React 18, Tailwind, Radix/shadcn, Zustand, RHF + Zod, next-intl
PlatformStripe, AWS S3, Resend, Mapbox, Docker, ECR, EC2, Caddy, Cloudflare
DocsObsidian vault (domain + flows + ADRs as a linked graph)

Private product, public org: architecture and shipping cadence stay split (API → staging-api.tranqui.io, app → staging-app.tranqui.io); the public face is the org.