Skip to content

Latest commit

 

History

15 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

TeamBoard

CI License: MIT

A small, honest, Linear-style multi-tenant team issue tracker. It exists to demonstrate the architecture that actually matters in B2B SaaS — not a toy CRUD app:

  • Isolated organizations (tenants) — every domain row is scoped by org_id.
  • Role-based access control enforced server-side through one choke point.
  • Invitations with single-use, expiring, email-bound tokens.
  • A real-time kanban board that updates across clients over SSE.
  • An append-only audit log of every mutation.
  • Seat-based billing with an honest, clearly-labelled simulated upgrade.

Built with Next.js 16 (App Router, Server Actions), better-auth, Drizzle ORM + postgres-js, Postgres 17, Tailwind v4, TypeScript strict, and Zod on every mutation.


Screenshots

These are real captures produced by the Playwright E2E run (e2e/teamboard.spec.ts), which drives the whole product against a freshly built server.

Real-time kanban board

The kanban board with backlog issues and a live-connection indicator

Members, roles & invitations (seat-tracked)

The members page showing roles, seat usage, and a pending invitation

Honest, simulated billing

The billing page showing simulated Pro upgrade and seat usage


Architecture

flowchart LR
  subgraph Browser["Browser (per org)"]
    UI["React client components<br/>kanban · modals · toasts"]
    ES["EventSource<br/>/api/orgs/:id/stream"]
  end

  subgraph Next["Next.js 16 server"]
    SA["Server Actions<br/>auth → requireRole → zod → mutate → audit → broadcast"]
    RH["Route handlers<br/>auth · state · SSE stream"]
    BUS["In-process event bus<br/>Map&lt;org_id, listeners&gt;"]
  end

  DB[("Postgres 17<br/>org-scoped tables")]

  UI -- "mutations (RPC)" --> SA
  UI -- "GET board state" --> RH
  ES -- "subscribe" --> RH
  SA -- "read / write" --> DB
  RH -- "read" --> DB
  SA -- "broadcast(org_id)" --> BUS
  BUS -- "event" --> RH
  RH -- "SSE event" --> ES
  ES -- "refetch state" --> UI
Loading

Every mutation is a Server Action that follows the same pipeline:

authenticate → requireRole(orgId, minRole) → validate (zod) → mutate (Drizzle)
             → write audit row → broadcast(org_id) over the bus

The browser never mutates the database directly and never sends a role it wants to use.


Multi-tenancy & RBAC

Tenant isolation

Organizations are modelled in first-party domain tables (not the better-auth organization plugin) so isolation is explicit and easy to audit. Every tenant-scoped table — members, invitations, issues, comments, audit_log — carries an org_id foreign key, and every read is filtered by it. A membership on org A can never surface data from org B, because the query that lists org B's issues is where org_id = B and the user simply has no membership row there.

Server-side role checks (never trust the client)

Roles are ordered owner > admin > member. Authorization lives in a single helper, called at the top of every mutating action and every data route. The role is always read fresh from the database, scoped to the org in the request:

export async function requireRole(orgId: string, minRole: Role): Promise<Actor> {
  const user = await requireUser(); // throws if not signed in
  const rows = await db
    .select({ id: members.id, role: members.role })
    .from(members)
    .where(and(eq(members.orgId, orgId), eq(members.userId, user.id)))
    .limit(1);

  const member = rows[0];
  const access = resolveAccess(
    member ? [{ orgId, role: member.role }] : [],
    orgId,
    minRole,
  );
  if (!access.ok) {
    throw new ForbiddenError(/* not a member / insufficient role */);
  }
  return { user, member: member! };
}

The comparison itself is a pure function (resolveAccess / hasMinRole), so tenant isolation and role ordering are unit-tested with no database:

// An owner of org-a is only a member on org-b — no privilege bleed.
resolveAccess([{ orgId: "org-a", role: "owner" }], "org-b", "admin"); // { ok: false }

Members can only work the board. Owners and admins additionally manage members, invitations, billing, and can see the audit log. Owner-only guards protect against demoting the last owner and against admins escalating themselves.


Real-time

The board is kept live with Server-Sent Events:

  1. Each mutating action calls broadcast({ type, orgId }) on an in-process event bus — a Map<org_id, Set<listener>>.
  2. GET /api/orgs/:id/stream authorizes the caller with requireRole, then subscribes to that org's channel and streams events (plus a heartbeat).
  3. The board's EventSource receives an event and refetches GET /api/orgs/:id/state. A live-connection indicator reflects the EventSource state (connecting / live / reconnecting).

This is intentionally single-instance (bus state is in one process's memory). The multi-instance path is a drop-in swap: replace the bus with Redis pub/sub — PUBLISH org:<id> on broadcast, SUBSCRIBE per connection — and nothing else changes because the event shape stays the same.


Billing is honest

There is no payment processor, real or faked. The org has a plan (free = 3 seats, pro = unlimited). The billing page shows seat usage vs the limit, and inviting past the free limit is blocked with an upgrade prompt.

For the demo, an admin can "simulate upgrade" — a button that flips the plan locally. The UI says so in plain language.

How a real Stripe integration would plug in (no code change to the domain model — only the source of the plan changes):

  1. "Upgrade" starts a Stripe Checkout Session for a seat-based subscription.
  2. Stripe calls a webhook route. It verifies the signature, then handles checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted.
  3. The handler is idempotent (keyed on the Stripe event id, stored so a redelivered event is a no-op) and maps subscription status → plan: active/trialing → pro, otherwise → free.
  4. It writes the org's plan exactly like simulateSetPlan does today — the seat gate, the board, and the audit log need no changes.

Getting started

Option A — Docker Compose (one command)

docker compose up --build

This starts Postgres 17 (schema applied from drizzle/0000_*.sql on first boot) and the app on http://localhost:3000. Set a real BETTER_AUTH_SECRET in your environment for anything beyond a local demo.

Option B — Local dev

# 1. Start Postgres on port 5437 (127.0.0.1, never localhost — IPv6 gotcha)
docker run -d --name teamboard-pg \
  -e POSTGRES_USER=teamboard -e POSTGRES_PASSWORD=teamboard -e POSTGRES_DB=teamboard \
  -p 5437:5432 postgres:17-alpine

# 2. Configure env
cp .env.example .env

# 3. Install deps (Node 24 required — see below) and apply the schema
npm install
npm run db:push          # drizzle-kit push --force

# 4. Run the dev server
npm run dev              # http://localhost:3000

Node 24 is required. The lockfile is npm-11 format; npm ci fails on Node 22. CI and the Docker image both pin node:24.


Environment variables

Variable Required Example Notes
DATABASE_URL yes postgres://teamboard:teamboard@127.0.0.1:5437/teamboard Use 127.0.0.1, not localhost (resolves to IPv6 on some hosts).
BETTER_AUTH_SECRET yes a 32+ char random string Signs session cookies. Generate a strong value in production.
BETTER_AUTH_URL yes http://localhost:3000 Public base URL; better-auth trusts this origin.

Testing

npm run lint        # eslint (0 warnings allowed)
npm run typecheck   # tsc --noEmit
npm test            # vitest — 31 unit tests, no DB required
npm run test:e2e    # playwright — full two-user live-update flow (builds first)
  • Unit tests cover the pure logic: RBAC role ordering + cross-org denial, the token-bucket rate limiter (with an injected clock), invitation token validation/expiry, and seat-limit math. All logic that can be pure is pure, so these need no database.
  • E2E (e2e/teamboard.spec.ts) signs up an owner → creates an org → creates issues → invites a second user → the second context signs up and accepts the invite → both load the board → the owner moves an issue → the second client sees it update live over SSE → simulates a Pro upgrade. It writes the three screenshots above into docs/.

Project layout

src/
  db/               schema.ts (auth + domain tables), index.ts (127.0.0.1 DSN)
  lib/
    auth.ts         better-auth server config
    auth-client.ts  better-auth browser client
    rbac.ts         requireRole / requireUser (server enforcement)
    rbac-core.ts    pure role logic (unit-tested)
    actions.ts      ALL server actions (auth→role→zod→mutate→audit→broadcast)
    queries.ts      org-scoped read helpers
    bus.ts          in-process SSE event bus
    ratelimit.ts    token-bucket limiter
    invites.ts      invite token + expiry logic (pure)
    seats.ts        seat-limit logic (pure)
  app/
    page.tsx                      landing
    sign-in, sign-up              auth
    dashboard                     org list
    org/[id]/                     board, members, audit, billing (+ shell layout)
    invite/[token]                invitation acceptance
    api/auth/[...all]             better-auth handler
    api/orgs/[id]/state           board state (JSON)
    api/orgs/[id]/stream          SSE stream
    api/orgs/[id]/issues/[id]     issue detail + comments
  components/       UI (board, members panel, billing panel, modal, toasts, …)
tests/             vitest suites
e2e/               playwright spec
drizzle/           generated SQL migration (mounted by compose on first boot)

Limitations (by design, and honest about it)

  • Single instance. The SSE bus and the rate limiter keep state in one process's memory. Horizontal scaling needs Redis pub/sub (bus) and a shared store (rate limiter). The code is structured so these are drop-in swaps.
  • Billing is simulated. No Stripe, no charges. The plan is flipped locally and the UI labels it as such.
  • Cookie sessions. Sessions are cookie-based via better-auth; there is no refresh-token rotation or device management.
  • No email delivery. Invitations produce a link you copy and share rather than sending an email.

Future improvements

  • Redis pub/sub for multi-instance real-time and shared rate limiting.
  • Real Stripe subscriptions via the idempotent webhook described above.
  • Optimistic drag-and-drop ordering within a column.
  • Email delivery for invitations; SSO / OAuth providers.
  • Per-issue activity feed and @mentions in comments.

License

MIT © 2026 Aminyx

About

Multi-tenant team issue tracker (Next.js 16): organizations with data isolation, server-side RBAC, invitations, real-time kanban over SSE, audit log, seat-based billing. Drizzle + Postgres + better-auth

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages