Etap 6 — ErgoFlow integration¶
ErgoFlow is the customer's upstream HR / access-control system (running at garos.ergoflow.app for the SDI tenant). Etap 6 wires SmartBox into ErgoFlow so that any active ErgoFlow employee can swipe their existing access card at a SmartBox kiosk and authenticate — no separate provisioning step inside SmartBox required.
The whole integration runs inside the backend container; no extra infra services were added.
TL;DR
Active ErgoFlow employee = SmartBox + Keycloak account auto-created with their card number bcrypt-hashed as the RFID credential. Card swipe works at the kiosk online or offline. ErgoFlow flips allowLogin=0 → SmartBox marks the user inactive on the next sync tick → kiosk cache stops accepting the card.
Sync flow¶
sequenceDiagram
autonumber
participant Admin
participant Panel as Panel (Next.js)
participant Backend as Backend (NestJS)
participant ErgoFlow as ErgoFlow upstream
participant Keycloak
participant DB as Postgres
Admin->>Panel: POST "Sync now"
Panel->>Backend: POST /integrations/ergoflow/sync
Backend-->>Panel: {ok, started:true} (<100ms)
Note over Backend: runFullSyncWork() starts in background
Backend->>ErgoFlow: POST /oauth2/login
ErgoFlow-->>Backend: { token }
loop Phase A · pull users (paged)
Backend->>ErgoFlow: GET /vending-api/users
ErgoFlow-->>Backend: { records: [...] }
Backend->>DB: upsert ErgoflowUser
end
loop Phase B · pull groups + members
Backend->>ErgoFlow: GET /user-groups
Backend->>ErgoFlow: GET /user-groups/:n/members
Backend->>DB: upsert ErgoflowGroup, ErgoflowGroupMember
end
loop Phase C · provision (active employees only)
Backend->>DB: upsert User (email = userNumber@ergoflow.local)
Backend->>Keycloak: findOrCreateUser(email)
Backend->>DB: upsert Credential (RFID, bcrypt(userNumber))
Note over Backend: ~100ms throttle (≈4 RPS to KC)
end
Backend->>DB: finalize ErgoflowSyncRun + clear progress
loop polling (panel open)
Panel->>Backend: GET /integrations/ergoflow/config (every 2s)
Backend-->>Panel: provisionRunning, done, total, label
end
What ships¶
- A new backend module at
backend/src/integrations/ergoflow/(config CRUD, scheduler, sync engine, JIT provisioning, mirror DB queries). - Five new Prisma models —
ErgoflowConfig,ErgoflowUser,ErgoflowGroup,ErgoflowGroupMember,ErgoflowSyncRun. - A new admin section in the panel under
System → Integracje → ErgoFlowwith four pages: Synchronizacja, Użytkownicy DB, Grupy DB, Historia. - A shared
<UrlPaginator>component + URL-state pattern used by every paginated table from etap 6 onwards (system users, all shipments, my shipments, ErgoFlow DB users, ErgoFlow DB groups).
Configuration¶
Stored in the ErgoflowConfig singleton row (id = 1):
| Field | Purpose |
|---|---|
baseUrl |
upstream ErgoFlow base URL (e.g. https://garos.ergoflow.app) |
email |
service-account login |
password |
service-account password (stored encrypted at rest is a TODO; for now stored as-is, accessible only to ADMIN via GET /integrations/ergoflow/config) |
enabled |
toggle the scheduler without losing credentials |
intervalMinutes |
scheduler cadence (default 60) |
lastSyncAt / lastSyncStatus / lastError |
summary of the most recent run |
provisionRunning / provisionTotal / provisionDone / provisionLabel / provisionStartedAt / provisionFinishedAt |
live progress of the in-flight async sync, polled by the panel |
PATCH /integrations/ergoflow/config re-arms the scheduler (cancel-first, then setInterval) on every save so toggling the cadence takes effect immediately.
Upstream protocol notes¶
The upstream API has three quirks that bit us in development and are now baked into the client:
1 · clientTime parameter
Every request sends clientTime=<ZonedDateTime> in Java's YYYY-MM-DDTHH:MM:SS.sssNNNNNN+02:00[Europe/Warsaw] format. If the timestamp drifts more than a few seconds from the upstream's clock the call returns 409 clientTimeIsntCorrect. We build it from new Date() per request — production clocks are NTP-synced so it never trips in real traffic; manual curl tests need TZ=Europe/Warsaw date.
2 · Response envelope
List endpoints return { "records": [...] }, not a raw array. The fetch helper unwraps it.
3 · Group names with spaces
nginx in front of the upstream Spring app decodes URL paths once, Spring decodes them again. So a group called operator wozka widlowego only resolves correctly if we double-encodeURIComponent the path segment (operator%2520wozka%2520widlowego). The fetch helper does this automatically for groupNumber. We confirmed this empirically: %20 returns 400, + returns 200 with empty body, %2520 returns 200 with real data.
Sync engine¶
POST /integrations/ergoflow/sync is a fire-and-forget trigger:
- Marks the run as started in
ErgoflowConfigand inserts anErgoflowSyncRunrow withtrigger='MANUAL'(or'SCHEDULED'when called by the scheduler). - Returns
{ ok: true, started: true }to the caller in <100 ms. - The actual work runs in
runFullSyncWork(), an unawaited promise: - Phase A — Pull users. Pages through
/users(perPage=200), upserting intoErgoflowUser. Each row getsfirstSeenAtset on INSERT (immutable),lastSeenAtbumped on every sync. Rows that disappear from the active list — or that come back withallowLogin=0— getdeactivatedAtset; reactivation only happens forallowLogin=1. - Phase B — Pull groups + members. Same pattern, two endpoints (
/user-groups,/user-groups/:n/members).ErgoflowGroupMembercarries the same lifecycle fields. - Phase C — JIT provision SmartBox + Keycloak. For every ErgoFlow user with
allowLogin=1:Userupsert keyed by deterministic email<userNumber>@ergoflow.local. Keepsrole=EMPLOYEEunless previously promoted.- Keycloak
findOrCreateUser(email)— idempotent: looks up by email, creates if missing, re-syncs realm role. Credentialupsert:type=RFID,valueHash = bcrypt(userNumber.toLowerCase().trim()),label='ErgoFlow #<userNumber>'. Replacing the hash is safe because the kiosk only ever bcrypt-compares.
- Throttle ~100 ms between Keycloak calls (≈4 RPS) so we don't blow up KC under a 3700-row first sync.
- On completion the
ErgoflowSyncRunrow is finalised with status (OK/OK_WITH_SKIPS/ERROR), counts (usersAdded,usersUpdated,usersTotal,groupsAdded,groupsUpdated,groupsTotal,usersSkipped), error message, duration. Theprovision*fields onErgoflowConfigare cleared.
Lifecycle semantics¶
firstSeenAtis INSERTed once and never updated. Useful for "this employee joined the company on…".lastSeenAtis bumped on every sync.deactivatedAtisnullwhile ErgoFlow keeps the user / group / membership active andallowLogin=1. Set to a timestamp the moment ErgoFlow drops them or setsallowLogin=0. Reactivation only happens forallowLogin=1.- A SmartBox
Userfor an ErgoFlow employee is markedactive=falsewhenever the ErgoFlow row is deactivated. The next/machines/me/synctick removes the row from kiosk caches and offline RFID auth stops accepting the card. RestoringallowLogin=1upstream → next sync flipsactive=true→ next kiosk sync re-caches.
Panel UX¶
/dashboard/system/integrations/ergoflow/ houses four pages:
- Synchronizacja — config form, schedule toggle, "Sync now" button. The
<ErgoflowProvisionProgress>widget polls/configevery 2 s while mounted: an indeterminate stripe during fetch, a determinateX / Ybar with phase label during Keycloak provisioning, then the last-run summary. - Użytkownicy DB — paginated mirror users (
<UrlPaginator>+?search&page&perPage). The "Konto SmartBox" column shows a→ profil ↗deep link to/dashboard/system/users/<smartboxUserId>for any row already provisioned in SmartBox; rows enriched server-side viaemail IN ('<userNumber>@ergoflow.local', …)lookup. - Grupy DB — paginated mirror groups + members drilldown.
- Historia —
ErgoflowSyncRunhistory, auto-refresh every 5 s. Columns: started, trigger, status, users (added/updated/total), groups (added/updated/total), skipped, duration.
The reverse cross-link lives on the system users page: every row whose email matches %@ergoflow.local shows an <ErgoflowSourceBadge> chip linking back to /dashboard/system/integrations/ergoflow/db-users (with stopPropagation so the chip click doesn't trigger the row's profile navigation).
Kiosk impact¶
Zero kiosk code changes. The kiosk's existing /machines/me/sync already pulls cached_users + cached_credentials; ErgoFlow-provisioned rows flow through the same pipeline as manually-created users. RFID auth at the kiosk is bcrypt.compare(scannedCardNumber, cached.valueHash) — works online or offline.
Endpoint reference¶
All routes are admin-only (JwtAuthGuard + RolesGuard + @Roles('ADMIN')).
| Method | Path | Purpose |
|---|---|---|
| GET | /integrations/ergoflow/config |
current config + scheduler state + in-flight progress |
| PATCH | /integrations/ergoflow/config |
upsert config; re-arms scheduler |
| POST | /integrations/ergoflow/test |
login + fetch first user page only (no DB writes) |
| POST | /integrations/ergoflow/sync |
fire-and-forget full sync; returns {ok, started} immediately |
| GET | /integrations/ergoflow/users?search=&page=&perPage= |
paginated mirror users (enriched with smartboxUserId / smartboxUserActive) |
| GET | /integrations/ergoflow/groups?search=&page=&perPage= |
paginated mirror groups |
| GET | /integrations/ergoflow/groups/:groupNumber/members |
members of one group |
| GET | /integrations/ergoflow/sync-history?limit= |
recent ErgoflowSyncRun rows |
Swagger at /docs carries the same routes (auto-generated from @ApiOperation decorators on the controller).
Future work¶
- Encrypt
ErgoflowConfig.passwordat rest (today only DB ACLs protect it). - Backfill existing manually-created users when ErgoFlow grows them an account: detect by employee number stored as a custom attribute, link instead of duplicating.
- Push direction: notify ErgoFlow when SmartBox issues a one-off PIN to a non-employee visitor — currently visitors only exist in SmartBox.
- Migrate from password grant → service-account JWT once ErgoFlow exposes one.