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 deployon 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. JwtStrategyfetches JWKS fromKEYCLOAK_JWKS_URIand verifies RS256 signature,exp,issand (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 refreshemail/fullNamefrom 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-Keyheader (returned once on machine registration). ApiKeyGuardbcrypt-compares the header againstmachine.apiKeyHashfor 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.machinefor 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
clientTimein JavaZonedDateTimeformatYYYY-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-
encodeURIComponentthe group number so it survives both layers. - Lifecycle:
firstSeenAtis INSERTed once and never updated;lastSeenAtbumps on every sync;deactivatedAtis set when ErgoFlow drops a user from the active list or flipsallowLogin=0. Reactivation only happens forallowLogin=1users. - Provisioning queue:
runFullSyncWorkruns in the background, persists progress (provisionRunning/provisionTotal/provisionDone/provisionLabel) on theErgoflowConfigsingleton; the panel polls/configevery 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:
Role—EMPLOYEE,ADMIN,MACHINE_OPERATOR,COURIERCredentialType—PIN,RFID,MOBILE_TOKENMachineStatus—ONLINE,OFFLINE,MAINTENANCELockerSize—S,M,LLockerStatus—FREE,RESERVED,OCCUPIED,BROKENLockerFunction—PARCEL,KEY_RENTAL,DOCUMENTShipmentStatus—CREATED,ASSIGNED,DEPOSITED,PICKED_UP,CANCELLED,EXPIREDMachineTaskType—OPEN_LOCKERMachineTaskStatus—PENDING,ACKNOWLEDGED,COMPLETED,FAILEDReleaseChannel—STABLE,BETAAssignmentStatus—PENDING,DOWNLOADING,INSTALLING,INSTALLED,FAILED,CANCELLEDAuditActorType—USER,MACHINE,SYSTEM
Models (key fields only — see prisma/schema.prisma for the full list):
User—id,keycloakId(nullable, unique),email,fullName,phone,role,active,preferredLocale, timestamps.Credential—userId,type,label,valueHash(bcrypt),active,expiresAt,lastUsedAt.Machine—code(unique, e.g.LOCKER-SZB-SDI-001),name,location,apiKeyHash,status,lastSeenAt,currentVersion,ipAddress,externalIp,adminUser,adminPassword,inactivityTimeoutSec(default 5),inactivityCountdownSec(default 10).Locker—machineId(unique withnumber),number,size,status,allowedFunctions(array),modbusAddress(nullable for etap 5).Shipment—trackingId(unique,SB-{INSTANCE}-{12d}),status,createdById,recipientId,machineId,lockerId,qrPayload(signed JWT),notes,expiresAt, plusassignedAt/depositedAt/pickedUpAt/cancelledAt.MachineTask—machineId,type(OPEN_LOCKER),payload(JSON),status,ackedAt,completedAt,error.MachineMetric—machineId,cpuPct,memPct,diskPct,extra(JSON),capturedAt.MachineRelease—version(unique semver),channel,fileName,fileSize,sha256(hex),storagePath,notes,uploadedById.MachineReleaseAssignment—machineId,releaseId,status,progressPct,bytesReceived,log,error,assignedById,startedAt,completedAt.AuditLog—actorId(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 fieldsprovisionRunning,provisionTotal,provisionDone,provisionLabel,provisionStartedAt,provisionFinishedAt.ErgoflowUser— PKuserNumber(the RFID card number from ErgoFlow).firstName,lastName,email,allowLogin,firstSeenAt,lastSeenAt,deactivatedAt. The deterministic SmartBox email for the JIT-createdUserrow is<userNumber>@ergoflow.local.ErgoflowGroup— PKuserGroupNumber.name,firstSeenAt,lastSeenAt,deactivatedAt.ErgoflowGroupMember— composite PK(userGroupNumber, userNumber)with FKs toErgoflowGroupandErgoflowUser.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. MarksONLINE → OFFLINEfor any machine whoselastSeenAtis older thanMACHINE_OFFLINE_THRESHOLD_SEC(env, default 120 s). Idempotent — second replica just touches 0 rows. EmitsEVT.MachineWentOffline.ShipmentWorker—@Cron(EVERY_MINUTE)shipment.auto-expire. ExpiresCREATED/ASSIGNED/DEPOSITEDshipments pastexpiresAt, frees reserved lockers. Each shipment in its own TX (failures skip the row, max 200 per tick). EmitsEVT.ShipmentExpired.NotificationWorker— RabbitMQ subscriber onsmartbox.events. Today it audits "would-send" notifications; real email / SMS dispatch is queued for the hardening phase (etap 8).ErgoflowScheduler— registered via@nestjs/scheduleSchedulerRegistry; re-armed on everyPATCH /integrations/ergoflow/configsave (cancel-first to prevent timer leaks). TriggersrunFullSyncWorkeveryintervalMinutes.
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 useclass-validator+class-transformer. - Errors:
404 NotFoundExceptionfor missing entities ·409 ConflictExceptionfor state mismatches (locker not FREE, shipment not in expected status) ·403 ForbiddenExceptionfor permission failures ·401 UnauthorizedExceptionfor invalid JWT / API key. - Logging:
nestjs-pinowithpino-prettyin dev.AuthorizationandX-Machine-Api-Keyheaders 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.json → version 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.