Skip to content

About

Bookingplattform for en bilpleiekjede: Next.js, PostgreSQL og kjøretøyoppslag mot Statens vegvesen

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Bookingplattform for en bilpleiekjede

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.

Stack

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.

Komme i gang

npm install
cp .env.example .env.local     # fyll inn DATABASE_URL
npm run db:migrate
npm run db:seed
npm run dev

Trenger du en database:

docker compose up -d           # Postgres på 5432

Eller 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)

Kjøretøyoppslag mot Statens vegvesen

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:

  1. 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.
  2. Registeret, med to sekunders timeout.
  3. 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.

Kapasitet og samtidighet

Å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.

Rapportene

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.

Misbruk og dobbeltbestilling

É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.

Testing

npm test        # 65 tester, ingen database å sette opp, ingen nettverk

E2E 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 start
npm run e2e

Enhetstestene 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.

Struktur

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

Valg jeg vil forsvare, og det som ikke er bygget

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.

About

Bookingplattform for en bilpleiekjede: Next.js, PostgreSQL og kjøretøyoppslag mot Statens vegvesen

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages