Przejdź do treści

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 → ErgoFlow with 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:

  1. Marks the run as started in ErgoflowConfig and inserts an ErgoflowSyncRun row with trigger='MANUAL' (or 'SCHEDULED' when called by the scheduler).
  2. Returns { ok: true, started: true } to the caller in <100 ms.
  3. The actual work runs in runFullSyncWork(), an unawaited promise:
  4. Phase A — Pull users. Pages through /users (perPage=200), upserting into ErgoflowUser. Each row gets firstSeenAt set on INSERT (immutable), lastSeenAt bumped on every sync. Rows that disappear from the active list — or that come back with allowLogin=0 — get deactivatedAt set; reactivation only happens for allowLogin=1.
  5. Phase B — Pull groups + members. Same pattern, two endpoints (/user-groups, /user-groups/:n/members). ErgoflowGroupMember carries the same lifecycle fields.
  6. Phase C — JIT provision SmartBox + Keycloak. For every ErgoFlow user with allowLogin=1:
    • User upsert keyed by deterministic email <userNumber>@ergoflow.local. Keeps role=EMPLOYEE unless previously promoted.
    • Keycloak findOrCreateUser(email) — idempotent: looks up by email, creates if missing, re-syncs realm role.
    • Credential upsert: type=RFID, valueHash = bcrypt(userNumber.toLowerCase().trim()), label='ErgoFlow #<userNumber>'. Replacing the hash is safe because the kiosk only ever bcrypt-compares.
  7. Throttle ~100 ms between Keycloak calls (≈4 RPS) so we don't blow up KC under a 3700-row first sync.
  8. On completion the ErgoflowSyncRun row is finalised with status (OK / OK_WITH_SKIPS / ERROR), counts (usersAdded, usersUpdated, usersTotal, groupsAdded, groupsUpdated, groupsTotal, usersSkipped), error message, duration. The provision* fields on ErgoflowConfig are cleared.

Lifecycle semantics

  • firstSeenAt is INSERTed once and never updated. Useful for "this employee joined the company on…".
  • lastSeenAt is bumped on every sync.
  • deactivatedAt is null while ErgoFlow keeps the user / group / membership active and allowLogin=1. Set to a timestamp the moment ErgoFlow drops them or sets allowLogin=0. Reactivation only happens for allowLogin=1.
  • A SmartBox User for an ErgoFlow employee is marked active=false whenever the ErgoFlow row is deactivated. The next /machines/me/sync tick removes the row from kiosk caches and offline RFID auth stops accepting the card. Restoring allowLogin=1 upstream → next sync flips active=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 /config every 2 s while mounted: an indeterminate stripe during fetch, a determinate X / Y bar 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 via email IN ('<userNumber>@ergoflow.local', …) lookup.
  • Grupy DB — paginated mirror groups + members drilldown.
  • HistoriaErgoflowSyncRun history, 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.password at 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.