← Francisco Cascalheira

Case study · NOS Alive 2026 · 9–11 July · Algés

In development · ran live at NOS Alive 2026

UniSpot

FR Eventos staffed eighteen bars at NOS Alive 2026, and the clocking, correcting and paying of the people who worked them ran on software I led the building of.

Client
FR Eventos (FR Unipessoal, Lda)
Event
NOS Alive 2026 · 9–11 July · Algés
Role
Lead developer — 295 of 322 commits, with two colleagues
Stack
TypeScript · Next.js 15 · React 19 · Prisma · PostgreSQL · iron-session · Zod · Railway
Public record

fig. 0 · the site, and who ran it

av. dr. alfredo magalhães ramalhopasseio marítimo de algés · rio tejoPALCO CLUBBINGPALCO NOSPALCO COMÉDIAops411131516171235678910121418CGCGCGCCDRBNOS Alive 202638.70° N · 9.23° W · algés · 9–11 jul18 bars · 209 staff · FR eventosin the platformother operators
fig. 0 — NOS Alive 2026, Algés · 18 bars lit = FR Eventos = the database · 6 ghosts = other operators, absent from it

What this document claims.

UniSpot is a staffing console for event bars: a manager opens each bar's roster, marks who turned up and when, corrects the record on the spot, and the system turns real hours into a bill. At NOS Alive 2026 it ran the floor for FR Eventos across eighteen bars and three nights, from a container behind the main stage. I designed the data model, wrote the concurrency, tenancy and time logic, and was on site to fix whatever broke. Two colleagues built parts of the interface; the engine is mine.

18
bars run
count Posto kind=BAR · NOS Alive 2026 · 18 Jul 2026
NOS Alive 2026 — the festival
209
staff rostered
distinct employees on the roster · 18 Jul 2026
614
shifts
count Shift · NOS Alive 2026 · 18 Jul 2026
944
clock events
count TimeEvent · captured on the console · 18 Jul 2026

What a festival floor needs.

A festival bar operator lives or dies on its floor. FR Eventos runs the bars at NOS Alive, Sudoeste, Paredes de Coura — hundreds of people across dozens of points of sale, most of them hired for a single weekend, many turning up to a bar they have never seen. Someone has to know who is working which bar tonight, whether they actually showed, when they came and went, and what each of them is owed at the end.

Before this, that was paper sign-in sheets, WhatsApp, and a spreadsheet reconciled after the fact — which meant hours nobody could stand behind and pay disputes nobody could settle. The job was to replace it with one place where a manager rosters a bar, clocks its staff in and out as they arrive, corrects the inevitable mistakes on the spot, and reads off billable hours that reconcile to the minute — running on venue wifi that drops, over three nights that do not wait for a bug fix.

The rules of the job.

The network is the enemy

Behind a stage, on saturated venue wifi and 4G, a request commits and its acknowledgement never comes back. The system has to survive the retry that follows without duplicating a person or an hour — idempotency is not a nicety here, it is the whole floor.

Nights cross midnight

A bar shift runs 19:30 to 06:00. A six-in-the-morning exit belongs to the night before, not to a new day — so “today” cannot mean the calendar day, and the operational day, its cutoff, and its edge cases in summer time were load-bearing from the first shift.

Managers, not operators

The people using it at 2 a.m. are bar managers, not trained operators. The console has to be usable one-handed on a phone, forgiving of the wrong tap, and honest when it refuses — every correction leaves a trail, none of them silently rewrites history.

One database, several companies

The platform is multi-tenant on a shared database — an organiser, its supplier companies, its managers, each allowed to see a different slice. Getting that scoping wrong leaks one company's roster to another, so every request resolves what the caller may touch before it touches anything.

Eighteen models, one console.

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

Shift

The floor

One person, one bar, one night — planned versus what happened.

Shape

  • employeeId, postId, date, planned start / end
  • actual start / end (from punches)
  • status: PLANNED | PRESENT | ABSENT | SUBSTITUTED
  • needsReview + reason, reviewedManually
  • rate, invoiceId

Relations

  • · worked by
  • · at
  • · 1—N
  • · billed on

Why it's shaped this way — The contested object (see fig. 2). Actuals come from clock punches; a human review can override them, and once reviewed, no late punch or idempotent retry may quietly undo the decision.

fig. 1 — 18 relational models · lead developer, with two colleagues · ran live at NOS Alive 2026

A shift is the contested object: it is planned against a bar, filled by whoever turns up, corrected when they punch in wrong, and finally billed. Its life is an explicit sequence — actuals from clock punches, dated by the operational day, and made final by a human.

  1. 01 PLANNED

    Rostered to a bar for a night, with planned hours and a resolved rate. Nobody has turned up yet.

  2. 02 PRESENT

    An ENTRY punch arrives on the console; actualStart is stamped. The operational-day cutoff decides which night it belongs to — a 06:00 punch is still last night.

  3. 03 REVIEWED

    An EXIT closes the pair, or the cron closes an unpaired shift without inventing an end time. A manager confirms or adjusts; reviewedManually is set, and retries can no longer touch it.

  4. 04 BILLED

    Real hours where the punches are clean, planned hours where they are not — flagged, never zero. The shift joins an invoice.

Terminal states

  • ABSENT · rostered, never punched — the cron marks it, inside a 24–48h window so it cannot rewrite old history
  • SUBSTITUTED · covered by another worker
fig. 2 — the shift lifecycle, as deployed. Actuals come from clock punches; the operational-day cutoff dates them; a human review is final.

What held.

4.1Make the console idempotent, or lose the floor

The console's mass actions are not naturally repeatable: an anonymous entry creates people, a mass exit closes shifts by count. Routes retry on a dropped connection — including the retry after a transaction that committed but whose acknowledgement was lost on venue wifi. Re-running that duplicates people and their hours. The fix is a batch key (ConsoleActionBatch) written as the first row of the transaction, so it commits atomically with the work; a re-send collides on the primary key and returns the stored result. Mass exit closes with an atomic claim — updateMany where the shift is still open — so a second manager gets nothing, not a phantom exit. This is fig. 4.

commit: “Endurecimento pré-lançamento: idempotência da consola e confiança na faturação

4.2The night, not the calendar day

Every read of “today” once derived the day from calendar midnight, so a night that ran to 06:00 split across two days and a 100%-night event broke. The operational day runs from a per-event cutoff to the next cutoff (default 07:00), so a late exit stays in the night it belongs to. The first version anchored the boundary to UTC and, in summer time, stranded a 06:30 Lisbon exit in the previous night; it now works in local wall-clock time.

commit: “definir e editar a hora de corte do dia operacional

4.3Resolve who you are before you touch anything

On a shared database, one wrong query hands one company's roster to another. Every request resolves an admin context first — which company, which events, which bars this caller may see, and whether they may see money at all — and scoped routes re-query the event under that scope before acting. The money-blind MANAGER role is a single capability check, not a rule re-implemented per screen.

commit: “fecha três fugas entre inquilinos nas rotas sem eventId no caminho

4.4Never bill zero to someone who worked

Real events have missing and late punches. Billing takes real hours from a clean punch pair, falls back to planned hours when a pair is missing, and only ever bills zero to someone marked absent — and every planned fallback is flagged for a human, not settled silently. A separate flag distinguishes a deliberate zero rate from an unresolved one, because the two used to collapse into the same number.

commit: “faturação real, fallback planeado

What broke.

A live festival is an honest test. Here is what broke — some of it days before the gates opened, some of it my own good code that turned out to be the wrong thing to keep.

5.1Three tenant leaks, five days before the festival

WasRoutes that did not carry an event id in their path slipped past the scope guard. Any admin session could confirm or adjust another organiser's shifts by id, attach a punch to another organiser's worker, or list another organiser's whole roster — names, codes, night by night.

NowEach of those routes now resolves the event from the shift or punch it is given and intersects it with the caller's scope; the supplier picker validates that a company is actually a supplier of this organiser. Found late, in a pre-launch audit — worth saying plainly.

5.2A retry undid a manager's decision

WasA kiosk punch arriving after a shift was reviewed re-ran the actuals computation and resurrected status and times from the punches — quietly reversing a manager's confirm, adjust or absent.

NowA reviewedManually flag makes the recompute early-return: once a human has decided a shift, no late punch or idempotent retry may touch its actuals.

5.3Two managers, one orphan punch

WasResolving an orphaned punch read the row and then wrote it, non-atomically. Two managers resolving the same unknown-number punch overwrote each other, last writer winning.

NowResolution is an updateMany that still carries employeeId: null in its where clause — zero rows means someone got there first, and the second attempt refuses instead of clobbering.

5.4The kiosk I built, hardened, and deleted

WasA self-service kiosk — punch by PIN or phone — with a genuinely hard concurrency engine behind it: a client-generated punch id for idempotency, a per-employee row lock for the read-decide-write, a clock-drift guard, all tested against real Postgres.

NowIt was used five times at NOS Alive and removed days later, because the floor ran on managers marking presence at the bar. The engine was good work; keeping unused code because it was hard to write is not. Removing it is part of the story, not a footnote to it.

The first of those — the idempotent console — is the engine that actually carried the festival. It is worth seeing run. The lever is the only variable: whether a batch key was written before the work.

Enter 20 anonymous staffThe retry has nothing to collide with, so the server runs the whole batch again — twenty people and their hours, a second time.

requestpeopleack lostsendwrite ×20commitretry0 in DB20 in DBINSERT ×2040 in DBtwenty people, and their hours, duplicated

Method

The batch key is ConsoleActionBatch.id, written as the first statement of the mass-entry transaction so it commits atomically with the twenty inserts. A re-send collides on the primary key (Prisma P2002) and returns the stored result rather than re-executing. Mass exit closes with an atomic updateMany over still-open shifts, so a second manager closing the same bar gets a count of zero, not a phantom exit. The same change collapsed twenty per-person transactions into one — a measured 101s → 13s for a bar of twenty.

fig. 4 — the lost-ack retry · commit ece2ce0 · the batch key is the referee

Where it stands today.

It ran live at NOS Alive 2026, 9 to 11 July, from a container behind the main stage — three of us on site, walking the bars to show managers the console and fixing what came up in real time. Across eighteen bars the platform holds 209 rostered staff, 614 shifts and 944 clock events, virtually all of them entered by managers on the console. The self-service kiosk that was supposed to capture them was used five times and retired a week later.

The corroboration is the festival's own site map, in fig. 0. Its bars are colour-coded by operator; the eighteen it marks as FR Eventos' — lit on the plate, twelve numbered bars and six branded stands — are the exact eighteen postos in the database, and the six it marks for other operators, drawn here as ghosts, are absent from it. FR Eventos is a real company with a public history of running these bars, and the festival ran on the dates the database records. What no external document attests is authorship: that is git's word, and git says 295 of 322 commits are mine, with two colleagues on the interface. The hard parts — the idempotent console, the tenancy, the operational day — are mine alone.

It is not in production the way the city platform is; it is a pilot that ran once, for real, and is still being built. The stack is deliberately ordinary — Next.js, Prisma, one Postgres — because a festival floor is no place to debug a clever abstraction at two in the morning, with a queue at the bar and no second engineer to call.