← Plan Overzicht
Lessen
0 Overzicht 1 Project setup 2 Eerste server 3 Docker + PostgreSQL 4 Database schema 5 Keycloak auth 6 Endpoints bouwen 7 NAV sync service 8 Routing service
πŸ“š Cursus Β· 7 Lessen

Bossuyt Core API

We bouwen stap voor stap een Node.js REST API die de centrale hub wordt voor alle Bossuyt apps. Elke les bouwt verder op de vorige.

🧠 Wat bouwen we?

Een Core API is het centrale zenuwstelsel van het platform. De Service App, Vanventory en OCR demo praten allemaal met deze API. De API praat op zijn beurt met de database en Keycloak. Niemand praat rechtstreeks met de database behalve de Core API.

Wat leer je

Tech stack

πŸ’‘ Waarom Fastify en niet Express?

Express is ouder en heeft meer tutorials, maar Fastify is sneller (benchmarks tonen 2-3x), heeft TypeScript ingebakken, en valideert request/response data automatisch via JSON Schema. Voor een nieuwe API in 2026 is Fastify de betere keuze.

Les 1 Β· ~30 min

Project Setup

We maken de mappenstructuur aan en configureren TypeScript. Na deze les heb je een werkend skelet zonder nog een regel businesslogica.

Mappenstructuur

Voordat je ook maar één bestand aanmaakt, bepaal je de structuur. Een goede structuur houdt je project begrijpbaar als het groeit.

bossuyt-core-api/ β”œβ”€β”€ src/ β”‚ β”œβ”€β”€ routes/ # alle API endpoints β”‚ β”‚ β”œβ”€β”€ technicians.ts β”‚ β”‚ β”œβ”€β”€ customers.ts β”‚ β”‚ └── workorders.ts β”‚ β”œβ”€β”€ db/ # database connectie en schema β”‚ β”‚ β”œβ”€β”€ connection.ts β”‚ β”‚ └── schema.ts β”‚ β”œβ”€β”€ middleware/ # auth, logging, error handling β”‚ β”‚ └── auth.ts β”‚ β”œβ”€β”€ sync/ # NAV sync service β”‚ β”‚ └── nav-import.ts β”‚ β”œβ”€β”€ types/ # gedeelde TypeScript types β”‚ β”‚ └── index.ts β”‚ └── server.ts # het startpunt van de app β”œβ”€β”€ Dockerfile β”œβ”€β”€ docker-compose.yml β”œβ”€β”€ .env # geheimen, nooit in git! β”œβ”€β”€ package.json └── tsconfig.json
πŸ“ Principe: Separation of Concerns

Elke map heeft één verantwoordelijkheid. Routes weten niet hoe de database werkt. De database weet niet van auth. Middleware weet niet van business logica. Dit maakt code makkelijk te testen en aan te passen zonder onverwachte bijwerkingen.

Stap voor stap

1

Map aanmaken en npm initialiseren

Dit maakt een package.json aan β€” het manifest van je project met alle dependencies.

terminalbash
mkdir bossuyt-core-api
cd bossuyt-core-api
npm init -y
2

Dependencies installeren

fastify is de server. typescript en tsx laten ons TypeScript schrijven. @types/* zijn TypeScript definities voor Node.js.

terminalbash
# Productie dependencies
npm install fastify @fastify/jwt @fastify/cors dotenv

# Development dependencies (alleen nodig tijdens het bouwen)
npm install -D typescript tsx @types/node
πŸ“¦ dependencies vs devDependencies

dependencies gaan mee in productie (draaien op de server). devDependencies zijn alleen nodig tijdens het ontwikkelen β€” ze komen niet in de Docker image. Dit houdt je container klein en veilig.

3

TypeScript configureren

Dit vertelt TypeScript hoe het je code moet compileren en interpreteren.

tsconfig.jsonjson
{
  "compilerOptions": {
    "target": "ES2022",        // moderne JavaScript features gebruiken
    "module": "ESNext",        // ES modules (import/export syntax)
    "moduleResolution": "bundler",
    "strict": true,            // strenge typecontrole β€” vindt bugs vroeg
    "outDir": "./dist",        // gecompileerde bestanden gaan hierheen
    "rootDir": "./src",        // bronbestanden staan hier
    "esModuleInterop": true    // compatibiliteit met CommonJS modules
  },
  "include": ["src/**/*"]
}
4

Scripts toevoegen aan package.json

npm run dev start de server met automatische herstart bij wijzigingen. npm run build compileert voor productie.

package.json (scripts sectie)json
{
  "scripts": {
    "dev": "tsx watch src/server.ts",
    "build": "tsc",
    "start": "node dist/server.js"
  }
}
βœ… Checkpoint

Je hebt nu: een mappenstructuur, package.json, tsconfig.json. Nog geen werkende code β€” dat komt in les 2.

Les 2 Β· ~45 min

Eerste Fastify Server

We schrijven de eerste server.ts en een testroute. Na deze les draait er een echte HTTP server op je machine.

Het startpunt: server.ts

Dit is het bestand dat Node.js uitvoert wanneer je npm run dev typt. Het maakt een Fastify instantie aan, registreert alle routes, en start de server.

src/server.tstypescript
import Fastify from 'fastify'
import { technicianRoutes } from './routes/technicians.js'
import { customerRoutes } from './routes/customers.js'
import { workOrderRoutes } from './routes/workorders.js'

// Maak een Fastify instantie aan
// logger: true = elke request wordt gelogd in de terminal
const app = Fastify({ logger: true })

// Registreer alle route modules
// prefix betekent: alle routes in dit bestand beginnen met /api/v1/...
await app.register(technicianRoutes, { prefix: '/api/v1' })
await app.register(customerRoutes, { prefix: '/api/v1' })
await app.register(workOrderRoutes, { prefix: '/api/v1' })

// Health check endpoint β€” Traefik en Docker gebruiken dit
// om te controleren of de server nog leeft
app.get('/health', async () => {
  return { status: 'ok', timestamp: new Date().toISOString() }
})

// Start de server
// 0.0.0.0 = luister op alle network interfaces (nodig in Docker)
const PORT = Number(process.env.PORT) || 3000

try {
  await app.listen({ port: PORT, host: '0.0.0.0' })
  console.log(`πŸš€ Core API draait op poort ${PORT}`)
} catch (err) {
  app.log.error(err)
  process.exit(1)  // stop het proces als de server niet kan starten
}
πŸ”Œ Wat is een route?

Een route is een combinatie van een HTTP methode (GET, POST, PUT, DELETE) en een URL pad (bijv. /api/v1/technicians). Wanneer een client (de Service App) dat pad aanroept met die methode, voert Fastify de bijhorende functie uit.

Eerste route: techniekers

We schrijven een route die een lijst van techniekers teruggeeft. Voorlopig met hardcoded data β€” de database connectie komt in les 3.

src/routes/technicians.tstypescript
import type { FastifyInstance } from 'fastify'

// Dit is het type dat een technician beschrijft
// Later verplaatsen we dit naar src/types/index.ts
type Technician = {
  id: string
  name: string
  email: string
  active: boolean
}

// Hardcoded testdata β€” dit vervangen we later met echte DB queries
const mockTechnicians: Technician[] = [
  { id: '1', name: 'Kevin Bossuyt', email: 'kevin@bossuyt.be', active: true },
  { id: '2', name: 'Luc Janssen',   email: 'luc@bossuyt.be',   active: true },
]

// FastifyInstance = de app zelf, doorgegeven door app.register()
export async function technicianRoutes(app: FastifyInstance) {

  // GET /api/v1/technicians
  // Geeft alle techniekers terug als JSON array
  app.get('/technicians', async (request, reply) => {
    return mockTechnicians
    // Fastify detecteert automatisch dat dit een array is
    // en stuurt Content-Type: application/json
  })

  // GET /api/v1/technicians/:id
  // :id is een URL parameter β€” stel de client roept /technicians/1 aan,
  // dan is params.id gelijk aan '1'
  app.get<{ Params: { id: string } }>(
    '/technicians/:id',
    async (request, reply) => {
      const { id } = request.params
      const tech = mockTechnicians.find(t => t.id === id)

      if (!tech) {
        // 404 sturen als de technician niet bestaat
        return reply.status(404).send({ error: 'Technician not found' })
      }

      return tech
    }
  )
}

Testen

terminalbash
npm run dev

# In een tweede terminal:
curl http://localhost:3000/health
# β†’ {"status":"ok","timestamp":"2026-04-08T..."}

curl http://localhost:3000/api/v1/technicians
# β†’ [{"id":"1","name":"Kevin Bossuyt",...}]

curl http://localhost:3000/api/v1/technicians/99
# β†’ {"error":"Technician not found"}
βœ… Checkpoint

Je hebt een werkende HTTP server met twee routes. Test ze met curl of open http://localhost:3000/api/v1/technicians in je browser.

Les 3 Β· ~60 min

Docker + PostgreSQL

We containeriseren de API en voegen een PostgreSQL database toe. Na deze les draait alles in Docker β€” exact zoals op de Hetzner server.

🐳 Waarom Docker?

Docker zorgt dat de code exact hetzelfde draait op jouw laptop als op de server. Geen "bij mij werkt het wel" meer. Je beschrijft de omgeving in een bestand (Dockerfile) en Docker bouwt die omgeving elke keer opnieuw. Bovendien kan je eenvoudig meerdere services (API + database) samen laten starten via docker-compose.yml.

Dockerfile

Een Dockerfile beschrijft hoe Docker een image bouwt van je applicatie. Denk aan het als een recept.

Dockerfiledockerfile
# Stap 1: Gebruik een officiΓ«le Node.js image als basis
# 'alpine' = kleine Linux distributie (~5MB vs ~150MB voor ubuntu)
FROM node:22-alpine AS base

# Stap 2: Stel de werkdirectory in binnen de container
WORKDIR /app

# Stap 3: Kopieer dependency bestanden EERST
# Docker cacht elke stap. Als package.json niet veranderd,
# hoeft Docker npm install niet opnieuw te draaien β†’ snellere builds
COPY package*.json ./
RUN npm ci --only=production

# Stap 4: Kopieer de rest van de broncode
COPY . .

# Stap 5: Compileer TypeScript naar JavaScript
RUN npm run build

# Stap 6: Vertel Docker welke poort de app gebruikt (documentatie)
EXPOSE 3000

# Stap 7: Commando om de app te starten
CMD ["node", "dist/server.js"]

docker-compose.yml

Docker Compose laat je meerdere containers tegelijk beheren. We hebben er twee nodig: de API en de database.

docker-compose.ymlyaml
services:
  # De Core API service
  bossuyt-api:
    build: .
    container_name: bossuyt-api
    restart: unless-stopped
    environment:
      - NODE_ENV=production
      - PORT=3000
      # Lees DB connectie uit .env bestand (nooit hardcoden!)
      - DATABASE_URL=${DATABASE_URL}
      - KEYCLOAK_URL=${KEYCLOAK_URL}
      - KEYCLOAK_REALM=${KEYCLOAK_REALM}
    networks:
      - traefik        # extern netwerk voor Traefik routing
      - bossuyt-internal  # intern netwerk voor DB communicatie
    labels:
      traefik.enable: "true"
      traefik.docker.network: "traefik"
      traefik.http.routers.bossuyt-api.rule: "Host(`api.bossuyt.fixassistant.com`)"
      traefik.http.routers.bossuyt-api.entrypoints: "websecure"
      traefik.http.routers.bossuyt-api.tls.certresolver: "lets-encrypt"
      traefik.http.services.bossuyt-api.loadbalancer.server.port: "3000"
    depends_on:
      bossuyt-db:
        condition: service_healthy  # wacht tot DB klaar is

  # PostgreSQL database
  bossuyt-db:
    image: postgres:16-alpine
    container_name: bossuyt-db
    restart: unless-stopped
    environment:
      POSTGRES_DB: bossuyt
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      # Data persisten buiten de container β†’ overleeft container restarts
      - bossuyt-db-data:/var/lib/postgresql/data
    networks:
      - bossuyt-internal  # alleen bereikbaar vanuit intern netwerk
    healthcheck:
      # Traefik wacht hierop voor de API start
      test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d bossuyt"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  bossuyt-db-data:  # named volume β€” Docker beheert dit

networks:
  traefik:
    external: true     # al aangemaakt door Traefik
  bossuyt-internal:
    driver: bridge     # intern netwerk, niet bereikbaar van buiten

.env bestand

Geheimen staan nooit in de code. Ze staan in een .env bestand dat je nooit in git zet.

.env (nooit in git!)bash
PORT=3000
DATABASE_URL=postgresql://bossuyt_user:geheim_wachtwoord@bossuyt-db:5432/bossuyt
DB_USER=bossuyt_user
DB_PASSWORD=geheim_wachtwoord
KEYCLOAK_URL=https://auth.fixassistant.com
KEYCLOAK_REALM=bossuyt
.gitignorebash
node_modules/
dist/
.env          # NOOIT committen
*.env.local
⚠️ .env en git

Als je ooit per ongeluk een .env commit naar GitHub, verander dan onmiddellijk alle wachtwoorden. Git history kan niet zomaar gewist worden en bots scannen GitHub constant op gelekte credentials.

Database connectie in Node.js

src/db/connection.tstypescript
import { drizzle } from 'drizzle-orm/node-postgres'
import { Pool } from 'pg'
import * as schema from './schema.js'

// Een Pool is een groep van herbruikbare database connecties
// In plaats van elke keer een nieuwe connectie te openen (traag),
// hergebruikt een pool bestaande connecties (snel)
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 10,  // maximaal 10 gelijktijdige connecties
})

// Drizzle wikkelt de pool in een type-safe query builder
export const db = drizzle(pool, { schema })
βœ… Checkpoint

Start alles met docker compose up --build. Je ziet de API logs Γ©n de PostgreSQL logs. Test curl http://localhost:3000/health β€” als dat werkt, draait alles correct.

Les 4 Β· ~60 min

Database Schema met Drizzle

We definiΓ«ren de databasetabellen in TypeScript. Drizzle genereert automatisch de SQL. Na deze les heb je een echte database met echte tabellen.

πŸ—„οΈ Wat is een ORM?

Een ORM (Object-Relational Mapper) laat je databaseoperaties schrijven als TypeScript code. Je schrijft db.select().from(technicians).where(eq(technicians.active, true)) in plaats van SQL strings. Het voordeel: TypeScript weet wat de query teruggeeft, en je krijgt autocomplete en typefoutdetectie.

Schema definiΓ«ren

src/db/schema.tstypescript
import { pgTable, text, boolean, timestamp, integer } from 'drizzle-orm/pg-core'

// pgTable() definieert een PostgreSQL tabel
// Het eerste argument is de tabelnaam
// Het tweede is een object met kolommen

export const technicians = pgTable('technicians', {
  id:        text('id').primaryKey(),          // unieke identifier
  navId:     text('nav_id').unique(),          // id in Dynamics NAV
  name:      text('name').notNull(),
  email:     text('email').notNull().unique(),
  phone:     text('phone'),
  active:    boolean('active').default(true),
  syncedAt:  timestamp('synced_at'),           // laatste NAV sync
  createdAt: timestamp('created_at').defaultNow(),
})

export const customers = pgTable('customers', {
  id:        text('id').primaryKey(),
  navId:     text('nav_id').unique().notNull(), // NAV klantnummer β€” verplicht
  name:      text('name').notNull(),
  address:   text('address'),
  city:      text('city'),
  contact:   text('contact'),
  phone:     text('phone'),
  email:     text('email'),
  syncedAt:  timestamp('synced_at'),
  createdAt: timestamp('created_at').defaultNow(),
})

export const equipment = pgTable('equipment', {
  id:           text('id').primaryKey(),
  navId:        text('nav_id').unique(),
  customerId:   text('customer_id').references(() => customers.id), // FK
  brand:        text('brand'),
  model:        text('model'),
  serialNumber: text('serial_number'),
  location:     text('location'),   // waar staat het toestel?
  lastService:  timestamp('last_service'),
  syncedAt:     timestamp('synced_at'),
  createdAt:    timestamp('created_at').defaultNow(),
})

export const workOrders = pgTable('work_orders', {
  id:               text('id').primaryKey(),
  navOrderNr:       text('nav_order_nr').unique(),
  customerId:       text('customer_id').references(() => customers.id),
  equipmentId:      text('equipment_id').references(() => equipment.id),
  technicianId:     text('technician_id').references(() => technicians.id),
  interventionType: text('intervention_type'),  // ketel onderhoud, airco...
  plannedDate:      text('planned_date'),        // ISO date string
  status:           text('status').default('open'), // open | in_progress | done
  notes:            text('notes'),
  syncedAt:         timestamp('synced_at'),
  createdAt:        timestamp('created_at').defaultNow(),
  updatedAt:        timestamp('updated_at').defaultNow(),
})
πŸ”— Foreign Keys (references)

.references(() => customers.id) is een foreign key. Het zegt: de waarde in customer_id moet altijd bestaan als id in de customers tabel. De database weigert een werkorder aan te maken voor een klant die niet bestaat. Dit beschermt je data integriteit.

Migratie uitvoeren

terminalbash
# Installeer drizzle en de PostgreSQL driver
npm install drizzle-orm pg
npm install -D drizzle-kit @types/pg

# Genereer de migratie SQL bestanden
npx drizzle-kit generate

# Voer de migratie uit op de database
npx drizzle-kit migrate

Types exporteren

Drizzle kan TypeScript types automatisch afleiden uit je schema. Zo zijn je DB types en je API types altijd in sync.

src/types/index.tstypescript
import type { InferSelectModel, InferInsertModel } from 'drizzle-orm'
import type { technicians, customers, equipment, workOrders } from '../db/schema.js'

// InferSelectModel = het type dat je terugkrijgt bij een SELECT query
export type Technician  = InferSelectModel<typeof technicians>
export type Customer    = InferSelectModel<typeof customers>
export type Equipment   = InferSelectModel<typeof equipment>
export type WorkOrder   = InferSelectModel<typeof workOrders>

// InferInsertModel = het type dat je nodig hebt om iets in te voegen
export type NewWorkOrder = InferInsertModel<typeof workOrders>
Les 5 Β· ~45 min

Keycloak JWT Authenticatie

We beschermen de API met JWT tokens van Keycloak. Na deze les kunnen alleen ingelogde gebruikers de API aanroepen.

πŸ”‘ Hoe werkt JWT authenticatie?

1. Technician logt in via de Service App β†’ Keycloak geeft een JWT token terug
2. Service App stuurt dat token mee bij elke API request (in de Authorization header)
3. Core API verifieert het token met Keycloak's publieke sleutel β€” zonder Keycloak te contacteren
4. Als het token geldig is, verwerkt de API de request. Zo niet, stuurt ze 401 terug.

src/middleware/auth.tstypescript
import type { FastifyInstance, FastifyRequest } from 'fastify'
import fastifyJwt from '@fastify/jwt'

// Haal de publieke sleutel op van Keycloak
// Keycloak publiceert deze op een vaste URL β€” geen geheim
async function fetchKeycloakPublicKey(keycloakUrl: string, realm: string): Promise<string> {
  const url = `${keycloakUrl}/realms/${realm}`
  const response = await fetch(url)
  const data = await response.json() as { public_key: string }
  // Keycloak geeft de sleutel zonder headers terug β€” we voegen ze toe
  return `-----BEGIN PUBLIC KEY-----\n${data.public_key}\n-----END PUBLIC KEY-----`
}

export async function setupAuth(app: FastifyInstance) {
  const publicKey = await fetchKeycloakPublicKey(
    process.env.KEYCLOAK_URL!,
    process.env.KEYCLOAK_REALM!
  )

  // Registreer de JWT plugin met de publieke sleutel
  await app.register(fastifyJwt, {
    secret: { public: publicKey },
    verify: { algorithms: ['RS256'] }  // Keycloak gebruikt RS256
  })

  // Decorator: voeg een 'authenticate' methode toe aan elke route
  // Zo kan je per route beslissen of auth verplicht is
  app.decorate('authenticate', async (request: FastifyRequest) => {
    await request.jwtVerify()  // gooit een error als het token ongeldig is
  })
}

// TypeScript: vertel Fastify dat 'authenticate' bestaat
declare module 'fastify' {
  interface FastifyInstance {
    authenticate: (request: FastifyRequest) => Promise<void>
  }
}

Routes beschermen

src/routes/technicians.ts (met auth)typescript
export async function technicianRoutes(app: FastifyInstance) {

  // onRequest hook = wordt uitgevoerd VOOR de route handler
  // Als authenticate een error gooit, stuurt Fastify automatisch 401
  app.get('/technicians', {
    onRequest: [app.authenticate]
  }, async (request, reply) => {
    // request.user bevat de JWT payload na verificatie
    const user = request.user as { sub: string, name: string, realm_access: { roles: string[] } }

    // Alleen admins mogen alle techniekers zien
    if (!user.realm_access.roles.includes('admin')) {
      return reply.status(403).send({ error: 'Forbidden' })
    }

    // Query de echte database in plaats van mock data
    const result = await db.select().from(technicians).where(
      eq(technicians.active, true)
    )
    return result
  })
}
Les 6 Β· ~60 min

Echte Endpoints Bouwen

De belangrijkste endpoint voor de Service App: werkorders voor vandaag ophalen. Dit is wat een technician ziet wanneer hij 's morgens de app opent.

Werkorders van vandaag

Dit is de kern van de Service App. Een technician opent de app β†’ de app vraagt de Core API voor zijn opdrachten van vandaag β†’ max 5, gecached in IndexedDB.

src/routes/workorders.tstypescript
import { db } from '../db/connection.js'
import { workOrders, customers, equipment } from '../db/schema.js'
import { eq, and } from 'drizzle-orm'
import type { FastifyInstance } from 'fastify'

export async function workOrderRoutes(app: FastifyInstance) {

  // GET /api/v1/workorders/today
  // Haalt de werkorders op voor de ingelogde technician voor vandaag
  app.get('/workorders/today', {
    onRequest: [app.authenticate]
  }, async (request) => {

    // Haal de technician ID op uit de JWT token
    const user = request.user as { sub: string }
    const technicianId = user.sub  // 'sub' = subject = user ID in Keycloak

    // Vandaag als ISO date string (YYYY-MM-DD)
    const today = new Date().toISOString().split('T')[0]

    // JOIN query: werkorders + klant info + toestel info
    // In één query, geen extra roundtrips naar de database
    const orders = await db
      .select({
        id:               workOrders.id,
        navOrderNr:       workOrders.navOrderNr,
        interventionType: workOrders.interventionType,
        status:           workOrders.status,
        notes:            workOrders.notes,
        // Klant info
        customerName:     customers.name,
        customerAddress:  customers.address,
        customerPhone:    customers.phone,
        // Toestel info
        equipmentBrand:   equipment.brand,
        equipmentModel:   equipment.model,
        equipmentSerial:  equipment.serialNumber,
        equipmentLocation: equipment.location,
      })
      .from(workOrders)
      .leftJoin(customers,  eq(workOrders.customerId,  customers.id))
      .leftJoin(equipment,  eq(workOrders.equipmentId, equipment.id))
      .where(and(
        eq(workOrders.technicianId, technicianId),
        eq(workOrders.plannedDate, today)
      ))
      .limit(10)  // veiligheidsnet β€” max 10 opdrachten per dag

    return orders
  })

  // PATCH /api/v1/workorders/:id/status
  // Technieker updatet de status van een werkorder
  app.patch<{
    Params: { id: string }
    Body: { status: 'in_progress' | 'completed', notes?: string }
  }>('/workorders/:id/status', {
    onRequest: [app.authenticate]
  }, async (request, reply) => {
    const { id } = request.params
    const { status, notes } = request.body

    const updated = await db
      .update(workOrders)
      .set({ status, notes, updatedAt: new Date() })
      .where(eq(workOrders.id, id))
      .returning()

    if (updated.length === 0) {
      return reply.status(404).send({ error: 'Werkorder niet gevonden' })
    }

    return updated[0]
  })
}
πŸ”— Wat is een JOIN?

Een JOIN combineert data uit meerdere tabellen in één query. leftJoin(customers, eq(workOrders.customerId, customers.id)) zegt: "voeg klantdata toe aan elke werkorder, gekoppeld via het customer_id veld". Zonder JOIN zou je voor elke werkorder een aparte query moeten doen voor de klant β€” dat is traag.

Les 7 Β· ~90 min

NAV Sync Service

We importeren data uit Dynamics NAV via CSV export. Na deze les heb je echte klanten en werkorders in je database.

⏳ Vereiste: NAV CSV export

Deze les kan je pas volledig uitvoeren als je een echte CSV export hebt van Dynamics NAV. Je kan alvast de structuur bouwen met een mock CSV die de verwachte kolommen heeft.

Mock CSV om mee te beginnen

data/mock-customers.csvcsv
NAV_ID,NAAM,ADRES,STAD,CONTACT,TELEFOON,EMAIL
BSY001,Horeca De Kroon,Marktplein 12,Gent,Jan Peeters,09 234 56 78,info@dekroon.be
BSY002,Restaurant Terminus,Stationsstraat 5,Brugge,Marie Declercq,050 33 22 11,marie@terminus.be
BSY003,Hotel Astoria,Koningsstraat 88,Antwerpen,Luc Willems,03 456 78 90,luc@astoria.be

Import service

src/sync/nav-import.tstypescript
import { parse } from 'csv-parse/sync'
import { readFileSync } from 'fs'
import { db } from '../db/connection.js'
import { customers } from '../db/schema.js'
import { sql } from 'drizzle-orm'
import { randomUUID } from 'crypto'

type NavCustomerRow = {
  NAV_ID: string
  NAAM: string
  ADRES: string
  STAD: string
  CONTACT: string
  TELEFOON: string
  EMAIL: string
}

export async function importCustomersFromCsv(filePath: string): Promise<void> {
  console.log(`πŸ“₯ Importeren van klanten uit ${filePath}...`)

  // Lees en parseer de CSV
  const fileContent = readFileSync(filePath, 'utf-8')
  const rows = parse(fileContent, {
    columns: true,      // eerste rij = kolomnamen
    skip_empty_lines: true,
    trim: true,         // verwijder spaties aan begin/einde
  }) as NavCustomerRow[]

  console.log(`  ${rows.length} rijen gevonden`)

  // Upsert = update als het bestaat, insert als het niet bestaat
  // We gebruiken nav_id als unieke sleutel β€” dat is de NAV identifier
  for (const row of rows) {
    await db
      .insert(customers)
      .values({
        id:       randomUUID(),
        navId:    row.NAV_ID,
        name:     row.NAAM,
        address:  row.ADRES,
        city:     row.STAD,
        contact:  row.CONTACT,
        phone:    row.TELEFOON,
        email:    row.EMAIL,
        syncedAt: new Date(),
      })
      // Als nav_id al bestaat (conflict), update dan de gegevens
      // We overschrijven id NIET β€” die blijft stabiel in ons systeem
      .onConflictDoUpdate({
        target: customers.navId,
        set: {
          name:     sql`excluded.name`,
          address:  sql`excluded.address`,
          city:     sql`excluded.city`,
          contact:  sql`excluded.contact`,
          phone:    sql`excluded.phone`,
          email:    sql`excluded.email`,
          syncedAt: sql`excluded.synced_at`,
        }
      })
  }

  console.log(`  βœ… ${rows.length} klanten gesynchroniseerd`)
}

// Handmatige trigger endpoint (voor Admin Portal)
// GET /api/v1/admin/sync/customers
export async function syncRoute(app: FastifyInstance) {
  app.post('/admin/sync/customers', {
    onRequest: [app.authenticate]
  }, async (request, reply) => {
    // Alleen admins mogen syncs triggeren
    const user = request.user as { realm_access: { roles: string[] } }
    if (!user.realm_access.roles.includes('admin')) {
      return reply.status(403).send({ error: 'Forbidden' })
    }

    await importCustomersFromCsv('/data/customers.csv')
    return { success: true, message: 'Sync voltooid' }
  })
}
πŸ”„ Wat is upsert?

Upsert = update or insert. Als de rij al bestaat (op basis van nav_id), wordt ze bijgewerkt. Als ze niet bestaat, wordt ze ingevoegd. Dit maakt de sync idempotent β€” je kan hem tien keer uitvoeren en het resultaat is altijd hetzelfde.

Automatische sync via cron

src/sync/scheduler.tstypescript
// node-cron laat je taken plannen met cron syntax
// npm install node-cron @types/node-cron
import cron from 'node-cron'
import { importCustomersFromCsv } from './nav-import.js'

export function startSyncScheduler() {
  // Cron syntax: seconde minuut uur dag maand weekdag
  // '0 6 * * *' = elke dag om 06:00
  cron.schedule('0 6 * * *', async () => {
    console.log('⏰ Automatische NAV sync gestart...')
    try {
      await importCustomersFromCsv('/data/customers.csv')
      console.log('βœ… Automatische sync voltooid')
    } catch (error) {
      console.error('❌ Sync mislukt:', error)
      // TODO: stuur een alert naar admin (email of Telegram)
    }
  })

  console.log('πŸ“… Sync scheduler actief β€” dagelijks om 06:00')
}
βœ… Les 7 klaar β€” één les te gaan

NAV sync draait. Nu voegen we de routing service toe: de abstractielaag die rijtijden berekent en later swappable is van ORS naar TomTom.

Les 8 Β· Routing Service

πŸ—ΊοΈ Rijtijden β€” provider-agnostisch

We bouwen een swappable routing service: Fase 1 via ORS (gratis), Fase 2 via TomTom (traffic-aware) β€” zonder de rest van de app te wijzigen.

🧠 Waarom een abstractielaag?

Als je https://api.openrouteservice.org/... rechtstreeks in je routes schrijft, moet je later alle code aanpassen bij een provider-switch. Met een IRoutingService interface wissel je in één bestand van provider. De rest van je app merkt het niet.

Stap 1 β€” De interface

src/routing/IRoutingService.tstypescript
export interface Coordinates {
  lat: number
  lon: number
}

export interface RouteResult {
  distanceKm: number
  travelMinutes: number
  provider: string   // 'ors' | 'tomtom' β€” UI gebruikt dit voor disclaimer
}

export interface IRoutingService {
  getETA(from: Coordinates, to: Coordinates): Promise<RouteResult>
  getRouteMatrix(stops: Coordinates[]): Promise<RouteResult[][]>
}

Stap 2 β€” ORS implementatie (Fase 1)

πŸ”‘ API key ophalen

Registreer gratis op account.heigit.org β†’ kopieer je API key β†’ zet in .env als ORS_API_KEY. Gratis tier: 2000 requests/dag β€” ruim genoeg voor een klein team techniekers.

src/routing/OrsRoutingService.tstypescript
import { IRoutingService, RouteResult, Coordinates } from './IRoutingService.js'

export class OrsRoutingService implements IRoutingService {
  private readonly baseUrl = 'https://api.openrouteservice.org/v2'

  constructor(private readonly apiKey: string) {}

  async getETA(from: Coordinates, to: Coordinates): Promise<RouteResult> {
    const res = await fetch(`${this.baseUrl}/directions/driving-car`, {
      method: 'POST',
      headers: {
        'Authorization': this.apiKey,
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        coordinates: [
          [from.lon, from.lat],  // ORS verwacht [lon, lat] β€” niet lat/lon!
          [to.lon, to.lat]
        ]
      })
    })

    if (!res.ok) throw new Error(`ORS error: ${res.status}`)
    const data = await res.json()
    const summary = data.routes[0].summary

    return {
      distanceKm: Math.round(summary.distance / 100) / 10,  // meter β†’ km
      travelMinutes: Math.round(summary.duration / 60),      // seconden β†’ min
      provider: 'ors'
    }
  }

  async getRouteMatrix(stops: Coordinates[]): Promise<RouteResult[][]> {
    // Matrix API: alle paren in één call β€” veel efficiΓ«nter dan N losse calls
    const res = await fetch(`${this.baseUrl}/matrix/driving-car`, {
      method: 'POST',
      headers: { 'Authorization': this.apiKey, 'Content-Type': 'application/json' },
      body: JSON.stringify({
        locations: stops.map(s => [s.lon, s.lat]),
        metrics: ['duration', 'distance']
      })
    })
    const data = await res.json()
    return data.durations.map((row: number[], i: number) =>
      row.map((sec: number, j: number) => ({
        distanceKm: Math.round(data.distances[i][j] / 100) / 10,
        travelMinutes: Math.round(sec / 60),
        provider: 'ors'
      }))
    )
  }
}

Stap 3 β€” Dependency injection in server.ts

src/server.ts (toevoeging)typescript
import { OrsRoutingService } from './routing/OrsRoutingService.js'
// Fase 2: swap deze lijn β€” niets anders hoeft te wijzigen
// import { TomTomRoutingService } from './routing/TomTomRoutingService.js'

const routingService = new OrsRoutingService(process.env.ORS_API_KEY!)
// Fase 2: const routingService = new TomTomRoutingService(process.env.TOMTOM_API_KEY!)

await app.register(routePlugin, { routingService })

Stap 4 β€” De API endpoint

src/routes/route.tstypescript
import { FastifyInstance } from 'fastify'
import { IRoutingService } from '../routing/IRoutingService.js'

export async function routePlugin(
  app: FastifyInstance,
  opts: { routingService: IRoutingService }
) {
  // Enkelvoudig: van β†’ naar
  app.post('/api/v1/route', {
    onRequest: [app.authenticate]
  }, async (request) => {
    const { from, to } = request.body as {
      from: { lat: number; lon: number }
      to:   { lat: number; lon: number }
    }
    return opts.routingService.getETA(from, to)
    // Response: { distanceKm: 12.4, travelMinutes: 22, provider: 'ors' }
  })

  // Dagplanning: alle stops in volgorde β†’ geeft reistijd per stap
  app.post('/api/v1/route/daily', {
    onRequest: [app.authenticate]
  }, async (request) => {
    const { stops } = request.body as {
      stops: Array<{ lat: number; lon: number }>
    }
    const matrix = await opts.routingService.getRouteMatrix(stops)
    // Enkel de opeenvolgende paren: [0β†’1, 1β†’2, 2β†’3, ...]
    return {
      steps: stops.slice(0, -1).map((_, i) => matrix[i][i + 1])
    }
  })
}

Stap 5 β€” UI disclaimer logica (Service App)

πŸ’‘ Waarom provider in de response?

De provider veld bepaalt of de UI de disclaimer toont. ORS heeft geen traffic data β†’ disclaimer "zonder file-rekening". TomTom heeft traffic β†’ geen disclaimer. De UI-logica verandert nooit, alleen de data.

ServiceApp β€” TravelTimeLabel componenttypescript
function TravelTimeLabel({ step }: { step: RouteStep }) {
  return (
    <span>
      πŸš— ~{step.travelMinutes} min Β· {step.distanceKm} km
      {step.provider === 'ors' && (
        <small style={{ color: '#888', fontSize: '11px' }}>
          {' '}zonder file-rekening
        </small>
      )}
      {/* provider === 'tomtom': traffic inbegrepen, geen disclaimer */}
    </span>
  )
}
πŸŽ‰ Cursus voltooid!

Je hebt de volledige Core API gebouwd: project setup, Fastify server, Docker + PostgreSQL, Drizzle schema, Keycloak auth, werkorder endpoints, NAV sync, Γ©n een swappable routing service. Dit is het fundament waarop de Service App, Vanventory en OCR demo zich aansluiten.