Skip to content

API Architecture

Overview

The API is a NestJS 11 application with TypeORM 0.3, PostgreSQL 16 (PostGIS 3.4), and BullMQ job queues on Redis. It serves as the backend for both the frontend and admin applications.

  • Global prefix: /api
  • Port: 8001 (configurable via PORT or API_PORT)
  • Swagger docs: http://localhost:8001/docs (mounted at /docs, not /api/docs)
  • Bull Board (queue dashboard): /api/queues — superadmin only (cookie-authenticated)

Bootstrap (main.ts)

The application bootstraps with:

  1. Helmet — security headers, registered first so they apply to every response. CSP is currently disabled (the Swagger UI loads inline scripts); crossOriginEmbedderPolicy is off; HSTS is enabled in production only (maxAge: 15552000, includeSubDomains: true).
  2. Compression middleware
  3. Cookie parser (for JWT in httpOnly cookies)
  4. Global prefix /api (set before CORS/Swagger)
  5. CORS — restricted to *.gis-data.work in production, any localhost:* in development. credentials: true (cookies allowed)
  6. Global ValidationPipewhitelist: true (strips unknown fields), transform: true (auto-converts types)
  7. Swagger — only mounted when NODE_ENV !== 'production'. Path is /docs (not /api/docs) because setGlobalPrefix('api') does not retroactively apply to the Swagger module.

Boot-time secret enforcement

Two helpers refuse to let the API boot in production with development defaults:

  • auth/jwt-secret.ts (resolveJwtSecret) throws when NODE_ENV=production and JWT_SECRET is unset or equals the placeholder change-me-in-production. Outside production it falls back to the placeholder so local dev keeps working.
  • MailService constructor throws when NODE_ENV=production and FRONTEND_URL is unset or starts with http://localhost — password-reset / invite links would otherwise point at a developer machine.

Module Structure

AppModule
├── ConfigModule (global, loads project-root .env)
├── ThrottlerModule (rate limiting; THROTTLE_TTL / THROTTLE_LIMIT)
├── ScheduleModule (cron jobs)
├── DatabaseModule (TypeORM + PostgreSQL + PostGIS)
├── QueuesModule (BullMQ + Redis + Bull Board)
├── AuthModule (JWT + guards + Passport strategy)
├── UsersModule
├── AccidentsModule
├── RoadsModule
├── BreakingModule
├── CompaniesModule
├── TilesModule (filter/scope-aware MVT proxy over pg_tileserv)
├── AuditLogModule
├── DataUploadModule
├── BugReportsModule
└── MailModule (Resend)

Global Guards

Three guards are applied globally to all routes (in order):

  1. JwtAuthGuard — Always runs the JWT Passport strategy so request.user is populated whenever a valid access_token cookie is present, including on @Public() routes. The difference is enforcement: on public routes a missing/invalid token is silently allowed; on protected routes it throws UnauthorizedException. This lets public endpoints (e.g. accidents map) personalise responses for logged-in users.

  2. RolesGuard — If the handler or controller is decorated with @Roles('admin', 'superadmin'), checks that request.user.role is in the allow-list. Passes if no @Roles() is specified.

  3. ThrottlerGuard — Rate limits requests. Defaults: THROTTLE_LIMIT=30 per THROTTLE_TTL=60000 ms (1 minute) per IP. The POST /api/auth/login endpoint applies a stricter per-handler @Throttle() (default LOGIN_THROTTLE_LIMIT=5 per minute).

Database

Connection

TypeORM connects to PostgreSQL via:

  • Individual vars: DB_HOST, DB_PORT, DB_USERNAME, DB_PASSWORD, DB_DATABASE
  • Or a single DATABASE_URL connection string (takes precedence)
  • SSL enabled with DB_SSL=true

Entities are auto-loaded (autoLoadEntities: true). Schema synchronization is off — use migrations.

PostGIS

The database uses PostGIS for spatial data:

  • SRID 4326 (WGS84) for all geometry columns
  • Point geometry for accidents (single locations)
  • MultiLineString geometry for road sections (linear features)
  • Spatial indexes on all geometry columns

Common PostGIS patterns used in the codebase:

sql
-- Creating a point
ST_SetSRID(ST_MakePoint(longitude, latitude), 4326)

-- Geometry to GeoJSON
ST_AsGeoJSON(geom)::json

-- Bounding box query
ST_MakeEnvelope(west, south, east, north, 4326)

-- Loading geometry from hex
ST_GeomFromEWKB(decode($1, 'hex'))

Migrations

Migrations live in migrations/ at the project root:

bash
# Run pending migrations
pnpm migration:run

# Generate from entity changes
pnpm migration:generate migrations/<name>

# Rollback last migration
pnpm migration:revert

Feature Modules

Accidents (/api/accidents)

Traffic accident data with filtering, map queries, and CSV export. The whole controller is @Public() but @CurrentUser() is still injected (see Global Guards) so responses can be personalised for logged-in users.

MethodEndpointAuthDescription
GET/accidents/filtersPublicFilter dropdown options
GET/accidents/mapPublicZoom-aware map data (density or markers)
GET/accidents/exportPublicCSV export stream (logs DATA_EXPORT to audit log)
GET/accidentsPublicPaginated accident list
GET/accidents/:idPublicSingle accident with participants

Entities: Accident (Point geometry, 30+ columns), AccidentParticipant (demographics, test results)

Key behavior: The /accidents/map endpoint switches between density aggregation (zoom < 16, grouped by road segment) and individual markers (zoom ≥ 16) based on the zoom query parameter.

Breaking (/api/breaking)

Hard-braking event data with filtering and map queries — same shape as accidents.

MethodEndpointAuthDescription
GET/breaking/filtersPublicFilter dropdown options
GET/breaking/mapPublicMap data for braking events
GET/breaking/:idPublicSingle braking event detail

Entity: Breaking (Point geometry, vehicle/driver context)

Companies (/api/companies)

Company management — admin and superadmin can read, superadmin can mutate.

MethodEndpointAuthDescription
GET/companiesAdmin+List companies (admins see only their own; superadmin sees all)
GET/companies/:idAdmin+Single company (admins blocked with 403 unless it's their own)
POST/companiesSuperadminCreate company
PATCH/companies/:idSuperadminUpdate company
DELETE/companies/:idSuperadminDelete company (returns 204)

Entity: Company (id, name, metadata)

Scope rules: GET /companies returns the full list for superadmin; for admin it returns just their assigned company (or [] when unassigned). GET /companies/:id throws ForbiddenException if a non-superadmin requests an id other than their own companyId.

Tiles (/api/tiles)

Filter-aware and scope-enforcing MVT proxy. Anonymous unfiltered tiles are served directly by pg_tileserv at /tiles/tiles.* (proxied by the frontend nginx); the controller below handles the requests where filters or company scope make it unsafe to expose the SQL function publicly.

MethodEndpointAuthDescription
GET/tiles/accidents/:z/:x/:y.pbfPublic (scoped if logged in)Filtered accidents density tile
GET/tiles/accidents/points/:z/:x/:y.pbfPublic (scoped if logged in)Filtered per-accident marker tile (zoom ≥ 16)
GET/tiles/breaking/:z/:x/:y.pbfPublic (scoped if logged in)Filtered braking density tile
GET/tiles/breaking/points/:z/:x/:y.pbfPublic (scoped if logged in)Filtered per-event marker tile (zoom ≥ 16)

Each handler validates (z, x, y) against MAX_ZOOM = 22, calls resolveCompanyScope(user) from common/auth/company-scope.ts, short-circuits to an empty MVT for scoped users with no roads, and forwards the validated filters to the underlying SQL function. Responses set Content-Type: application/vnd.mapbox-vector-tile and Cache-Control: private, max-age=300.

The full tile-serving architecture (pg_tileserv role, tiles schema, function-vs-proxy split, frontend nginx routing) lives on a dedicated page — see Tile Serving.

Roads (/api/road-sections)

Road section geometry data for map visualization.

MethodEndpointAuthDescription
GET/road-sectionsPublicRoad sections (optional bounding box filter)
GET/road-sections/geometriesPublicMinimal geometry data for map rendering

Entity: RoadSection (MultiLineString geometry, section codes, road categories)

Auth (/api/auth)

JWT-based authentication with httpOnly cookie tokens.

MethodEndpointAuthDescription
POST/auth/loginPublicLogin, returns JWT cookie
POST/auth/logoutAuthClears JWT cookie
GET/auth/meAuthCurrent user profile
PATCH/auth/profileAuthUpdate name
POST/auth/change-passwordAuthChange password
POST/auth/forgot-passwordPublicRequest password reset email
POST/auth/reset-passwordPublicReset password with token

JWT Strategy: Extracts token from access_token cookie. Payload: { sub: userId, email, role }. Expiry: 4 hours (configurable).

Password Policy: Minimum 10 characters, 1 uppercase, 1 lowercase, 1 digit, 1 special character (enforced via @IsStrongPassword()).

Atomic password reset: AuthService.resetPassword issues a single conditional UPDATE … WHERE id = :id AND resetToken = :token AND resetToken IS NOT NULL. If two requests arrive with the same valid token, only the first one's affected count is non-zero — the loser sees the same BadRequestException ("Neveljavna ali potečena povezava…") that an expired token would produce, so a token can never be redeemed twice.

Users (/api/users)

User management — admin and superadmin only.

MethodEndpointAuthDescription
GET/usersAdmin+Paginated user list with search
GET/users/:idAdmin+Single user
POST/usersAdmin+Create user (sends invite email)
PATCH/users/:idAdmin+Update user
DELETE/users/:idAdmin+Soft delete (deactivate)
GET/users/:id/audit-logAdmin+User's audit trail (admin returns 403 if target is in a different company)
POST/users/:id/reset-passwordAdmin+Trigger password reset email
DELETE/users/:id/permanentSuperadminGDPR hard delete
GET/users/:id/exportSuperadminExport all user data (GDPR)

Entity: User (email, bcrypt password, role, isActive, reset token)

Audit Log (/api/audit-log)

Activity logging — superadmin only. The CSV export itself logs a DATA_EXPORT audit entry on the actor.

MethodEndpointAuthDescription
GET/audit-logSuperadminPaginated audit log
GET/audit-log/exportSuperadminCSV export (PII-minimized by default)

Entity: AuditLog (userId, action, ipAddress, userAgent, metadata JSONB)

CSV export & PII: The default CSV export omits ipAddress and userAgent columns because they are personal data. A superadmin must opt in with ?includeNetwork=true to include them; the flag is also recorded in the DATA_EXPORT audit entry so opt-ins are auditable.

Action types live in @gis-data/shared (auditActions constant + AuditAction type) and include login, logout, failed login, user CRUD operations, password changes, data uploads, and CSV exports.

Bug Reports (/api/bug-reports)

Bug reports and feature requests.

MethodEndpointAuthDescription
GET/bug-reportsSuperadminPaginated list
GET/bug-reports/exportSuperadminCSV export
GET/bug-reports/:idSuperadminSingle report
POST/bug-reportsAuthSubmit report
PATCH/bug-reports/:idSuperadminUpdate status/resolution

Entity: BugReport (title, description, type, status, priority, resolution)

Data Upload (/api/data-upload)

CSV upload, manual fetch triggering, analytics refresh, and raw-data reprocessing — superadmin only.

MethodEndpointAuthDescription
POST/data-upload/csvSuperadminUpload a CSV file (?source=accidents|breaking)
POST/data-upload/fetch-triggerSuperadminEnqueue an external fetch job
POST/data-upload/refresh-analyticsSuperadminEnqueue an analytics-refresh job
POST/data-upload/reprocess-raw-dataSuperadminRe-run the transformer over existing raw rows

Job Queues

BullMQ queues with Redis for background processing. Queue names are exported from @gis-data/shared (FETCH_QUEUE, PROCESS_QUEUE, ANALYTICS_QUEUE).

QueueConsumerPurpose
FETCH_QUEUEdata-fetchingPulls raw rows from the AVP WFS API into the raw_data table
PROCESS_QUEUEdata-processingTransforms raw_data rows into normalised accidents / breaking (concurrency 5)
ANALYTICS_QUEUEdata-processingRecomputes analytics tables (density-by-segment, etc.)

Both consumer apps (data-fetching, data-processing) are headless NestJS services deployed independently to Railway via their own production Dockerfile and railway.json — see Project Structure → Data Pipeline.

The API runs a daily cron inside QueuesService.onModuleInit that enqueues an ANALYTICS_QUEUE refresh at 03:00 (0 3 * * *). The old monthly accident-fetch cron is deliberately removed — operators trigger accident fetches manually from the admin panel.

BullMQ job options

Every job enqueued by the API (and the workers) is scheduled with the shared DEFAULT_JOB_OPTS constant:

ts
export const DEFAULT_JOB_OPTS = {
  attempts: 3,
  backoff: { type: 'exponential', delay: 2000 },
};

This gives transient failures (network blips, upstream 5xx, Redis hiccups) two retries with exponential backoff. The analytics refresh uses the same attempts: 3 with a longer delay: 5000. Non-retryable failures (e.g. zero-record fetches) throw UnrecoverableError so BullMQ marks the job as FAILED on the first attempt and it surfaces in Bull Board without burning retries.

Bull Board Dashboard

Bull Board UI is mounted at /api/queues and gated by the custom Express middleware in api/src/queues/bull-board-auth.middleware.ts (createBullBoardAuthMiddleware(jwtService, secret)) that:

  1. Reads the access_token cookie
  2. Verifies it with JwtService using the resolved JWT_SECRET
  3. Returns 401 if missing/invalid, 403 unless the role is superadmin

The middleware runs outside the global Nest guard chain — the Bull Board UI is mounted on the raw Express instance — so it must perform its own auth check.

Email

Resend integration for transactional emails:

  • Password reset emails
  • User invite emails (with initial password-setup link)

Reset/invite links are built from FRONTEND_URL (default http://localhost:8000).

Configure with RESEND_API_KEY, RESEND_FROM, and FRONTEND_URL env vars.

Custom Decorators

DecoratorUsageDescription
@Public()Controller/HandlerBypasses JWT authentication
@Roles('admin', 'superadmin')Controller/HandlerRequires specific user role
@CurrentUser()ParameterInjects { userId, email, role } from JWT