Bossuyt Core API
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
βοΈ Project & Server
Node.js + TypeScript project opzetten, Fastify server draaien, eerste route schrijven
π³ Docker + Database
PostgreSQL container, environment variables, connectie vanuit Node.js
ποΈ Database schema
Drizzle ORM: tabellen definiΓ«ren die matchen met je TypeScript types
π Keycloak auth
JWT tokens valideren, middleware schrijven, routes beschermen
π£οΈ Endpoints
GET/POST routes voor techniekers, klanten en werkorders
π NAV Sync
CSV inlezen, data upserten, cron job instellen
Tech stack
- Runtime: Node.js 22 (LTS)
- Taal: TypeScript β zelfde als de Service App, alles is consistent
- Framework: Fastify β sneller dan Express, native TypeScript support, ingebouwde validatie
- Database: PostgreSQL β al aanwezig in je Docker stack
- ORM: Drizzle β TypeScript-first, geen magie, je schema = je types
- Auth: Keycloak JWT β je Keycloak draait al op auth.fixassistant.com
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.
Project Setup
Mappenstructuur
Voordat je ook maar één bestand aanmaakt, bepaal je de structuur. Een goede structuur houdt je project begrijpbaar als het groeit.
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
Map aanmaken en npm initialiseren
Dit maakt een package.json aan β het manifest van je project met alle dependencies.
mkdir bossuyt-core-api
cd bossuyt-core-api
npm init -y
Dependencies installeren
fastify is de server. typescript en tsx laten ons TypeScript schrijven. @types/* zijn TypeScript definities voor Node.js.
# 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 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.
TypeScript configureren
Dit vertelt TypeScript hoe het je code moet compileren en interpreteren.
{
"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/**/*"]
}
Scripts toevoegen aan package.json
npm run dev start de server met automatische herstart bij wijzigingen. npm run build compileert voor productie.
{
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
}
}
Je hebt nu: een mappenstructuur, package.json, tsconfig.json. Nog geen werkende code β dat komt in les 2.
Eerste Fastify Server
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.
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
}
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.
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
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"}
Je hebt een werkende HTTP server met twee routes. Test ze met curl of open http://localhost:3000/api/v1/technicians in je browser.
Docker + PostgreSQL
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.
# 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.
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.
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
node_modules/
dist/
.env # NOOIT committen
*.env.local
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
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 })
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.
Database Schema met Drizzle
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
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(),
})
.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
# 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.
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>
Keycloak 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.
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
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
})
}
Echte Endpoints Bouwen
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.
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]
})
}
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.
NAV Sync Service
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
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
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' }
})
}
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
// 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')
}
NAV sync draait. Nu voegen we de routing service toe: de abstractielaag die rijtijden berekent en later swappable is van ORS naar TomTom.
πΊοΈ Rijtijden β provider-agnostisch
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
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)
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.
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
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
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)
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.
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>
)
}
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.