Przejdź do treści

Etap 1 — Backend core

Etap 1 zbudował backend NestJS od zera do "działa cały lifecycle paczki" w czterech krokach:

Chunk Commit Co wleciało
1.1 bab0ff1 NestJS skeleton + /health + Dockerfile + deploy pipeline tar | ssh
1.2 5c0245c Prisma + Postgres + Auth (Keycloak JWKS) + Users + Credentials
1.3 6271f62 Machines + Lockers + Shipments + RabbitMQ events
1.4 650c5cb Keycloak user provisioning + runbook

Po etapie 1 backend miał już dwa równoległe schematy auth (JWT dla operatorów, Machine API key dla kiosków), pełny model danych w Prismie, event bus na RabbitMQ i admin-flow do tworzenia kont w Keycloaku z poziomu API. Wszystko co nadeszło później (panel, kiosk, integracje) stało na tej bazie.

Najtrudniejsza decyzja etapu 1

Dwa równoległe schematy auth (JWT vs Machine API key) zamiast jednego. Wybór: kiosk nie trzyma user JWT — ma tylko per-instance secret, a tożsamość użytkownika jest rozwiązywana po stronie serwera i zwracana inline w odpowiedzi. To uprościło wszystko co przyszło później (offline cache, mobile-parity API, OTA pipeline). Cena: każdy nowy endpoint trzeba przyporządkować do jednego z dwóch guardów.

Architektura modułowa

graph TB
    classDef ctrl fill:#0AD6E8,stroke:#10F3FF,color:#06101A,stroke-width:2px
    classDef svc fill:#101F33,stroke:#0AD6E8,color:#fff
    classDef infra fill:#1A2C42,stroke:#5EE6A0,color:#fff
    classDef cross fill:#0A1628,stroke:#F5C16C,color:#fff

    Controllers[Controllers<br/>users · machines · lockers<br/>shipments · credentials]:::ctrl
    Services[Services<br/>UsersService · ShipmentsService<br/>CredentialsService · …]:::svc
    Auth[Auth guards<br/>JwtAuthGuard · ApiKeyGuard<br/>RolesGuard]:::cross
    Audit[Audit interceptor]:::cross

    Prisma[(PrismaService)]:::infra
    Events[EventsService<br/>→ RabbitMQ]:::infra
    KC[KeycloakAdminService]:::infra

    Controllers --> Auth
    Controllers --> Audit
    Controllers --> Services
    Services --> Prisma
    Services --> Events
    Services --> KC

Jeden moduł na domenę, jeden serwis na moduł, infrastruktura wstrzykiwana przez DI. To utrzymało code-bazę czystą nawet po dorzuceniu workerów (etap 4) i ErgoFlow (etap 6).

Auth model — dwa schematy

ts @UseGuards(JwtAuthGuard, RolesGuard) @Roles(Role.ADMIN) @Get('users') list(@Query() opts) { ... }

  • Bearer token w Authorization, weryfikowany przez JwtStrategy na podstawie JWKS z KEYCLOAK_JWKS_URI.
  • Sprawdza exp, iss i opcjonalnie aud (RS256).
  • Auto-sync użytkownika: pierwsza udana walidacja → linkujemy User po sub (Keycloak ID) lub email; kolejne wywołania odświeżają email / fullName z claims, ale rola zostaje w naszej DB (Keycloak roles tylko seedują przy pierwszym logowaniu).
  • Wyłączeni użytkownicy (active=false) → 401.

ts @MachineAuth() @Post('deposit/begin') begin(@Req() req, @Body() body) { const machine = req.machine; // dołączone przez ApiKeyGuard ... }

  • Plain secret w nagłówku X-Machine-Api-Key — zwracany raz przy POST /machines.
  • ApiKeyGuard bcrypt-compare-uje header z machine.apiKeyHash dla każdego rekordu (linear scan; OK dla obecnej skali, plan: indeksowanie po prefiksie klucza przy ~setkach maszyn).
  • Dopasowana maszyna ląduje jako req.machine dla handlera.

Per-shipment authorisation

@Roles(...) jest za grubo-ziarniste żeby wyrazić "user jest odbiorcą tej paczki". Tę logikę wymuszamy w handlerze (me-shipments, pickup, deposit). Decyzja świadoma — guard zostaje prosty, business rules trafiają do serwisu.

Schema Prisma — modele etapu 1

```prisma model User { id String @id @default(uuid()) keycloakId String? @unique email String @unique fullName String role Role @default(EMPLOYEE) active Boolean @default(true) ... }

model Credential { id String @id @default(uuid()) userId String type CredentialType // PIN | RFID | MOBILE_TOKEN valueHash String // bcrypt active Boolean ... }

model Machine { id String @id @default(uuid()) code String @unique // np. LOCKER-SZB-SDI-001 apiKeyHash String // bcrypt ... }

model Locker { id String @id @default(uuid()) machineId String number Int size LockerSize // S | M | L status LockerStatus // FREE | RESERVED | OCCUPIED | BROKEN ... @@unique([machineId, number]) }

model Shipment { id String @id @default(uuid()) trackingId String @unique // SB-{INSTANCE}-{12d} status ShipmentStatus // CREATED | ASSIGNED | DEPOSITED | PICKED_UP | CANCELLED | EXPIRED createdById String recipientId String machineId String? lockerId String? qrPayload String // signed JWT ... } ```

Pełna lista enumów + pól w backend/README.md → Schema. Modele dorobione w późniejszych etapach: MachineTask, MachineMetric, MachineRelease, MachineReleaseAssignment, AuditLog, ErgoflowConfig/User/Group/GroupMember/SyncRun.

Lifecycle paczki

stateDiagram-v2
    direction LR
    [*] --> CREATED: POST /shipments
    CREATED --> ASSIGNED: POST /shipments/:id/assign-locker
    ASSIGNED --> DEPOSITED: POST /shipments/:id/deposited (kiosk)
    DEPOSITED --> PICKED_UP: POST /pickup/:id/complete (kiosk)

    CREATED --> CANCELLED: POST /shipments/:id/cancel
    ASSIGNED --> CANCELLED
    DEPOSITED --> CANCELLED

    CREATED --> EXPIRED: ShipmentWorker (etap 4)
    ASSIGNED --> EXPIRED
    DEPOSITED --> EXPIRED

    PICKED_UP --> [*]
    CANCELLED --> [*]
    EXPIRED --> [*]

ShipmentsService enkapsuluje wszystkie tranzycje. Każda zmiana stanu:

  1. Zmienia Shipment.status + ewentualnie Locker.status.
  2. Wpisuje znacznik czasu (assignedAt, depositedAt, pickedUpAt, cancelledAt).
  3. Publikuje event na RabbitMQ (shipment.assigned, shipment.deposited, …).
  4. Ląduje w AuditLog przez interceptor.

Wszystkie cztery kroki w jednej transakcji Prisma — nie wyślemy event-a o paczce która tak naprawdę nie zmieniła stanu.

RabbitMQ — event bus

Topic exchange smartbox.events, queue per worker, routing key per typ eventu. Etap 1 publikuje, etap 4 dorabia konsumentów (MachineWorker, ShipmentWorker, NotificationWorker).

Routing key Kiedy
shipment.created nowa paczka
shipment.assigned rezerwacja schowka
shipment.deposited kurier zostawił paczkę
shipment.picked_up odbiorca odebrał
shipment.cancelled anulowanie
shipment.expired etap 4: ShipmentWorker po expiresAt
locker.assigned / locker.released razem ze zmianami szafek
machine.heartbeat / machine.offline etap 3+
machine.task.open_locker task otwarcia (kiosk pickup, emergency)

Payload to JSON z machineId / shipmentId / userId — konsumenty mogą być idempotentne.

Audit interceptor

Każdy state-changing handler dostaje dekorator:

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

Globalny interceptor zapisuje po sukcesie:

Pole Skąd
actorType JWT user → USER, Machine API key → MACHINE, cron → SYSTEM
actorId / actorLabel user ID + email · machine ID + code · null + system
action dotted string z dekoratora
entityType / entityId z entityIdFrom (response.id / params.id / custom resolver)
payload snapshot request body — bcrypt hashes / passwords / API keys / Authorization headers redacted
ip / userAgent z requestu

Audit nigdy nie failuje requestu

Errors w write-do-AuditLog są loggowane i połykane. Jeden popsuty audit row nie ma prawa zwrócić 500 użytkownikowi.

Keycloak user provisioning (chunk 1.4)

POST /users (admin) tworzy Keycloak account przez admin-cli REST API:

POST /users → KeycloakAdminService.createUser(email, fullName, tempPassword) → tworzy user w realm `smartbox` → ustawia `requiredActions: ['UPDATE_PASSWORD']` (force change on next login) → returnuje plain `tempPassword` w 201 — JEDEN RAZ → wewnętrznie zapisuje User { keycloakId, email, role }

KeycloakAdminService.findOrCreateUser(email) (idempotentny look-up + create) doszedł w etapie 6 dla JIT-provisioningu z ErgoFlow, ale wzorzec dwóch ścieżek (admin tworzy ręcznie / system tworzy automatycznie) wystartował tutaj.

QR signed payload

Każda nowa paczka dostaje signed JWT w qrPayload:

ts const qrPayload = jwt.sign( { sid: shipment.id, tid: shipment.trackingId, exp: ttl }, process.env.QR_SIGNING_SECRET, );

Kiosk (etap 3+) parsuje QR, weryfikuje signature lokalnie, używa sid+tid żeby wywołać /deposit/begin-existing. Klucz QR_SIGNING_SECRET żyje tylko w .env na VM — kompromitacja klucza wymaga rotacji + invalidate wszystkich aktywnych QR.

Co się zmieniło od etapu 1

  • /users + /shipments dostały paginację po stronie serwera w etapie 6 (przy okazji ErgoFlow). Stary kontrakt "zwróć wszystko" hangował UI przy >3700 wierszy. Dziś kontrakt to envelope {rows, total, page, perPage}. Patrz Etap 6 i backend/README.md.
  • Locker dostał pole allowedFunctions (PARCEL / KEY_RENTAL / DOCUMENT) i modbusAddress w etapie 4.1.
  • Machine dostał inactivityTimeoutSec / inactivityCountdownSec (etap 3.5), externalIp (etap 3.5), adminUser / adminPassword (etap 3.5), currentVersion (etap 3.1).
  • Audit interceptor dorobił REDACT więcej pól w czasie — między innymi pola od ErgoFlow.

Pełen aktualny endpoint reference → Backend (NestJS). Live Swagger → api.smartbox.ergoflow.app/docs.