Appearance
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
PORTorAPI_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:
- Helmet — security headers, registered first so they apply to every response. CSP is currently disabled (the Swagger UI loads inline scripts);
crossOriginEmbedderPolicyis off; HSTS is enabled in production only (maxAge: 15552000,includeSubDomains: true). - Compression middleware
- Cookie parser (for JWT in httpOnly cookies)
- Global prefix
/api(set before CORS/Swagger) - CORS — restricted to
*.gis-data.workin production, anylocalhost:*in development.credentials: true(cookies allowed) - Global
ValidationPipe—whitelist: true(strips unknown fields),transform: true(auto-converts types) - Swagger — only mounted when
NODE_ENV !== 'production'. Path is/docs(not/api/docs) becausesetGlobalPrefix('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 whenNODE_ENV=productionandJWT_SECRETis unset or equals the placeholderchange-me-in-production. Outside production it falls back to the placeholder so local dev keeps working.MailServiceconstructor throws whenNODE_ENV=productionandFRONTEND_URLis unset or starts withhttp://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):
JwtAuthGuard— Always runs the JWT Passport strategy sorequest.useris populated whenever a validaccess_tokencookie is present, including on@Public()routes. The difference is enforcement: on public routes a missing/invalid token is silently allowed; on protected routes it throwsUnauthorizedException. This lets public endpoints (e.g. accidents map) personalise responses for logged-in users.RolesGuard— If the handler or controller is decorated with@Roles('admin', 'superadmin'), checks thatrequest.user.roleis in the allow-list. Passes if no@Roles()is specified.ThrottlerGuard— Rate limits requests. Defaults:THROTTLE_LIMIT=30perTHROTTLE_TTL=60000ms (1 minute) per IP. ThePOST /api/auth/loginendpoint applies a stricter per-handler@Throttle()(defaultLOGIN_THROTTLE_LIMIT=5per minute).
Database
Connection
TypeORM connects to PostgreSQL via:
- Individual vars:
DB_HOST,DB_PORT,DB_USERNAME,DB_PASSWORD,DB_DATABASE - Or a single
DATABASE_URLconnection 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:revertFeature 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /accidents/filters | Public | Filter dropdown options |
| GET | /accidents/map | Public | Zoom-aware map data (density or markers) |
| GET | /accidents/export | Public | CSV export stream (logs DATA_EXPORT to audit log) |
| GET | /accidents | Public | Paginated accident list |
| GET | /accidents/:id | Public | Single 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /breaking/filters | Public | Filter dropdown options |
| GET | /breaking/map | Public | Map data for braking events |
| GET | /breaking/:id | Public | Single braking event detail |
Entity: Breaking (Point geometry, vehicle/driver context)
Companies (/api/companies)
Company management — admin and superadmin can read, superadmin can mutate.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /companies | Admin+ | List companies (admins see only their own; superadmin sees all) |
| GET | /companies/:id | Admin+ | Single company (admins blocked with 403 unless it's their own) |
| POST | /companies | Superadmin | Create company |
| PATCH | /companies/:id | Superadmin | Update company |
| DELETE | /companies/:id | Superadmin | Delete 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /tiles/accidents/:z/:x/:y.pbf | Public (scoped if logged in) | Filtered accidents density tile |
| GET | /tiles/accidents/points/:z/:x/:y.pbf | Public (scoped if logged in) | Filtered per-accident marker tile (zoom ≥ 16) |
| GET | /tiles/breaking/:z/:x/:y.pbf | Public (scoped if logged in) | Filtered braking density tile |
| GET | /tiles/breaking/points/:z/:x/:y.pbf | Public (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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /road-sections | Public | Road sections (optional bounding box filter) |
| GET | /road-sections/geometries | Public | Minimal geometry data for map rendering |
Entity: RoadSection (MultiLineString geometry, section codes, road categories)
Auth (/api/auth)
JWT-based authentication with httpOnly cookie tokens.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/login | Public | Login, returns JWT cookie |
| POST | /auth/logout | Auth | Clears JWT cookie |
| GET | /auth/me | Auth | Current user profile |
| PATCH | /auth/profile | Auth | Update name |
| POST | /auth/change-password | Auth | Change password |
| POST | /auth/forgot-password | Public | Request password reset email |
| POST | /auth/reset-password | Public | Reset 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /users | Admin+ | Paginated user list with search |
| GET | /users/:id | Admin+ | Single user |
| POST | /users | Admin+ | Create user (sends invite email) |
| PATCH | /users/:id | Admin+ | Update user |
| DELETE | /users/:id | Admin+ | Soft delete (deactivate) |
| GET | /users/:id/audit-log | Admin+ | User's audit trail (admin returns 403 if target is in a different company) |
| POST | /users/:id/reset-password | Admin+ | Trigger password reset email |
| DELETE | /users/:id/permanent | Superadmin | GDPR hard delete |
| GET | /users/:id/export | Superadmin | Export 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /audit-log | Superadmin | Paginated audit log |
| GET | /audit-log/export | Superadmin | CSV 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /bug-reports | Superadmin | Paginated list |
| GET | /bug-reports/export | Superadmin | CSV export |
| GET | /bug-reports/:id | Superadmin | Single report |
| POST | /bug-reports | Auth | Submit report |
| PATCH | /bug-reports/:id | Superadmin | Update 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.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /data-upload/csv | Superadmin | Upload a CSV file (?source=accidents|breaking) |
| POST | /data-upload/fetch-trigger | Superadmin | Enqueue an external fetch job |
| POST | /data-upload/refresh-analytics | Superadmin | Enqueue an analytics-refresh job |
| POST | /data-upload/reprocess-raw-data | Superadmin | Re-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).
| Queue | Consumer | Purpose |
|---|---|---|
FETCH_QUEUE | data-fetching | Pulls raw rows from the AVP WFS API into the raw_data table |
PROCESS_QUEUE | data-processing | Transforms raw_data rows into normalised accidents / breaking (concurrency 5) |
ANALYTICS_QUEUE | data-processing | Recomputes 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:
- Reads the
access_tokencookie - Verifies it with
JwtServiceusing the resolvedJWT_SECRET - Returns
401if missing/invalid,403unless the role issuperadmin
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
| Decorator | Usage | Description |
|---|---|---|
@Public() | Controller/Handler | Bypasses JWT authentication |
@Roles('admin', 'superadmin') | Controller/Handler | Requires specific user role |
@CurrentUser() | Parameter | Injects { userId, email, role } from JWT |