← Francisco Cascalheira

Case study · Feb 2026 — present

In production · API responding · 08:20 UTC

opPORTOnities

Câmara Municipal do Porto places young people in summer internships through software one student built alone.

Client
Câmara Municipal do Porto
Programme
Summer-internship programme, ages 18–21
Role
Sole developer — requirements to production
Stack
TypeScript · Express · Prisma · PostgreSQL · React (Vite) · Zod · Azure Blob · Railway

What this document claims.

opPORTOnities is the recruitment platform behind the city's summer-internship programme: candidates aged 18–21 register, approved companies post vacancies, and applications move through a supervised pipeline to confirmed placements. I sat in the requirements meetings, designed the data model, wrote every line, and deployed it. I am the only engineer who has ever committed to this codebase.

294
commits, all mine
git shortlog -sn: one author
380+
vacancies handled
12
relational models
3
portals: candidate · company · admin

What the city needed.

Every summer, Porto's city council places hundreds of young residents and students in paid internships at local companies. Before this platform, that meant forms, spreadsheets and email threads: candidates mailing documents, staff cross-checking eligibility by hand, companies chasing the council for candidate lists.

The council needed a real system: one place where candidates register and prove eligibility, companies apply and get vetted, vacancies get published, and every application moves through a controlled pipeline that municipal staff supervise — with the reporting a public institution is accountable for.

The rules of the job.

One developer

No team to review my architecture. Every decision — schema, auth, deployment — was mine to get right, and mine to fix at 2 a.m. when it wasn't.

Municipal stakeholders

Requirements arrived in meetings with council staff, in Portuguese, shaped by administrative law and programme rules — an age window, residence criteria, vetting duties. The spec was a conversation, not a document.

A real deadline

The programme has a calendar. Registrations open on an announced date whether the software is ready or not.

Other people's sensitive data

Eighteen-to-twenty-one-year-olds submitting IDs, school records and addresses. GDPR consent, honour declarations, audit trails and strict company-side visibility rules are load-bearing features, not compliance theatre.

Twelve models, one machine.

The whole platform stands on twelve Prisma models over PostgreSQL. This is the real schema, sanitised to shapes — click a model, or walk it with the arrow keys.

Application

Matching

The core object: one candidate applying to one vacancy, exactly once.

Shape

  • status machine (see fig. 2)
  • timestamps per transition: applied, interest, selected, rejected
  • selectionExpiresAt — selections lapse
  • company + admin notes
  • UNIQUE (candidate, vacancy)

Relations

  • · belongs to
  • · belongs to
  • · 1—1 optional

Why it's shaped this way — The unique constraint is the referee: the database — not application code — guarantees a candidate can't apply twice to the same vacancy, no matter what races the UI produces.

fig. 1 — 12 relational models · one developer · in production for Câmara Municipal do Porto

An application is the contested object: companies compete for candidates, candidates weigh offers, and the council supervises all of it. Its lifecycle is an explicit state machine — every transition timestamped, every selection given a deadline.

  1. 01 PENDING

    Candidate applied. Visible to the company under the platform's visibility rules.

  2. 02 COMPANY_INTERESTED

    The company flags interest. Timestamped; the candidate is notified.

  3. 03 SELECTED

    A selection with a deadline — selectionExpiresAt. Unanswered selections lapse instead of blocking the candidate forever.

  4. 04 CONFIRMED

    Candidate accepts. A Match record is created — the placement now exists independently of the pipeline.

Terminal states

  • REJECTED · by the company
  • WITHDRAWN · by the candidate
  • DECLINED · candidate turns down a selection
fig. 2 — the application state machine, as deployed. Each transition is timestamped; selections expire.

What broke, and what held.

4.1Create the account last

The first registration wizard created the user account at step one. Real users abandoned mid-wizard, and the half-registered accounts leaked into admin dashboards and Excel exports until staff asked why the numbers looked wrong. I rewrote the flow to defer account creation to the final submit, added error recovery for the failure cases uploads produce, and filtered the legacy orphans out of every report. Transactional boundaries belong at the end of a flow, not the beginning.

commit: “refactor(register): defer account creation to final submit

4.2Let the database referee

Selection is contested: companies compete for the same candidates, and a candidate can be selected by one company while another is deciding. Application code can't be trusted to serialise the world, so the guarantees live in Postgres — a unique constraint on (candidate, vacancy), a 1—1 constraint between Match and its winning application, and selection deadlines that lapse automatically. Then I wrote race-condition tests against the running API to prove it holds.

commit: “Harden matching flows with race-condition coverage

4.3Authorisation lives in one place

What a company may see about a candidate is a policy question with legal weight. Early on, those rules were re-implemented per endpoint — until two screens disagreed about how many applications a company had. I centralised visibility into one module every route consults, and put the master switch (whether companies can access candidate data at all) in the platform settings the council controls. When counts disagree, users stop trusting the numbers; when authorisation is scattered, you can't even say what the rule is.

commit: “Centralize company application visibility rules

4.4Excel is a production surface

The council runs on Excel, so exports aren't a convenience feature — they're how the programme is administered and reported upward. I hit Excel's own cell-size limits, blob-storage URL generation failing mid-export, and downloads breaking across browsers. Each fix taught the same lesson: the file a stakeholder opens is as much “the product” as any screen, and it gets the same testing, logging and error handling.

commit: “Fix admin report export Excel cell limits

Where it stands today.

The platform is in production, serving candidates, companies and municipal staff through three portals. It has handled 380+ vacancies. Before each release I run a smoke-test suite against the live API; before the biggest one I ran a four-pass audit of the whole platform and fixed 91 bugs, because nobody else was going to find them.

The stack is deliberately boring: Express, Prisma, PostgreSQL, a React SPA, and a shared types package so the API contract is one set of Zod schemas consumed by both sides. Boring bought me speed as a solo developer — every hour spent fighting a clever framework is an hour the council doesn't get.

Production API responding at time of render · checked 08:20 UTC · refreshed every 5 minutes