oyware [email protected]

What EpisodeTracker is actually built from

A full tour of the stack — the API, the database, the services, and the words for all of it

2026-09-09TypeScript, React 19, Fastify 5, Prisma 6, PostgreSQL 16

Yes — this is a full-stack app, not a frontend

A lot of small web projects are just a frontend calling somebody else's API. This one is not.

There is a Fastify server written in TypeScript that you control, sitting in front of your own PostgreSQL database, on your own DigitalOcean droplet. Every account, tracked show, watched episode and notification lives in tables you designed.

The only things you do not own are the TV data (TVmaze), the model that suggests shows (Groq, then Gemini), and mail delivery (Brevo). All three are wrapped in your own code, so the rest of the app never touches them directly.

JSON API endpoints 33
Database tables 8
Screens in the app 14
Lines of your code ~12,500
Commits 153

That line count excludes dependencies and build output: 4,588 lines of API, 7,196 of web, 496 of shared types, 199 of database schema.

The stack, layer by layer

Read this top to bottom and you have the whole thing. Nothing else is in play.

Layer Technology What it does here
Language TypeScript 5.7 Both sides. One language front to back, with types shared between them.
UI React 19 Every screen. No UI kit, no Tailwind, no component library — the CSS is hand-written.
Routing (web) react-router-dom 7 URLs like /watchlist render without a page reload.
Build tool Vite 6 Dev server on :5173 and the production bundle in apps/web/dist.
Installable app vite-plugin-pwa Generates the manifest and service worker, so the site installs to a phone home screen.
Server Fastify 5 The backend. Handles every /api/* request and also serves the built React app.
Database access Prisma 6 Turns prisma/schema.prisma into SQL migrations plus a typed query client.
Database PostgreSQL 16 A Docker container locally; installed directly on the droplet in production.
Auth Node crypto.scrypt No auth library. Passwords hashed by hand, sessions stored as database rows.
Scheduling node-cron Runs the daily "airing today" job inside the server process.
Push web-push Sends browser notifications signed with VAPID keys.
Email Brevo HTTP API Plus nodemailer and Resend as alternate paths, chosen by which env vars are set.
Tests Node --test Built into Node — no Jest, no Vitest. Seven test files over the fiddliest logic.
Edge Cloudflare + Nginx DNS, TLS and proxying in front of the app.

This is a deliberately small dependency list. The API has 10 runtime dependencies and the web app has 3. Fewer libraries means fewer things that break on upgrade, and it is the reason a project this size still typechecks and builds in under a minute.

What happens when someone opens the site

This is a genuine sequence — each hop hands off to the next. Knowing it is how you debug anything.

  1. Cloudflare — DNS, TLS termination, proxy. It forwards to the droplet and injects its analytics beacon, which is why the app's Content-Security-Policy explicitly allows static.cloudflareinsights.com.
  2. Nginx, port 443 on the droplet — reverse-proxies everything to 127.0.0.1:3001. The app binds to loopback only, so nothing on the internet can reach the Node process directly; it has to come through Nginx.
  3. Fastify, apps/api/src/index.ts — one single Node process. If the path starts with /api it is the JSON API; otherwise it serves files from apps/web/dist, and for a known app route like /history it hands back index.html so React takes over.
  4. Session lookup — before any route runs, loadCurrentUser reads the sid cookie, finds the matching Session row and attaches the user to the request. Protected routes then simply check that it is there.
  5. Route handler, e.g. shows/routes.ts — the actual logic: reading tracked shows, computing how many episodes behind you are, calling TVmaze if the cache is stale.
  6. Prisma to Postgres — Prisma turns the typed query into SQL. Postgres runs on the same droplet and listens on localhost, so the database is not exposed to the network at all.
  7. JSON back to React, apps/web/src/api/client.ts — one fetch wrapper handles every call. It always sends credentials: "include" so the session cookie travels, and throws ApiRequestError on any non-2xx so screens can show a real message.

The design decision behind this. Web and API share one origin — one process serves both. That is why every API route lives under /api (so it can never collide with a page URL like /settings), and why the session cookie can stay sameSite: lax instead of needing looser cross-site settings.

Three workspaces in one repository

It is an npm-workspaces monorepo: one npm install at the root wires all three together.

Path Package Contents
apps/web/ @episode-tracker/web React app. 14 screens under src/pages/, shared parts in src/components/, one fetch wrapper in src/api/client.ts. Biggest file: ShowDetailPage.tsx, 639 lines.
apps/api/ @episode-tracker/api Fastify server, split by feature: auth/, shows/, push/, public/, admin/, plus tvmaze/, ai/, email/, cron/. Biggest file: shows/routes.ts, 907 lines.
packages/shared/ @episode-tracker/shared 37 TypeScript types both sides import, so the API and the UI cannot disagree about a shape. Also holds the two pure watch-history functions.
prisma/ — The database: schema.prisma plus 6 migrations, the SQL history of every table change since June.

The rule that keeps both sides honest

Because both apps import the same types, changing an API response shape without updating the UI is a compile error, not a runtime surprise. Two conventions make that work: dates are always ISO string in shared types (JSON has no date type), and the shared User type deliberately omits passwordHash, so it is impossible to accidentally send a hash to the browser.

All 33 API routes

Everything is mounted under /api. Anything marked session needs the sid cookie — that is 27 of the 33.

Accounts and sessions

Method Path Access What it does
POST /auth/signup public Create an account, hash the password, start a session.
POST /auth/login public Verify the password, set the httpOnly sid cookie.
POST /auth/logout public Delete the session row and clear the cookie.
POST /auth/change-password session Checks the old password first, then revokes every other session.
GET /auth/me session Who am I? What the UI calls on load to know if you are signed in.
PATCH /auth/preferences session Username, public-profile toggle, email and push switches.

Finding shows

Method Path Access What it does
GET /shows/search session Search TVmaze by name.
GET /shows/schedule session Everything airing on a given date, ranked by popularity — the Discover screen.
GET /shows/trending session What is popular right now.
GET /shows/suggestions session AI picks from your library. Never fails — falls back to popular shows.
POST /shows/suggestions/dismiss session "Not interested" — excluded from the prompt and every future response.
GET /shows/:id session Full detail for one show, including its episode list.
GET /shows/:id/similar session Related shows.

Your library

Method Path Access What it does
POST /shows/track session Cache the show from TVmaze, then record that you track it.
DELETE /shows/tracked/:id session Untrack.
GET /shows/tracked session The dashboard. Excludes watchlisted shows.
GET /shows/watching session Started but not finished.
GET /shows/watchlist session Saved for later, not started.
GET /shows/completed session Finished, with your rating.
GET /shows/history session Everything marked watched, newest first.
PUT /shows/tracked/:id/watchlist session Move to or off the watchlist.
PUT /shows/tracked/:id/watched session Set the "latest episode watched" marker.
PUT /shows/tracked/:id/episode session Mark one specific episode watched or unwatched.
PUT /shows/tracked/:id/season session Mark a whole season at once.
PUT /shows/tracked/:id/completed session Finish the show and rate it.
PUT /shows/tracked/:id/notify session Per-show notification opt-out.
POST /shows/tracked/email session Email yourself your tracked-shows table on demand.

Push, public and operations

Method Path Access What it does
GET /push/vapid-public-key public The browser needs this key before it can subscribe.
POST /push/subscribe session Register this device. Keyed on endpoint, so re-subscribing updates in place.
POST /push/unsubscribe session Remove this device.
GET /public/profile/:username public The shareable profile at /u/<username>, only if the user turned it on.
GET /health public Returns {status:"ok"}. Touches no database — the right target for an uptime monitor.
POST /admin/run-cron admin token Manually fire the daily notification job. Needs the x-admin-token header, and is disabled entirely if ADMIN_TOKEN is unset.

Eight tables, and what each one is for

Defined in prisma/schema.prisma. Every table except ShowCache uses a cuid string as its primary key.

Table Scope Purpose
User one per account Email, password hash, optional public username, and the two master notification switches — email on by default, push off.
Session many per user A login. The row id is what sits in the sid cookie. Logging out deletes the row, which is what makes sessions genuinely revocable.
TrackedShow per user + show The core join. Watchlist state, completion date, your rating, the per-show notify flag, and a denormalised "latest watched" marker for fast dashboard rendering.
WatchedEpisode per user + episode One row per episode watched. The real source of truth — it is what allows out-of-order watching, accurate behind-counts and watch-time stats.
ShowCache global One row per show, shared by everyone. Caches next-episode info, genres, runtime and the entire episode list as JSON, so the API computes your progress without calling TVmaze again.
NotificationLog per user + episode The dedupe guard. A unique key on (user, episode, kind, channel) is what makes the daily job safe to run twice without spamming anyone.
PushSubscription many per user One per browser or device. Auto-pruned when the push service reports it expired. Never sent back to the client.
DismissedSuggestion per user + show The "not interested" list, kept out of the AI prompt and filtered from every response.

The one structural quirk to remember

TrackedShow and ShowCache have no database foreign key between them, on purpose — you can track a show before it has been cached. The dashboard joins them in application code (loadTrackedShows()), not in SQL. If a tracked show ever renders with missing artwork or a blank next episode, that join is where to look.

The four services it depends on

Each one is isolated behind a single file, so the rest of the app never sees their raw responses.

TVmaze — all the TV data

Free, no API key. Wrapped in apps/api/src/tvmaze/client.ts, which exposes six functions and maps everything into the app's own types. Show details are fetched with ?embed[]=nextepisode&embed[]=episodes so one call gets both.

Groq, then Gemini — the suggestions engine

Both are OpenAI-compatible chat endpoints, so ai/client.ts is one generic caller with an ordered provider chain: try Groq, retry a couple of times on rate limits, then fall through to Gemini. Two things make this safe. Every AI pick is resolved against TVmaze by name, so hallucinated shows drop out. And the whole path never throws — if AI is down you get popular-airing-today picks, and the response says source: "popular" so the UI can be honest about it.

Brevo — email delivery

An HTTPS API rather than SMTP, and that is the point: most VPS providers block outbound mail ports, and this one does. The mailer is pluggable — getEmailer() picks the first configured option in order (Brevo, then Gmail SMTP, then Resend, then console), so local development can just print emails to the terminal.

Cloudflare — DNS, TLS, proxy

Sits in front of the droplet, and is the reason the CSP has that one allowance for the analytics beacon it injects at the edge.

Cost. TVmaze, Groq and Gemini are free tiers. Brevo is free to roughly 300 emails a day. Cloudflare is the free plan. The only thing being paid for is the droplet.

Running it, testing it, shipping it

Local development

npm install                  # installs all three workspaces
npm run db:up                # Postgres in Docker (Docker Desktop must be running)
npm run dev                  # API on :3001 + web on :5173, together

npm test                     # 7 test files, Node's built-in runner
npm run db:studio            # browse the database in a GUI
npm run db:migrate -- --name add_thing

Deploying

Deployment is a manual command, never an automatic push. Work happens on develop; when a change is confirmed it is fast-forwarded to main, and then deploy-oyware is run from the Mac. Pushing to main on its own changes nothing on the live site. The deploy script backs up the database before it touches anything, then runs prisma migrate deploy on the droplet.

What runs on its own

One thing: the daily notification job, scheduled by node-cron inside the running server at 0 12 * * * in Asia/Amman. It refreshes the show caches, finds episodes airing today, and emails or pushes each tracker once per episode.

Because it lives in the process, a deploy restart across that exact minute can skip a day — which is the one gap the (currently disabled) GitHub Action was kept around to cover.

What GitHub checks

On every push to develop or main, CI installs, generates the Prisma client, runs the tests, typechecks all three workspaces and builds the web app. No secrets and no database needed — they are all offline checks.

Terms you will hit in your own codebase

Coming from mobile development, most of this maps onto something already familiar.

Term What it means here
Fastify The web server framework. Roughly what Ktor is on the JVM: build an app object, register routes, listen on a port.
Prisma An ORM. You describe tables in one schema file; it generates both the SQL migrations and a fully typed query client, so a typo in a column name fails at compile time.
migration A numbered SQL file recording one change to the database structure. Applied in order, they rebuild the schema from scratch on any machine. There are 6.
cuid The random string ID format used for primary keys here, instead of auto-incrementing numbers. Safe to generate anywhere, and it does not leak how many rows exist.
preHandler A Fastify hook that runs before a route handler. This one resolves the session cookie into a user, so every route can just read request.currentUser.
scrypt A deliberately slow, memory-hungry hashing function. The slowness is the feature — it makes brute-forcing a stolen password hash expensive.
httpOnly cookie A cookie JavaScript cannot read, which is why a stolen session cannot be lifted out of the page by injected script.
CORS The browser rule about which origins may call your API. In production it is not needed at all — web and API share one origin — so it is not even registered.
CSP Content-Security-Policy. A header listing exactly what the page may load. This one is built at boot from the shipped HTML, so it cannot go stale.
SPA Single-page app. The server sends one HTML shell and React swaps screens without full page loads. It is why the server keeps an explicit list of app routes to serve that shell for.
PWA The bundle of manifest, service worker and icons that makes a website installable to a home screen, like a lightweight app install.
service worker A background script in the browser. This one does two jobs: caching the app shell for instant repeat loads, and receiving push notifications when the tab is closed.
VAPID The keypair that proves push messages genuinely came from your server. The public half is handed to the browser; the private half stays in the droplet's .env.
ESM Modern JavaScript modules. The quirk here: imports are written with a .js extension even though the file on disk is .ts.
monorepo Several packages in one repository, installed together. This one has three, linked by npm workspaces.
reverse proxy Nginx taking public requests and forwarding them to the app on localhost. It holds the certificates and shields the Node process from direct traffic.

One thing that has drifted

The repository's CLAUDE.md still says "No Web Push yet (email is still the notification path)". That is out of date. Web Push shipped: there is a PushSubscription table, a 20260620130000_web_push migration, three push endpoints, a push-sw.js service worker, and per-channel dedupe in NotificationLog.

Worth correcting, because that file is what any future session reads first.