A platform where students can experiment with all the possible combinations of class schedules they can pick at the start of the semester.
Made with ❤️ by NIAEFEUP. Production: https://tts.niaefeup.pt.
This repository is a Bun workspace monorepo with two apps:
| Path | Stack | Dev URL |
|---|---|---|
apps/backend |
Bun + Elysia + Prisma 8 | http://localhost:3000 |
apps/frontend |
React + Vite + Tailwind | http://localhost:3100 |
New here? Follow Quick start top to bottom. For team workflow (branching, commits, PRs) read
.github/CONTRIBUTING.md.
- Requirements
- Quick start (fresh clone)
- Run the app
- Environment variables
- Authentication
- Database
- Docker
- Quality checks & CI
- Useful commands
- Project structure
- Troubleshooting
- Further documentation
- Bun >= 1.4 — the runtime and package manager for the whole monorepo.
curl -fsSL https://bun.sh/install | bash - Docker + Docker Compose — runs the local PostgreSQL database (and the optional containerized apps).
- (Optional) devenv / Nix — the repo ships a
devenv.nix+.envrcthat provide Node 22, Bun and corepack. If you usedirenv, rundirenv allowonce.
Every command below is meant to be copy-pasted from the repository root unless a cd is shown.
# 0. Clone
git clone https://github.com/NIAEFEUP/tts-fe.git # or the SSH URL if you prefer
cd tts-fe
# 1. Install all workspace dependencies (installs both apps)
bun install
# 2. Start PostgreSQL (127.0.0.1:5434, user/password/db = tts/tts/tts)
# `--wait` blocks until the database is actually accepting connections.
docker compose up -d --wait
# 3. Create the app env files (both are git-ignored; the committed .env.schema holds
# working defaults). The frontend copy is optional for local dev.
cp apps/backend/.env.schema apps/backend/.env
cp apps/frontend/.env.schema apps/frontend/.env
# 4. Edit apps/backend/.env and set at least your own NMEC ("up" number) and role.
# See the Environment variables section below.
# 5. Generate the Prisma contract, create the tables, and seed the demo data.
cd apps/backend
bun run contract:emit # generate contract.json + contract.d.ts (git-ignored)
bun run db:init # create all tables (empty DB only)
bun run db:seed:demo # catalog + demo data; makes ADMIN_UP an admin
cd ../..db:seed:demo is the last step on purpose: db:seed and db:seed:demo are mutually exclusive and
both truncate every table first, so if you run db:seed afterwards you lose the demo exchange data.
Start the two servers in two terminals (both hot-reload on save):
# terminal 1 — backend → http://localhost:3000
cd apps/backend && bun run dev
# terminal 2 — frontend → http://localhost:3100
cd apps/frontend && bun run devOpen http://localhost:3100.
Daily loop: docker compose up -d (if the database is stopped) plus the two bun run dev.
Both apps declare their environment in a committed .env.schema and validate it at startup
with varlock. The schema is the source of truth; local overrides go in a
git-ignored .env (or .env.local) sitting next to it. Validate at any time with:
bunx varlock load # run from inside apps/backend or apps/frontend| Variable | Default | Notes |
|---|---|---|
DATABASE_URL |
postgresql://tts:tts@localhost:5434 |
Matches the Docker Compose database. |
ADMIN_UP |
up2026xxxxx |
Set to your own NMEC. Seeded as the demo admin by db:seed:demo. |
ADMIN_ROLE |
ADMIN |
USER | ADMIN | SUPERUSER. |
PORT |
3000 |
Backend port. |
FRONTEND_URL / ALLOWED_ORIGINS |
http://localhost:3100 |
Post-login target and credentialed-CORS allowlist. |
DEV_LOGIN_ENABLED |
true |
Enables username/password dev login; forced off when NODE_ENV=production. |
SIGARRA_USERNAME / SIGARRA_PASSWORD |
empty | Sigarra account the dev login authenticates as (only read in dev). |
OIDC_CLIENT_ID / OIDC_CLIENT_SECRET |
empty | Needed only for real Sigarra OIDC login. |
ADMIN_UP is stored as bare digits internally, so up202207150 and 202207150 are the same
account. See apps/backend/.env.schema for the full, commented list
(session TTL, cookie flags, OIDC endpoints/scopes, …).
Optional for local dev (the schema defaults are enough). Useful overrides:
| Variable | Default | Notes |
|---|---|---|
PORT |
3100 |
Vite dev-server port. |
VITE_APP_BACKEND_URL |
http://localhost:3000 |
Backend the SPA talks to. |
VITE_APP_FEDERATED_AUTH |
false |
true shows the Sigarra OIDC login button instead of dev login. |
Only VITE_-prefixed variables reach the browser; everything else is treated as sensitive. In
code, read them through varlock's typed ENV object (import { ENV } from 'varlock/env') — not
import.meta.env (always a string, so "false" is truthy). See
apps/frontend/.env.schema for the full list.
Two flows, selected by environment:
- Local dev — username/password.
POST /v1/auth/dev-loginvalidatesSIGARRA_USERNAME/SIGARRA_PASSWORDagainst Sigarra and issues a cookie session. Enabled byDEV_LOGIN_ENABLED(on by default, forced off whenNODE_ENV=production). The frontend shows this button whenVITE_APP_FEDERATED_AUTH=false(the default). - Production — Sigarra OIDC.
GET /v1/auth/loginredirects to Sigarra; the callback exchanges the code, upserts the user and sets the same httpOnlysessioncookie. SetOIDC_CLIENT_ID/OIDC_CLIENT_SECRET/OIDC_REDIRECT_URI/FRONTEND_URL/ALLOWED_ORIGINSon the backend andVITE_APP_FEDERATED_AUTH=trueon the frontend.
Routes are served under /v1; the public URL also carries an /api prefix added by the
deployment (Niployments), so the frontend's VITE_APP_BACKEND_URL must include it (e.g.
https://tts.niaefeup.pt/api) in staging and production.
Roles are assigned by seeding, never by logging in: db:seed:demo creates the ADMIN_UP
account with ADMIN_ROLE and full admin scope.
The schema and runtime client live in apps/backend/src/infrastructure/database/:
| File | Purpose |
|---|---|
contract.prisma |
The schema — the source of truth. Edit this. |
contract.json |
Generated by contract:emit. Do not edit. Git-ignored. |
contract.d.ts |
Generated by contract:emit. Do not edit. Git-ignored. |
prisma.ts |
Runtime client. import { db } from '@/infrastructure/database/prisma'. |
The project uses Prisma 8 (contract-first, @prisma/orm-postgres) — do not follow Prisma
6/7 guides. See apps/backend/docs/tech-stack.md for the full
rationale and client usage, and apps/backend/docs/database.md
for the migration model, data transforms and recovery paths.
Both seed scripts truncate every table first and produce deterministic ids, so they are safe to re-run.
| Command | Populates |
|---|---|
bun run db:seed |
Catalog only — faculties, courses, course units and occurrences, from the committed fixture. |
bun run db:seed:demo |
Catalog plus the mock exchange layer — users, classes, enrollments, exchange windows, sample exchanges and enrollment requests. |
⚠️ The two are mutually exclusive.db:seedwipes the demo data anddb:seed:demore-creates it. Always finish withdb:seed:demoif you want exchanges.
The demo admin is you: set ADMIN_UP to your NMEC in apps/backend/.env. db:seed:demo
creates that account with role ADMIN_ROLE. Both seeds refuse to run unless DATABASE_URL points
at a loopback host.
The catalog is real scraper data in
src/scripts/fixtures/feup-leic-meic-mesw.json;
regenerate it from the repo-root database.db with bun run catalog:extract. The mock layer covers
L.EIC and M.EIC only.
edit contract.prisma → contract:emit → (dev: db:update) or (shared/prod: migration:plan → migrate)
Quick path (local dev only): applies the diff directly, no migration history — never use against a shared database.
cd apps/backend
# 1. edit src/infrastructure/database/contract.prisma
bun run contract:emit
bun run db:updateFormal path (reviewed, version-controlled, replayable):
cd apps/backend
# 1. edit src/infrastructure/database/contract.prisma
bun run contract:emit
# 2. plan a migration (writes migrations/app/<timestamp>_<slug>/)
bun run migration:plan -- --name <slug>
# 3. review the generated files
bun run migration:show -- <dir-name-or-hash-prefix>
# 4. preview, then apply
bun run migrate:show
bun run migrate
# 5. refresh the local `db` ref (it lags after a plain migrate)
bun run db:updateAdding a NOT NULL column with no default leaves a placeholder(...) in the generated
migration.ts; fill in the data transform, self-emit with node migrations/app/<dir>/migration.ts,
then bun run migrate.
cd apps/backend
bun run db:verify # verify the live DB matches the contract (0 = clean, 4 = drift)
bun run db:schema # print the live DB schema as a tree
bun run migration:status # pending / applied migration state
bun run migration:list # all on-disk migrations as a graph
bun run db:sign # re-sign the marker after a manual DB fix
bun run db:studio # open Prisma Studio (visual DB inspector)| Error | Cause | Fix |
|---|---|---|
MIGRATION.HASH_MISMATCH |
migration.ts edited without self-emitting |
node migrations/app/<dir>/migration.ts, then bun run migrate |
MIGRATION.UNFILLED_PLACEHOLDER |
Placeholder not filled in migration.ts |
Fill the transform, self-emit, then bun run migrate |
MIGRATION.PLAN_ORIGIN_UNKNOWN |
No db ref and migrations already exist |
Run bun run db:update, or pass --from <hash> |
MIGRATION.HASH_NOT_IN_GRAPH |
db ref points past the graph |
bun run migration:ref:set -- db <hash>, then re-plan |
CONTRACT.MARKER_MISSING |
DB has no marker (fresh DB or wipe) | bun run db:init |
CONTRACT.MARKER_MISMATCH |
DB is at a different contract version | Migrate forward, or bun run db:sign if the DB is correct |
Run from the repository root:
docker compose up -d --wait # start PostgreSQL and wait until healthy
docker compose down # stop (keeps the data volume)
docker compose down -v # stop and wipe the database volume
docker compose logs -f postgresFrom apps/frontend:
# Development container (hot reload, port 3100)
chmod +x build.sh dev.sh prod.sh # once, if scripts aren't executable
./build.sh dev # docker compose build tts-frontend
./dev.sh # docker compose up tts-frontend
docker compose down # stop
# Production container (static build served by nginx)
touch .env # production build copies .env into the image
./build.sh prod # docker compose build tts-frontend-prod
./prod.sh # docker compose up tts-frontend-prodThe root Dockerfile is multi-stage with targets backend-dev, backend-prod, frontend-dev,
frontend-prod:
docker build --target backend-prod -t tts-be .
docker build --target frontend-prod -t tts-fe .The monorepo uses Oxlint for linting and Oxfmt for formatting.
A Husky pre-commit hook runs oxfmt --check and oxlint automatically on every commit.
# from the repository root
bun run lint # lint both apps
bun run lint:fix # lint + auto-fix
bun run format # format both apps
bun run format:check # check formatting without writing
# per app
cd apps/backend && bun run test && bun run typecheck # bun test; emits contract then tsc
cd apps/frontend && bun run typecheck && bun run build # tsc; needs the emitted contractBefore pushing, run the same checks CI runs: bun run contract:emit in apps/backend,
bun run typecheck and bun run test there, bun run typecheck in apps/frontend, and
bun run lint from the root. See .github/workflows/codeanalysis.yaml.
# ── repo root ───────────────────────────────────────────────────────────────
bun install # install all workspace dependencies
bun run lint # lint both apps (oxlint)
bun run lint:fix # lint + auto-fix
bun run format # format both apps (oxfmt)
bun run format:check # check formatting
# ── backend (apps/backend) ──────────────────────────────────────────────────
bun run dev # dev server + hot reload (http://localhost:3000)
bun run start # production server
bun run test # run tests (bun test)
bun run test:watch # tests in watch mode
bun run test:coverage # tests with coverage
bun run typecheck # contract:emit + tsc
bun run lint # oxlint
bun run contract:emit # regenerate contract.json + contract.d.ts
bun run catalog:extract # regenerate the scraper fixture from database.db
bun run db:init # create tables on an empty DB + write the marker
bun run db:update # quick dev-only schema sync (no migration history)
bun run db:seed # seed catalog only (truncates)
bun run db:seed:demo # seed catalog + demo data (truncates; makes ADMIN_UP admin)
bun run db:verify # verify live DB matches the contract
bun run db:schema # print live DB schema as a tree
bun run db:sign # re-sign the marker after a manual DB fix
bun run db:studio # open Prisma Studio
bun run migrate # apply all pending migrations
bun run migrate:show # preview what would run (no writes)
bun run migration:plan -- --name <slug> # plan a migration
bun run migration:show -- <dir> # inspect one migration package
bun run migration:status # pending / applied state
bun run migration:list # list on-disk migrations as a graph
bun run migration:ref:set -- <name> <hash> # set a migration ref pointer
# ── frontend (apps/frontend) ────────────────────────────────────────────────
bun run dev # Vite dev server (http://localhost:3100)
bun run build # typecheck + production build
bun run typecheck # typecheck only
bun run preview # preview the production build locally
bun run lint # oxlint
# ── docker ──────────────────────────────────────────────────────────────────
docker compose up -d --wait # start PostgreSQL
docker compose down # stop (keeps data)
docker compose down -v # stop and wipe data
docker build --target backend-prod -t tts-be .
docker build --target frontend-prod -t tts-fe ..
├── apps/
│ ├── backend/ # Bun + Elysia + Prisma 8 API
│ │ ├── src/
│ │ │ ├── application/ # Use-cases (business logic)
│ │ │ ├── core/ # Auth, config, utils
│ │ │ ├── domain/ # Entities, ports, value objects
│ │ │ ├── infrastructure/ # Database (Prisma), external adapters
│ │ │ └── interface/ # Routes, middleware, validators
│ │ ├── docs/ # endpoints.md, tech-stack.md, database.md, schema-migration.md
│ │ ├── legacy/ # Old Django backend (deprecated)
│ │ └── .env.schema # Backend env vars + defaults (varlock)
│ └── frontend/ # React + Vite SPA
│ ├── src/
│ └── .env.schema # Frontend env vars + defaults (varlock)
├── docs/ # Cross-cutting docs
├── .github/ # CI workflows, issue/PR templates, CONTRIBUTING
├── docker-compose.yaml # PostgreSQL for local dev
├── Dockerfile # Multi-stage images for both apps
└── package.json # Workspace root (lint, format, scripts)
The backend follows a layered, domain-driven pattern: interface (thin routes) → application
(use-cases) → domain (entities/ports, framework-free) → infrastructure (Prisma, adapters).
db:init only works on an empty database. If the DB already has tables, either it is already
current (bun run db:sign) or you need to wipe and recreate it:
# from the repository root
docker compose down -v # stop and wipe the database volume
docker compose up -d --wait # start fresh
cd apps/backend
bun run contract:emit
bun run db:init
bun run db:seed:democontract.json and contract.d.ts are git-ignored, so a fresh clone — or a pull that changed
contract.prisma — leaves the generated Prisma client missing or stale (errors like
Cannot find module './contract.d.ts' or Property 'Occurrence' does not exist). This is not a
code bug — regenerate the client:
cd apps/backend
bun run contract:emitbun run typecheck runs contract:emit first, so unless you invoked tsc directly the client
cannot be stale.
Make sure the backend is running on :3000, VITE_APP_BACKEND_URL points at it, and the backend's
ALLOWED_ORIGINS includes the frontend origin (http://localhost:3100 by default).
The frontend type-checks against the backend's emitted declarations, so emit the contract first
(cd apps/backend && bun run contract:emit).
.github/CONTRIBUTING.md— branching, commits, PRs, code standards.apps/backend/docs/endpoints.md— the API source of truth.apps/backend/docs/tech-stack.md— stack decisions and Prisma 8 usage.apps/backend/docs/schema-migration.md— legacy Django → Prisma 8 mapping.docs/all-issues-guide.md— the cutover issue breakdown.