Przejdź do treści

SmartBox Backend (NestJS)

📖 Online: docs.smartbox.ergoflow.app/components/backend/ · Live Swagger: api.smartbox.ergoflow.app/docs

REST API + RabbitMQ event publisher + OIDC integration with Keycloak. Authoritative source for shipments, lockers, machines, users and the kiosk OTA pipeline.

  • Public endpoint: https://api.smartbox.ergoflow.app
  • Swagger UI: /docs
  • Health probe: /health
  • Stack: NestJS 10 · TypeScript 5 · Prisma 5 · Postgres 16 · RabbitMQ 3.13 · Redis 7 · Keycloak 26 · Node 22+
  • Container: multi-stage Dockerfile, runs prisma migrate deploy on start.

The repo also ships a separate workers container (same image, different bootstrap file) for cron jobs.

Layout

backend/ ├── prisma/ │ ├── schema.prisma data model — see "Schema" section │ └── migrations/ SQL migrations applied at boot ├── src/ │ ├── main.ts bootstrap, Swagger, ValidationPipe, pino-http │ ├── workers.bootstrap.ts workers container entrypoint (no HTTP) │ ├── app.module.ts wires every feature module │ ├── app.controller.ts GET /, GET /health │ ├── common/ │ │ ├── auth/ │ │ │ ├── jwt.strategy.ts Keycloak JWKS + auto user-sync │ │ │ ├── api-key.guard.ts X-Machine-Api-Key bcrypt-compare │ │ │ ├── roles.guard.ts @Roles(Role.ADMIN) enforcement │ │ │ └── ... │ │ ├── audit/ @Audit decorator + global interceptor │ │ └── pagination, dto helpers, etc. │ ├── infra/ │ │ ├── prisma/ PrismaService │ │ ├── amqp/ RabbitMQ publisher │ │ ├── redis/ ioredis wrapper │ │ └── health/ /health checks │ ├── users/ CRUD + Keycloak sync + password reset (paginated) │ ├── credentials/ PIN / RFID / MOBILE_TOKEN │ ├── machines/ registration, heartbeat, task queue, sync │ ├── lockers/ per-machine inventory + function tags │ ├── shipments/ full lifecycle CRUD + assign-locker + cancel (paginated) │ ├── deposit/ kiosk-side deposit endpoints (machine-auth) │ ├── pickup/ kiosk-side pickup endpoints (machine-auth) │ ├── me-shipments/ mobile-parity JWT endpoints (etap 4.6) │ ├── kiosk-users/ kiosk-initiated profile edits (machine-auth) │ ├── releases/ OTA pipeline (upload + assign + stream + status) │ ├── metrics/ machine telemetry (CPU/RAM/disk) │ ├── audit/ GET /audit reader │ ├── integrations/ │ │ └── ergoflow/ ErgoFlow upstream sync — config, scheduler, │ │ mirror tables, JIT user/Keycloak provisioning │ └── workers/ MachineWorker, ShipmentWorker, NotificationWorker ├── Dockerfile multi-stage; entrypoint runs migrations then main.js ├── package.json version 0.1.0, node >=22 └── tsconfig*.json

Auth model

Two parallel auth schemes; every controller is one or the other (never both on the same route).

JWT (Keycloak)

  • Bearer token in Authorization: Bearer <jwt> header.
  • JwtStrategy fetches JWKS from KEYCLOAK_JWKS_URI and verifies RS256 signature, exp, iss and (optional) aud.
  • Auto user-sync: on first valid JWT we either link by sub (Keycloak ID) or fall back to email match → write the link → subsequent calls just refresh email / fullName from the token, but role stays in our DB (Keycloak realm roles only seed on first login).
  • Disabled users (active=false) → 401 Unauthorized.
  • Roles: EMPLOYEE (default) · ADMIN (full access) · MACHINE_OPERATOR (reserved) · COURIER (cross-user shipment management on the panel).
  • Decorator: @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN).

Machine API key (X-Machine-Api-Key)

  • Plain secret in X-Machine-Api-Key header (returned once on machine registration).
  • ApiKeyGuard bcrypt-compares the header against machine.apiKeyHash for every machine row (linear scan — fine for the current fleet, will index by key prefix when we hit ~hundreds of machines).
  • The matched machine attaches as req.machine for handlers.
  • Decorator: @MachineAuth().

API surface

Below is every route the API exposes today. Auth column legend: JWT = JwtAuthGuard, JWT(role) = JwtAuthGuard + RolesGuard with @Roles(role), MA = X-Machine-Api-Key, public = no auth.

Root

Method Path Auth Purpose
GET / public {name, version, stage}
GET /health public liveness — Postgres / Redis / RabbitMQ latency

Users + self

Method Path Auth Purpose
GET /me JWT current user (auto-syncs from JWT claims)
POST /me/password JWT self-service password change (verifies current via Keycloak direct grant)
GET /users?search=&role=&active=&page=&perPage= JWT(ADMIN) paginated list — {users, total, page, perPage} envelope, ILIKE search on email/fullName
GET /users/:id JWT(ADMIN) detail + 10 recent shipments
POST /users JWT(ADMIN) provision Keycloak account, return temp password ONCE
PATCH /users/:id JWT(ADMIN) update role / active (propagates to Keycloak)
POST /users/:id/reset-password JWT(ADMIN) issue new temporary password (forces change on next login)

Credentials

Method Path Auth Purpose
GET /users/:userId/credentials JWT (self or ADMIN) list PIN / RFID / MOBILE_TOKEN entries
POST /users/:userId/credentials JWT (self or ADMIN) add credential (return plain value ONCE, bcrypt stored)
PATCH /credentials/:id JWT(ADMIN) disable / relabel
DELETE /credentials/:id JWT(ADMIN) hard delete

PINs are 6 digits, RFIDs are 4–32 hex chars, MOBILE_TOKENs are random 32-byte tokens. Only one active PIN per user at a time; multiple RFIDs allowed.

Machines (admin / operator)

Method Path Auth Purpose
POST /machines JWT(ADMIN) register; returns plain API key ONCE
GET /machines JWT list all (Stan column data computed from heartbeat + active assignment)
GET /machines/:id JWT detail with lockers
PATCH /machines/:id JWT(ADMIN) update name / location / inactivityTimeoutSec / inactivityCountdownSec / adminUser / adminPassword
POST /machines/:id/lockers/:lockerId/emergency-open JWT(ADMIN) spawn OPEN_LOCKER task + emit event

Machines (kiosk-facing, machine-auth)

Method Path Auth Purpose
GET /machines/me MA own row + inactivityTimeoutSec / inactivityCountdownSec / externalIp
GET /machines/me/sync?since=ISO MA offline cache feed — users + credential hashes + lockers + DEPOSITED shipments
POST /machines/me/replay MA replay offline outbox (pickup.completed, deposit.completed); response includes per-event outcome: OK | CONFLICT
POST /machines/:id/heartbeat MA update lastSeenAt, capture externalIp from X-Real-IP, mark ONLINE
GET /machines/me/tasks MA list PENDING MachineTask rows
POST /machines/me/tasks/:taskId/ack MA mark ACKNOWLEDGED
POST /machines/me/tasks/:taskId/complete MA mark COMPLETED or FAILED + optional error

Lockers

Method Path Auth Purpose
GET /machines/:machineId/lockers JWT list lockers on a machine
GET /machines/:machineId/locker-stats JWT per-function / size / status aggregates
POST /machines/:machineId/lockers JWT(ADMIN) create (number, size, allowedFunctions, modbusAddress)
PATCH /lockers/:id JWT(ADMIN) update status / size / allowedFunctions / modbusAddress
DELETE /lockers/:id JWT(ADMIN) delete if FREE and no active shipments

Shipments (panel + mobile API)

Method Path Auth Purpose
POST /shipments JWT create — generates trackingId (SB-{INSTANCE}-{12d}) + signed QR JWT
GET /shipments?scope=mine&status=...&search=&page=&perPage= JWT paginated list — {shipments, total, page, perPage} envelope; admin/courier with scope=all see every row, others see own + received; status param repeatable for multi-select
GET /shipments/:id JWT detail with timeline
POST /shipments/:id/assign-locker JWT reserve first FREE locker matching lockerId or preferredSize (CREATED → ASSIGNED)
POST /shipments/:id/cancel JWT cancel + free locker

Deposit (kiosk, machine-auth)

Method Path Auth Purpose
POST /deposit/verify-sender MA sender PIN/RFID auth
GET /deposit/recipients?q=&excludeUserId= MA typeahead (≥2 chars, max 20 active users)
POST /deposit/begin MA new shipment + reserve free locker on this machine (ASSIGNED)
POST /deposit/begin-existing MA attach existing CREATED shipment + reserve locker (idempotent for ASSIGNED here)
GET /deposit/lookup?last6= MA courier lookup by tracking-ID suffix (CREATED / ASSIGNED only)
PATCH /kiosk/users/:userId/profile MA self-update phone + preferredLocale (name/email immutable from kiosk)

Pickup (kiosk, machine-auth)

Method Path Auth Purpose
POST /shipments/:id/deposited MA sender finished depositing (ASSIGNED → DEPOSITED, locker OCCUPIED)
POST /pickup/verify MA recipient PIN/RFID auth → list waiting shipments + spawn OPEN_LOCKER tasks
GET /pickup/by-user?userId= MA post-login menu — list DEPOSITED shipments + spawn tasks
POST /pickup/:shipmentId/complete MA recipient confirmed (DEPOSITED → PICKED_UP, locker FREE)

Me-shipments (mobile-parity, JWT)

Foundation for future Android courier + employee apps — same state machine as the kiosk endpoints, JWT-authed.

Method Path Auth Purpose
POST /me/shipments/:id/open-locker JWT issue OPEN_LOCKER task — recipient (pickup), sender / COURIER (deposit), or ADMIN
POST /me/shipments/:id/confirm-deposit JWT sender / courier / admin: ASSIGNED → DEPOSITED
POST /me/shipments/:id/confirm-pickup JWT recipient / admin: DEPOSITED → PICKED_UP

See docs/etap-4-mobile-api.md for full request/response shapes.

Releases (OTA pipeline)

Admin (JWT):

Method Path Auth Purpose
POST /releases JWT(ADMIN) multipart upload (max 500 MB), backend computes SHA-256 + persists under /data/releases/<version>/<filename>
GET /releases JWT(ADMIN) list with uploader + assignment count
GET /releases/:id JWT(ADMIN) detail
DELETE /releases/:id JWT(ADMIN) delete if no non-terminal assignments
POST /machines/:machineId/releases JWT(ADMIN) create MachineReleaseAssignment (one active per machine)
GET /machines/:machineId/releases JWT(ADMIN) last 50 assignments
POST /release-assignments/:id/cancel JWT(ADMIN) cancel in-progress assignment

Machine (machine-auth):

Method Path Auth Purpose
GET /machines/me/release-assignment MA current assignment (or null)
POST /machines/me/release-assignment/:id/status MA report DOWNLOADING / INSTALLING / INSTALLED / FAILED + progressPct + bytesReceived + installedVersion
GET /releases/:id/file MA stream binary; response headers X-Release-SHA256 + X-Release-Version + Content-Disposition: attachment

Metrics

Method Path Auth Purpose
POST /machines/me/metrics MA submit CPU / RAM / disk sample (60 s interval; not audited, high-frequency)
GET /machines/:id/metrics?from=&to=&limit= JWT(ADMIN) time-series for the panel chart (default last 6 h)

Audit

Method Path Auth Purpose
GET /audit?actorType=&action=&entityType=&entityId= JWT(ADMIN) paginated audit feed
GET /audit/actions JWT(ADMIN) distinct action names (UI dropdown)

ErgoFlow integration (admin-only)

ErgoFlow is the customer's upstream HR/access system. SmartBox mirrors its employees and groups into local tables, then JIT-creates a SmartBox User + Keycloak account + RFID Credential (valueHash = bcrypt(userNumber)) per active employee — so an ErgoFlow card swipe authenticates at the kiosk with no further setup.

Method Path Auth Purpose
GET /integrations/ergoflow/config JWT(ADMIN) current config + scheduler state + last/in-progress sync progress
PATCH /integrations/ergoflow/config JWT(ADMIN) upsert baseUrl / email / password / enabled / intervalMinutes; re-arms scheduler
POST /integrations/ergoflow/test JWT(ADMIN) login + fetch first page of users (config probe; no DB writes)
POST /integrations/ergoflow/sync JWT(ADMIN) fire-and-forget full sync; returns {ok, started} in <100 ms, work continues in background
GET /integrations/ergoflow/users?search=&page=&perPage= JWT(ADMIN) paginated mirror users — {users, total, page, perPage} envelope; each row enriched with smartboxUserId / smartboxUserActive deep-link metadata
GET /integrations/ergoflow/groups?search=&page=&perPage= JWT(ADMIN) paginated mirror groups
GET /integrations/ergoflow/groups/:groupNumber/members JWT(ADMIN) members of one group
GET /integrations/ergoflow/sync-history?limit= JWT(ADMIN) recent ErgoflowSyncRun audit rows

Notes:

  • clientTime: every call to upstream sends clientTime in Java ZonedDateTime format YYYY-MM-DDTHH:MM:SS.sssNNNNNN+02:00[Europe/Warsaw]. Drift > a few seconds → 409 clientTimeIsntCorrect.
  • Group names with spaces: nginx in front of upstream Spring decodes path once, Spring decodes again — we double-encodeURIComponent the group number so it survives both layers.
  • Lifecycle: firstSeenAt is INSERTed once and never updated; lastSeenAt bumps on every sync; deactivatedAt is set when ErgoFlow drops a user from the active list or flips allowLogin=0. Reactivation only happens for allowLogin=1 users.
  • Provisioning queue: runFullSyncWork runs in the background, persists progress (provisionRunning / provisionTotal / provisionDone / provisionLabel) on the ErgoflowConfig singleton; the panel polls /config every 2 s while the integration page is open.

Audit decorator

Every state-changing endpoint is annotated:

ts @Audit('shipment.create', { entityType: 'Shipment', entityIdFrom: 'response.id' }) @Post() create(...) { … }

A global interceptor records the call after success:

Field Source
actorType JWT user → USER, machine API key → MACHINE, cron / bg → SYSTEM
actorId user ID or machine ID
actorLabel email / machine code
action dotted string from decorator (e.g. shipment.create)
entityType / entityId from entityIdFrom (response.id / params.id / custom resolver)
payload request body snapshot, with bcrypt hashes / passwords / API keys / Authorization headers redacted
ip / userAgent from request headers

The interceptor never fails the request — audit write errors are logged but swallowed.

Schema (Prisma)

Enums:

  • RoleEMPLOYEE, ADMIN, MACHINE_OPERATOR, COURIER
  • CredentialTypePIN, RFID, MOBILE_TOKEN
  • MachineStatusONLINE, OFFLINE, MAINTENANCE
  • LockerSizeS, M, L
  • LockerStatusFREE, RESERVED, OCCUPIED, BROKEN
  • LockerFunctionPARCEL, KEY_RENTAL, DOCUMENT
  • ShipmentStatusCREATED, ASSIGNED, DEPOSITED, PICKED_UP, CANCELLED, EXPIRED
  • MachineTaskTypeOPEN_LOCKER
  • MachineTaskStatusPENDING, ACKNOWLEDGED, COMPLETED, FAILED
  • ReleaseChannelSTABLE, BETA
  • AssignmentStatusPENDING, DOWNLOADING, INSTALLING, INSTALLED, FAILED, CANCELLED
  • AuditActorTypeUSER, MACHINE, SYSTEM

Models (key fields only — see prisma/schema.prisma for the full list):

  • Userid, keycloakId (nullable, unique), email, fullName, phone, role, active, preferredLocale, timestamps.
  • CredentialuserId, type, label, valueHash (bcrypt), active, expiresAt, lastUsedAt.
  • Machinecode (unique, e.g. LOCKER-SZB-SDI-001), name, location, apiKeyHash, status, lastSeenAt, currentVersion, ipAddress, externalIp, adminUser, adminPassword, inactivityTimeoutSec (default 5), inactivityCountdownSec (default 10).
  • LockermachineId (unique with number), number, size, status, allowedFunctions (array), modbusAddress (nullable for etap 5).
  • ShipmenttrackingId (unique, SB-{INSTANCE}-{12d}), status, createdById, recipientId, machineId, lockerId, qrPayload (signed JWT), notes, expiresAt, plus assignedAt / depositedAt / pickedUpAt / cancelledAt.
  • MachineTaskmachineId, type (OPEN_LOCKER), payload (JSON), status, ackedAt, completedAt, error.
  • MachineMetricmachineId, cpuPct, memPct, diskPct, extra (JSON), capturedAt.
  • MachineReleaseversion (unique semver), channel, fileName, fileSize, sha256 (hex), storagePath, notes, uploadedById.
  • MachineReleaseAssignmentmachineId, releaseId, status, progressPct, bytesReceived, log, error, assignedById, startedAt, completedAt.
  • AuditLogactorId (nullable), actorType, actorLabel, action, entityType, entityId, payload, ip, userAgent, createdAt.

ErgoFlow integration tables:

  • ErgoflowConfig — singleton (id=1). baseUrl, email, password, enabled, intervalMinutes, lastSyncAt, lastSyncStatus, lastError, plus async progress fields provisionRunning, provisionTotal, provisionDone, provisionLabel, provisionStartedAt, provisionFinishedAt.
  • ErgoflowUser — PK userNumber (the RFID card number from ErgoFlow). firstName, lastName, email, allowLogin, firstSeenAt, lastSeenAt, deactivatedAt. The deterministic SmartBox email for the JIT-created User row is <userNumber>@ergoflow.local.
  • ErgoflowGroup — PK userGroupNumber. name, firstSeenAt, lastSeenAt, deactivatedAt.
  • ErgoflowGroupMember — composite PK (userGroupNumber, userNumber) with FKs to ErgoflowGroup and ErgoflowUser. firstSeenAt, lastSeenAt, deactivatedAt.
  • ErgoflowSyncRun — audit trail: startedAt, finishedAt, trigger (MANUAL / SCHEDULED / STARTUP), status, usersAdded / usersUpdated / usersTotal, groupsAdded / groupsUpdated / groupsTotal, usersSkipped, error.

Workers

The workers container runs the same image but boots from dist/workers.bootstrap.js — no HTTP, no Swagger.

  • MachineWorker@Cron(EVERY_MINUTE) machine.auto-offline. Marks ONLINE → OFFLINE for any machine whose lastSeenAt is older than MACHINE_OFFLINE_THRESHOLD_SEC (env, default 120 s). Idempotent — second replica just touches 0 rows. Emits EVT.MachineWentOffline.
  • ShipmentWorker@Cron(EVERY_MINUTE) shipment.auto-expire. Expires CREATED / ASSIGNED / DEPOSITED shipments past expiresAt, frees reserved lockers. Each shipment in its own TX (failures skip the row, max 200 per tick). Emits EVT.ShipmentExpired.
  • NotificationWorker — RabbitMQ subscriber on smartbox.events. Today it audits "would-send" notifications; real email / SMS dispatch is queued for the hardening phase (etap 8).
  • ErgoflowScheduler — registered via @nestjs/schedule SchedulerRegistry; re-armed on every PATCH /integrations/ergoflow/config save (cancel-first to prevent timer leaks). Triggers runFullSyncWork every intervalMinutes.

Events

Published to topic exchange smartbox.events for every state transition:

shipment.created, shipment.assigned, shipment.deposited, shipment.picked_up, shipment.cancelled, shipment.expired, locker.assigned, locker.released, machine.heartbeat, machine.offline, machine.task.open_locker.

Payload is JSON, includes machineId / shipmentId / userId for idempotent consumers.

Environment variables

.env.example (in this directory) is the source of truth — copy to .env and fill in real values.

Var Purpose Default
NODE_ENV development / production
LOG_LEVEL pino-http level (debug / info) info
PORT HTTP listen port 3000
DATABASE_URL Postgres DSN (Prisma)
REDIS_URL ioredis URL
RABBITMQ_URL amqp:// URL
KEYCLOAK_ISSUER_URL OIDC issuer
KEYCLOAK_AUDIENCE JWT aud claim (optional)
KEYCLOAK_JWKS_URI JWKS endpoint derived
BCRYPT_ROUNDS bcrypt cost 12
QR_SIGNING_SECRET HMAC for signed-QR JWT
MACHINE_OFFLINE_THRESHOLD_SEC heartbeat grace period 120
SHIPMENT_INSTANCE_CODE INSTANCE segment in SB-{INSTANCE}-{12d} 001

Build & deploy

```bash

local dev

npm install npm run prisma:generate npm run start:dev # http://localhost:3000, Swagger /docs

build

npm run build # nest build → dist/

production image

docker build -t smartbox-backend .

entrypoint: sh -c "npx prisma migrate deploy && node dist/main.js"

```

Production runs as smartbox-backend-1 and smartbox-workers-1 containers behind Traefik. Migrations apply automatically on container startup; a fresh docker compose up -d backend workers is enough.

Conventions

  • Validation: global ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true }). DTOs use class-validator + class-transformer.
  • Errors: 404 NotFoundException for missing entities · 409 ConflictException for state mismatches (locker not FREE, shipment not in expected status) · 403 ForbiddenException for permission failures · 401 UnauthorizedException for invalid JWT / API key.
  • Logging: nestjs-pino with pino-pretty in dev. Authorization and X-Machine-Api-Key headers are auto-redacted.
  • Audit: every state-changing handler is wrapped with @Audit('action.dotted', { entityType, entityIdFrom }). The interceptor never fails the request.
  • Events: EventsService.publish(routingKey, payload) for all transitions. Workers and consumers subscribe via @RabbitListener.

Versioning

package.jsonversion is 0.1.0 for the API surface itself. The kiosk-facing Machine.currentVersion is the kiosk app version (currently 0.58.0), reported by the OTA agent on every successful install.