En full-stack Next.js-app for en fiktiv kjede med 14 avdelinger på kjøpesentre: kunden slår opp bilen sin på kjennemerke, ser pris og ledige tider for den avdelingen hun står på, og bestiller. Driftspanelet på baksiden viser dagens belegg, ordrene og salgstallene per avdeling.
Kjernen er to ting som må stemme:
- Kapasitet. Ledige tider regnes ut av ordrene som allerede ligger i databasen — åpningstider minus jobbene som er inne, med samtidighetstak per avdeling. Ingen «ledig»-flagg som kan komme ut av sync.
- Pris. Klienten viser et estimat mens kunden velger. Serveren regner summen på nytt fra databasen når ordren opprettes. Det klienten sender inn er et forslag, aldri en sannhet.
Innholdet er oppdiktet. Integrasjonene er ekte: kjøretøyoppslaget går mot Statens vegvesens motorvognregister, og værvarselet i driftspanelet mot MET Norway.
| Lag | Valg |
|---|---|
| Språk | TypeScript, strict |
| Frontend | React 19, Next.js 16 (App Router), Tailwind 4 |
| Datahenting | TanStack Query |
| Backend | Node.js — Next route handlers |
| Database | PostgreSQL via pg, håndskrevet SQL og nummererte migrasjoner |
| Testing | Vitest (enhet), Cypress (e2e) |
| Eksterne API-er | Statens vegvesen (enkeltoppslag), MET Norway (locationforecast) |
Ingen ORM. Skjemaet er lite nok til at SQL er lettere å lese enn et abstraksjonslag over det, og spørringene her — samtidighet, aggregering, låsing — er nettopp de en ORM likevel må vike for.
npm install
cp .env.example .env.local # fyll inn DATABASE_URL
npm run db:migrate
npm run db:seed
npm run devTrenger du en database:
docker compose up -d # Postgres på 5432Eller en driftet Postgres (Neon, Supabase, RDS) — sett DATABASE_URL med
?sslmode=require og kjør de samme to kommandoene.
npm run db:seed skriver katalogen og lager 18 måneder ordrehistorikk pluss tre
uker fram i tid, deterministisk. npm run db:reset bygger historikken på nytt.
| Kommando | Hva den gjør |
|---|---|
npm run dev |
Utviklingsserver |
npm test |
Enhetstester (Vitest) |
npm run typecheck |
tsc --noEmit |
npm run e2e |
Cypress mot en kjørende app |
npm run db:cleanup |
Sletter utgåtte kjøretøyoppslag (cron, én gang i døgnet) |
Kunden skriver kjennemerket; appen henter merke, modell, årsmodell, drivstoff og farge fra motorvognregisteret. Oppslaget ligger på serveren, ikke i nettleseren: API-nøkkelen skal ikke ut til klienten, og cachen skal deles av alle kunder.
Rekkefølgen i app/api/vehicles/[regNr]/route.ts:
- Fersk cache i
vehicles(30 dager). Tekniske kjøretøydata endrer seg nesten aldri, og kvoten hos vegvesenet er 50 000 kall i døgnet. - Registeret, med to sekunders timeout.
- Utdatert cache. Er registeret nede, er tre måneder gamle data om en bil fortsatt riktige nok til å bestille en vask.
Feiler alt, får kunden fylle inn bilen selv og bestillingen går videre. Oppslaget står midt i en kjøpstrakt: det skal aldri være det som stopper en bestilling.
Klienten skiller mellom «fant ikke bilen» og «fikk ikke svar». Det er samme kode i to grener, men helt ulike beskjeder å få: den første kan kunden rette selv.
Nøkkelen søkes om med ID-porten (skjema hos
vegvesenet),
er gratis og godkjennes automatisk. Uten SVV_API_KEY svarer ruten
unavailable, og de fire seedede kjennemerkene (EB12345, DR34567, SU98765,
EK55443) treffer cachen. Kjennemerke er en personopplysning: appen lagrer bare
de tekniske feltene, aldri eier.
Åpningstider og tidsluker er vegg-klokke i norsk tid. En booking lagres derfor
som booking_date + minutter fra midnatt, ikke som timestamptz — ellers måtte
hver kapasitetsberegning konvertere fram og tilbake, og lørdagen sommertiden
snur ville et åtte timers åpningsvindu blitt sju eller ni.
Ledige tider (lib/slots.ts) er en ren funksjon: åpningsvindu, varighet
(tjeneste + tillegg + rigg) og allerede bookede intervaller inn, klokkeslett ut.
Ingen databasetilgang, så reglene kan testes på millisekunder.
Belegget måles som høyeste samtidighet inne i intervallet, ikke som antall bookinger som overlapper det. Tre jobber kan overlappe vinduet vårt uten å overlappe hverandre; da er det plass til bilen selv om taket er to.
Selve bestillingen låser avdelingsraden (select … for update) og teller
overlappende ordre på nytt inne i transaksjonen. To kunder som trykker «Bekreft»
på den siste plassen i samme sekund ville ellers begge lest «én ledig» før noen
av dem hadde skrevet. Taper man kappløpet, kommer man tilbake til
tidspunktsteget med beskjed om hvorfor — ikke en generisk feil.
Dagstripa henter fjorten dager i ett kall: åpningstidene og alle ordrene i vinduet i to spørringer, resten er regning i minnet.
Driftspanelet aggregerer i databasen, ikke i nettleseren. Et år for hele kjeden
er rundt 30 000 ordre; å sende dem over nettet for å summere dem i en useMemo
ville vært flere megabyte JSON for å tegne tolv søyler. lib/db/reports.ts
kjører sju parallelle group by-spørringer og returnerer ferdige tall.
Ordrelinjene hentes bare der man faktisk trenger radene: dagslistene, og CSV-eksporten — som først laster dem ned når noen trykker på knappen.
Én bil, én tid, én ordre. Et dobbelttrykk eller et retry på ustabilt nett
ga to bestillinger. Nå er (avdeling, dato, klokkeslett, kjennemerke) unik for
ordre som ikke er avbestilt, og et gjentatt kall får den eksisterende ordren
tilbake med 200 i stedet for å lage en ny. Klienten trenger ikke sende noe
ekstra for det.
Kjøretøyoppslaget er ratebegrenset, 20 kall per IP per minutt. Uten det er ruta en gratis oppslagstjeneste mot motorvognregisteret, betalt av vår kvote på 50 000 kall i døgnet. Telleren ligger i Postgres og ikke i minnet, så grensen gjelder på tvers av instanser.
Kjennemerker slettes. Et kjennemerke er en personopplysning, og et oppslag
vi ikke har brukt på et halvår har vi ingen grunn til å beholde. npm run db:cleanup rydder cachen og tellerne, og hører hjemme i en cron én gang i
døgnet.
SMS-leverandøren er ikke koblet på — lib/notifications.ts logger det den ville
sendt. En halvferdig integrasjon mot en betalt tjeneste er verre enn et tydelig
punkt der den skal inn.
npm test # 65 tester, ingen database å sette opp, ingen nettverkE2E kjøres mot et produksjonsbygg, ikke mot next dev: dev-serveren
kompilerer ruter på forespørsel, og da rekker Cypress å klikke før React har
hydrert.
npm run build && npm startnpm run e2eEnhetstestene dekker det som avgjør hva en kunde betaler og når en bil får plass: prisregning og mva, samtidighet og tidsluker, validering av kjennemerke, norsk tid over sommertidsskiftet, og kartleggingen av svaret fra vegvesenet — inkludert hver måte kallet kan feile på.
Databasetestene kjører mot ekte Postgres, kompilert til WebAssembly
(PGlite). Samme migrasjoner, samme SQL, samme select … for update — men uten
at noen må ha en database kjørende for å kjøre npm test. De dekker
kapasitetstaket, at prisen hentes fra basen og ikke fra kallet, at avbestilte
ordre holdes utenfor, og at aggregeringene summerer på riktig nivå.
De var verdt å skrive: aggregeringsspørringene sorterte på en kolonne som var
castet til text, så «99000» kom foran «225000». Testen fant det; en
visuell gjennomgang av panelet ville ikke gjort det.
E2E-testen går gjennom hele trakten på en telefonskjerm, uten å vite hvilken avdeling, tjeneste eller tid som velges: den tar den første som er ledig, og tester dermed kapasiteten slik den faktisk er den dagen den kjører. Den treffer ekte database og ekte kjøretøyoppslag.
app/
api/ route handlers: kjøretøy, tidsluker, ordre, rapporter
booking/ bookingtrakten (7 steg, én reducer)
admin/ driftspanelet
components/ UI, delt mellom kundeflate og panel
lib/
db/ SQL-laget: katalog, ordre, kjøretøy, rapporter
svv/ klient mot Statens vegvesen
slots.ts kapasitetsregning (ren, testet)
pricing.ts prisregning (ren, testet)
catalog.ts kildedata for kjeden — det seed-skriptet skriver inn
db/
migrations/ nummererte SQL-filer
seed/run.ts katalog + deterministisk ordrehistorikk
cypress/e2e/ end-to-end
Katalogen ligger i kode og seedes til databasen. lib/catalog.ts er
kildedataen for avdelinger, tjenester og tillegg. Alt som blir til en ordre —
pris, varighet, om tjenesten finnes i avdelingen — leses fra basen; sidene som
bare viser fram katalogen leser kildefila direkte. I produksjon hører katalogen
hjemme i et CMS eller bak et admin-API.
Redigering i driftspanelet lever i nettleseren. Endringer består mens man
klikker rundt, men forsvinner ved omlasting. Tabellen som skal ta imot dem
(location_services) finnes allerede, og salgstallene på samme skjerm kommer
fra basen.
Ingen innlogging. Panelet er åpent, og «Min side» viser en oppdiktet kunde. Rapport-ruten tar avdeling som spørreparameter; i produksjon kommer den fra sesjonen — en franchisetaker skal se sine egne tall, ikke naboens.
Ingen betaling. Kunden betaler i avdelingen. PDF-en fra bookingen er derfor en ordrebekreftelse, ikke en kvittering: kvitteringen produseres av kassen, og kassasystemer er produkterklæringspliktige til Skatteetaten.