I built a bug tracker back at CalPERS years ago, in PHP, with none of the pieces I’d consider table stakes now. No live updates. No proper refresh tokens.
Links
Tools & Tech Stack
- Fastify, TypeScript, Node.js
- Drizzle ORM, PostgreSQL, Redis
- MinIO (S3-compatible storage), MailHog, Nodemailer
- Server-Sent Events, Prometheus, Grafana
- React 19, Vite, Tailwind CSS, shadcn/ui
- Zustand, Axios, React Hook Form, Zod, React Router v7
- Docker Compose, NX monorepo, pnpm
- Vitest (59 tests total across backend and frontend)
Authentication, Done Properly This Time
Email and password login sits alongside Google OAuth, and if you sign up with one and later use the other with the same email, they link into a single account rather than creating a duplicate. Access tokens live in memory only and expire in fifteen minutes. Refresh tokens live in an httpOnly cookie the browser never lets JavaScript touch, and every time one gets used it’s revoked and replaced with a new one.

That rotation matters for a reason that isn’t obvious until you’ve been burned by it: a stolen refresh token is only good for one use before it stops working, and if someone else tries to use the one you already redeemed, the theft becomes visible instead of silent.
Tickets, Assigned and Tracked
Agents create tickets, assign them to each other, and move them through status changes from new to resolved. The list page shows real customer names and subjects pulled from a seeded dataset of twenty-five thousand tickets, and paginates twenty-five at a time using a cursor rather than an offset, which stays fast no matter how deep you page.

Editing a ticket reassigns it, changes its status, or updates its category, and the same form component handles both creating a new ticket and editing an existing one, just with a couple of extra fields shown on the edit side.

One Form, Two Jobs
Creating a ticket and editing one use the exact same form component. The only difference is a single flag: the create page hides status and assignment, since neither makes sense before a ticket exists, and the edit page shows them. That wasn’t the first version. The first version was two separate forms with the same fields typed out twice, until it became obvious that “same fields, one extra section sometimes” is a much smaller problem than two forms that would inevitably drift apart the moment one of them got a fix the other didn’t.
A Pulse Check for Everything
Five one-line scripts, pnpm health, db:ping, redis:ping, minio:ping, mail:ping, each doing exactly one thing: confirm a backing service is actually up before assuming something more interesting is broken. It’s a small habit, but it earns its keep constantly. Half of debugging a multi-service app is ruling out “did I forget to start Docker again,” and a ten-second command beats staring at a stack trace that’s really just complaining about a container that never came up.
Attachments and Email, the Boring Parts That Matter
Agents attach files to a ticket and get them back later, stored in MinIO the same way they’d be stored in real S3. Each ticket shows its own list of attachments with a file size next to each one, and both download and delete are one click away.

When a ticket gets assigned, the agent gets an email. When it gets resolved, the customer does. Both run through MailHog locally, and both are genuinely fire-and-forget: if the mail server hiccups, the ticket update still succeeds, it just logs the failure instead of blocking the response.

Live Updates and Metrics
Open the ticket list in two browser tabs and create a ticket in one. It shows up in the other without a refresh. That’s Server-Sent Events, a single persistent connection per browser tab that the backend pushes to whenever a ticket changes. Getting the auth token attached to that connection took some real thought, since the browser’s EventSource API can’t send custom headers the way a normal request can, so the token rides along as a query parameter and gets verified manually on the way in.

Prometheus scrapes the API every five seconds and Grafana turns that into the dashboard above. It’s one panel right now, request rate, but the pipeline is real: actual requests hitting the actual API, actual metrics in an actual time-series database.
Architecture
Every API route lives under a single /api prefix. This wasn’t the original plan. It became necessary once the local dev proxy needed one clean rule to forward requests from the frontend’s dev server to the backend, instead of a growing list of individual paths that someone would eventually forget to update.
The refresh token never touches JavaScript. It’s set as an httpOnly cookie server-side and read the same way, which means an XSS vulnerability elsewhere on the page can’t walk off with it. The access token trades some of that safety for convenience, living in memory so it can be attached to requests without a round trip, but it only lives fifteen minutes.
Shared constants instead of copy-pasted enums. Ticket statuses, priorities, and categories are defined once, in the database package, and both the backend routes and the frontend dropdowns import from that single source. Add a new status there and it shows up everywhere it needs to, with nothing else to update.
File uploads go through a real object store from day one, not a folder on disk. MinIO speaks the same S3 API real AWS S3 does, which means the upload and download code isn’t a local-dev shortcut that gets rewritten later. Swapping MinIO for actual S3 in a real deployment is a config change, not a rewrite.
Every backend route that touches the database, storage, or email goes through a shared package, never a raw connection built inline. packages/db holds one Postgres client and one Drizzle schema, reused by every route file that needs them, instead of each route opening its own connection. The same is true for the MinIO and mail clients, each with a single instance the whole app shares.
The frontend never talks to a raw fetch call directly. Every request goes through one Axios instance with two interceptors already attached: one that adds the access token to outgoing requests, one that catches an expired token, silently refreshes it, and retries the original request. A component asking for ticket data has no idea any of that is happening.
Server-Sent Events, not a heavier real-time library. Ticket updates only ever flow one direction, server to client, which is exactly what SSE is built for. A full WebSocket connection would have handed back the ability to send messages upstream too, which nothing here actually needs.
Challenges
A version mismatch I’d already documented once, and still made you rediscover. Drizzle’s ORM and its CLI tool need to be on matching versions, and when they’re not, the error doesn’t say so. It says a package subpath isn’t exported. I’d hit this exact error in the previous app in this series and written down the fix. When it showed up again here, I didn’t recognize it. I guessed at a missing config file instead, which led to a second, unrelated bug from a wrong troubleshooting step I gave, before finally checking the one thing that should have been the first thing I checked.
Cookies that worked in Postman and failed in the browser. SameSite cookie rules treat two different localhost ports as different sites, and a cookie set with the wrong combination of flags gets silently dropped by the browser with no error to chase. The fix was routing frontend requests through a local dev proxy so the browser only ever sees one origin.
A race condition that ate the better part of a day, across three separate sessions, before it actually got fixed. React’s StrictMode intentionally double-fires effects in development, and the app’s silent session-restore logic didn’t guard against running twice. Every time it happened, the symptom looked identical: log in, refresh the page, land back on the login screen for no visible reason. I chased it as a cookie problem first: wrong SameSite value, wrong path, wrong proxy config, and each theory produced a real fix for a real, adjacent bug, none of which was the actual cause. It kept coming back. The actual mechanism was two refresh requests firing at nearly the same millisecond, one succeeding and rotating the token, the other failing and deleting the cookie the first one had just set, out from under it. The fix, once I finally found it, was four lines: a useRef guard so the effect only ever runs once no matter how many times React invokes it. What actually cracked it open wasn’t cleverness, it was finally sitting down and reading raw server logs closely enough to notice two requests stamped down to the same millisecond, sitting right next to each other, that I’d scrolled past every time before.
A test that silently hung instead of erroring. Fastify’s automatic route prefixing means a file sitting in a folder gets that folder name prepended to whatever path it registers, and I got bitten by both directions of that rule more than once. A route written as /tickets inside routes/tickets/create.ts actually became /tickets/tickets. Later, a download route deliberately written with the full path became /tickets/attachments/... instead of the clean /attachments/... it needed, because I’d forgotten the folder’s own prefix was still being added underneath it.
A warning that punished every attempt to fix it. The dev server printed the same cosmetic warning on every single startup, for weeks, about a CommonJS file living inside a project marked as an ECMAScript module. It never once affected anything that actually worked. Twice, on two different days, the fix looked obvious enough to just do, change one file extension setting in the build config. Both times it broke the server outright, in a different way each time, and both times the actual fix required understanding a piece of the build tool’s internal wiring that wasn’t worth the detour for a message that was, in the end, just noise. It stayed unfixed. That was the correct call.
An import extension that couldn’t satisfy two different tools at once. A small database seed script wouldn’t run, complaining a module couldn’t be found. The fix was adding a real .ts extension to a relative import, since Node’s own TypeScript execution needs one. That fix then broke the actual application build, which uses a different tool entirely and chokes on that same extension. Chasing the two back and forth, a config flag here, a reverted line there, ate a real chunk of a session before the actual answer showed up: use a different runner, tsx, for standalone scripts specifically, and leave the application’s own source files alone. It’s the same fix a previous app in this series had already landed on, which made it worse, not better, to be relearning it from scratch.
A test-mocking rule that took a genuine while to get right. Vitest hoists vi.mock() calls above every import in a file, which means a mock factory can’t safely reference a value that itself came from an import, even from a small shared helper file built specifically to avoid repeating the same three lines across every test. The fix, vi.hoisted(), needs to live directly inside each test file rather than being shared, which meant giving up on the DRY version I wanted in exchange for the one that actually worked.
Not everything fought back this hard. MinIO connected and worked close to the first try, no real debugging involved. MailHog was the same, once it landed in the right package instead of the wrong one. Server-Sent Events had exactly one real puzzle to solve, the browser’s EventSource API can’t send an auth header, so the token rides along as a query parameter and gets checked by hand on the way in, and that got worked out in a single pass rather than chased across multiple sessions like the others above.
Lessons Learned
Tests catch bugs that manual testing walks right past. Writing a real regression test for a ticket update route surfaced a bug where the server never responded at all if you changed a status without also reassigning an agent, because the response code was nested inside the wrong conditional. It had been sitting there the whole time.
A wrong error message is worse than no error message. The registration form’s password field said it required eight characters. The actual validation rule required one. Nobody caught it by looking, because the message was doing its job of describing the intent, just not the code underneath it.
Centralize on the first repetition, not the third. Every time a set of values got typed out more than once, in a test file, in a form, in a schema, it turned out to belong in one shared constants file instead. Doing that early costs a few minutes. Doing it late means finding every place the old value snuck in.
A shared package isn’t a junk drawer. A database package started out holding exactly that, the schema and the connection client, and quietly grew a MinIO client and an email client alongside it, because both needed to live somewhere and the database package already had the plumbing set up. Nothing about a mail server belongs next to a database, and I didn’t catch it until it was pointed out to me directly. Pulling email into its own package afterward was easy. Not doing it in the first place was the actual mistake.
A stale error can outlive its fix. More than once, a change that was correct on paper still showed as broken in the editor, because TypeScript’s language server had cached an older version of a file and hadn’t noticed the update. Restarting it became a real, repeated step, not a one-time trick, and skipping it wasted time more than once before it became a habit.
Not every warning deserves a fix. Some warnings are announcing a real, approaching breakage. Others are just pointing at a newer alternative that works exactly as well as the old one. Chasing the second kind, twice, cost more time than the warnings themselves were ever worth, and one of those “quick fixes” broke a working build entirely before getting reverted.
Test Coverage
59 tests total: 37 on the backend covering every route including authentication, file uploads and deletion, email triggers, and pagination, and 22 on the frontend covering form validation, the ticket editor, and the live ticket list.
Backend:
✓ api src/app/routes/auth/register.test.ts (2 tests)
✓ api src/app/routes/auth/refresh.test.ts (3 tests)
✓ api src/app/routes/auth/logout.test.ts (2 tests)
✓ api src/app/routes/auth/login.test.ts (3 tests)
✓ api src/app/routes/tickets/upload-download.test.ts (5 tests)
✓ api src/app/routes/tickets/tickets.test.ts (11 tests)
✓ api src/app/routes/tickets/email-notifications.test.ts (3 tests)
✓ api src/app/lib/ticket-events.test.ts (2 tests)
✓ api src/app/routes/health.test.ts (1 test)
✓ api src/app/routes/me.test.ts (3 tests)
✓ api src/app/routes/agents/agents.test.ts (2 tests)
Test Files 11 passed (11)
Tests 37 passed (37)
Frontend:
✓ web src/components/nav-bar.spec.tsx (3 tests)
✓ web src/pages/tickets-page.spec.tsx (3 tests)
✓ web src/app/app.spec.tsx (2 tests)
✓ web src/pages/login-page.spec.tsx (4 tests)
✓ web src/pages/register-page.spec.tsx (4 tests)
✓ web src/components/ticket-form.spec.tsx (6 tests)
Test Files 6 passed (6)
Tests 22 passed (22)