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 przezJwtStrategyna podstawie JWKS zKEYCLOAK_JWKS_URI. - Sprawdza
exp,issi opcjonalnieaud(RS256). - Auto-sync użytkownika: pierwsza udana walidacja → linkujemy
Userposub(Keycloak ID) lub email; kolejne wywołania odświeżająemail/fullNamez 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 przyPOST /machines. ApiKeyGuardbcrypt-compare-uje header zmachine.apiKeyHashdla każdego rekordu (linear scan; OK dla obecnej skali, plan: indeksowanie po prefiksie klucza przy ~setkach maszyn).- Dopasowana maszyna ląduje jako
req.machinedla 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:
- Zmienia
Shipment.status+ ewentualnieLocker.status. - Wpisuje znacznik czasu (
assignedAt,depositedAt,pickedUpAt,cancelledAt). - Publikuje event na RabbitMQ (
shipment.assigned,shipment.deposited, …). - Ląduje w
AuditLogprzez 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+/shipmentsdostał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 ibackend/README.md.Lockerdostał poleallowedFunctions(PARCEL/KEY_RENTAL/DOCUMENT) imodbusAddressw etapie 4.1.Machinedostał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.