All work

Case study

PEN Ticketing System

Internal ticketing platform for PEN, used by 100+ people to run 5k+ active tickets a month across 120+ projects. Microsoft Entra ID limits sign-in to organisation accounts. Prisma holds the workflow on PostgreSQL, and server handlers enforce department-scoped roles.

Role
Built it; owned the schema
When
Oct 2025 – present
For
Planet Education Networks
Links
  • Next.js
  • TypeScript
  • Prisma
  • PostgreSQL
  • Entra ID
PEN Ticketing System screenshot
PEN Ticketing System — Next.js, TypeScript, Prisma

01The brief

The problem

PEN's engineering and operations teams were tracking work across spreadsheets, chat threads and a third-party board nobody fully trusted. Requests got lost between departments, nobody could say who owned what, and time spent on a ticket was a guess at the end of the month.

The goal was one internal tool that every department could use without seeing each other's work: a board, sprints, ticket detail and a time log, signed in with the same Microsoft account people already use, and kept honest by the code that actually ships.

02In use

The product

03Architecture

How it fits together

  1. Identity
    • Microsoft Entra ID (OIDC) — only organisation accounts can sign in
    • Session carries the user id; roles are resolved on the server, never trusted from the client
  2. Client
    • Next.js App Router — board, sprints, ticket detail, time log
    • TanStack Query for server state, optimistic updates on drag and status change
  3. Server
    • Server components render the first paint of every view
    • Route handlers own every mutation and re-check department scope on each call
  4. Data
    • PostgreSQL through Prisma — typed queries, versioned migrations
    • Composite indexes shaped around the board's real queries
  5. Integrations
    • Supabase Realtime pushes change events to open boards
    • Resend sends assignment and mention email
    • GitHub webhook advances ticket status from pull request events

04Challenges

The hard parts

Department isolation without row-level security

Problem

Departments must never see each other's tickets, but Prisma connects to Postgres as a single database role. RLS would have meant threading per-request session variables through every query and debugging policy failures that surface as empty result sets.

Approach

Scope is enforced in one place on the server: a guard that resolves the caller's memberships and turns them into a Prisma where clause every ticket query is built on. Server components and route handlers both go through it, so there is no path to a ticket that skips the check — and a denied request fails loudly instead of returning nothing.

Roles that differ per team

Problem

The same person can lead one team and be staff on another. A single role column on the user could not express that, and admin, manager, lead and staff each unlock different actions.

Approach

Role lives on the membership, not the user. Permission checks ask "what is this user on this ticket's team", which keeps cross-team work possible without granting anyone more than they hold.

Status that reflects shipped code

Problem

Manually dragged statuses drift from reality: tickets sat in review long after merge, or jumped to done before anything shipped.

Approach

A GitHub webhook links pull requests to tickets and moves status forward only — opened moves to review, merged moves to done — and never backwards. Deliveries are verified by signature and processed idempotently, so a retried webhook cannot double-apply a transition.

Live boards without a polling storm

Problem

Several people work the same board at once. Polling every open tab would hammer the database and still feel stale.

Approach

Supabase Realtime broadcasts a small change event; each client invalidates just the affected TanStack Query keys and refetches through the same scoped API. Realtime never carries ticket data itself, so it can never leak across departments.

05Data model

Schema decisions

The model is small on purpose. Most of the rules live in how these tables relate rather than in how many there are.

User

  • id
  • entraOid (unique)
  • email
  • name

Identity comes from Entra; the row is created on first sign-in.

Department → Team

  • id
  • departmentId
  • name
  • key

Departments are the isolation boundary; teams sit inside one.

Membership

  • userId
  • teamId
  • role: ADMIN | MANAGER | LEAD | STAFF

Composite unique on (userId, teamId). This is where roles live.

Ticket

  • id
  • teamId
  • sprintId?
  • status (enum)
  • assigneeId?
  • reporterId
  • rank

Indexed on (teamId, status) and (sprintId) — the two ways the board reads.

Sprint

  • id
  • teamId
  • startsOn
  • endsOn
  • state

Belongs to a team, so sprint scope follows department scope for free.

TimeLog

  • id
  • ticketId
  • userId
  • minutes
  • loggedOn

Integer minutes and a date, not timestamps — totals stay exact.

PullRequestLink

  • ticketId
  • repo
  • number
  • state

Unique on (repo, number); the webhook upserts against it.

Status as an enum, transitions in code
A Postgres enum keeps bad values out of the table; which transitions are legal (and the webhook's forward-only rule) lives in one function both the UI and the webhook call.
Ranked ordering instead of positions
Board order is a sortable rank between neighbours, so moving a card writes one row instead of renumbering a whole column.
Time as minutes
Logging integer minutes against a calendar date avoids float drift and timezone edge cases when hours are summed per person, per sprint or per department.
Migrations, never db push
Every schema change ships as a reviewed Prisma migration, so production and every developer database move through the same steps.

06Performance

Keeping it fast

Server-rendered first paint
Board and ticket views render on the server with data already scoped, so the page arrives useful and TanStack Query hydrates from it instead of fetching again.
Optimistic updates
Drags, status changes and assignments apply instantly on the client and roll back if the server rejects them. The board never waits on a round trip to feel responsive.
Narrow queries
Board cards select only the columns a card shows; comments, history and time logs load on the detail view. Indexes match the board's filters, so a column read is an index scan, not a table scan.
Targeted invalidation
Realtime events name the ticket and team that changed, so a client refetches one query key, not the whole board.

07Outcome

Where it landed

Active users
100+
Active tickets every month
5k+
Projects tracked
120+
  • One place for every department's work, behind the company's own sign-in, now used daily by 100+ people across 120+ projects.
  • Ticket status driven by pull requests rather than memory.
  • Time logged against tickets, reportable by person, sprint and department.
Putting authorisation in one server-side guard rather than spreading it across policies made it easier to reason about, test and review. Keeping Realtime as a signal and never as a data channel was the decision that let live updates and strict isolation coexist.
Next case studyNexora