Skip to content

About

Multi-client IT asset tracking: which person or desk holds each device mapped by floor, detailed per unit, with an audit trail each client can see.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

asset-tracker

A multi-tenant IT asset-tracking platform — track which person or desk holds which laptop / tablet / charger / cable, slice across an organisation ("all laptops", "what's on desk 14", "everything in for repair"), and collect it all back at offboarding.

Built as a Spring Cloud microservices backend + a Next.js console, in one Gradle-multi-project monorepo, to demonstrate distributed-systems and DevOps practice: service discovery, an API gateway with JWT, a synchronous orchestrator with real failure paths, per-tenant scoping, containerised delivery, and CI that publishes images.


What it does

Area
Assets one filterable list — by type, status, holder, or free-text (tag / serial / model / holder). Each asset carries a serial or an IMEI (a cellular hotspot or phone is identified by IMEI, and some carry no serial at all; a cable carries neither), a condition, deploy + warranty dates, and a lifecycle status (IN_STOCK · ASSIGNED · IN_REPAIR · BROKEN · PENDING_RECYCLE · RECYCLED · RETIRED · LOST) with guarded transitions. Add / edit inline; retire-and-replace marks a unit gone and opens a pre-filled form for its replacement.
Asset types a per-client list the techs curate — add a type (rejected if the name is already taken), remove one (blocked while assets use it, or move them to another type first).
People employees of a client; ACTIVE → OFFBOARDING → DEPARTED. A desk is optional.
Desks / rooms / sites each carries a QR tag and a building + floor; the console lays desks out as a building → floor map. "What's on this desk" is a query, not stored state.
Assignments check-out / check-in / transfer, and offboard-all for a departing employee. Append-only history — a return stamps returnedAt, it never deletes.
Reports live rollups per client: by type / status / condition / department, lifecycle events from the audit trail, break-and-loss by department, most-replaced tag slots, fleet value.
Audit trail every mutating call writes a who / what / when row in the same transaction as the change.
Tenancy every record belongs to a clientId; the JWT carries the set a user may act on.
Roles ADMIN · TECH (full asset operations) · POC (a customer's point of contact — approves for their own organisation) · HR (offboarding) · USER (an employee: only their own gear). Nothing is readable without a token, and a USER's queries are forced onto their own person record server-side.
Event sign-out an employee requests gear for an event by name, date and quantity — "1 loaner laptop, 2 TVs" — a POC or tech approves it, and a tech hands it out by naming the real units, which check out through the normal custody path.
Event equipment a per-client pool a tech curates — "Acme lends 1 laptop, 1 charger, 2 TVs, 2 speakers, 1 mic". It is the sign-out form's menu, and requests are booked against it per day: two TVs taken for the 3rd leaves none for anyone else that day, and the second ask is refused. Availability is recomputed from the requests rather than kept as a running total, so denying or closing one hands its gear straight back.

A tag identifies a slot, not a unit (ADR 0007): uniqueness is per (client, tag, type) and only among in-service statuses, so a laptop and its bundled charger + cable can share one tag, and a lost accessory is replaced on the same tag while the dead row stays for history.

The failure paths that make the orchestration interesting: checking out an asset that is already assigned → 409; one that is retired or lost → 422; returning one that was never checked out → 409.

Architecture

                         ┌──────────────┐
  browser ──HTTPS──▶      │  api-gateway │  routing · CORS · RS256 JWT (JWKS) · forwards X-User-Id /
                          │    :8080     │  X-User-Role / X-Client-Ids / X-Person-Id
                          └──────┬───────┘
        ┌───────────┬───────────┼───────────┬────────────┬─────────────┐
        ▼           ▼           ▼           ▼            ▼             ▼
   auth-service  client-    people-    location-    asset-       assignment-
     :8081      service     service     service     service        service
   signs RS256   :8085      :8087       :8084       :8083          :8082
   /jwks.json                                          ▲   ┌──────────┘
                                                       └───┤ assign / return / search
                                                           │ (Resilience4j: retry + breaker)
                              RabbitMQ ◀── publishes custody events ──┘
                                 │
                                 ▼
                       notification-service :8086  (consumes the events)

   discovery-server :8761 (Eureka)   ·   config-server :8888 (Spring Cloud Config, native)

assignment-service is the orchestrator. POST /assignments is not @Transactional — it spans HTTP calls — so persistence is split into the short independent transactions of AssignmentTransactions, and a failed downstream call still leaves a correct history row. Its call to asset-service is wrapped in a Resilience4j retry + circuit breaker; custody events go to notification-service over RabbitMQ, not a synchronous call.

Full diagrams and the decision records are in docs/.

Repository layout

services/            10 Spring Boot services (Gradle sub-projects)
web/                  Next.js 16 console (Assets / People / Desks / Types / Reports)
infra/compose/        docker-compose + the Postgres/Flyway overlay
deploy/k8s/           Kustomize base + local/cloud overlays (see docs/deployment.md)
config-repo/          Spring Cloud Config native backend
e2e/                  REST-Assured suite through the gateway
docs/                 architecture + ADRs
build.gradle          root: shared Java 21 / Spring Cloud BOM / quality gates

One gradlew, one .github/workflows/ci.yml.

Run it

cd infra/compose
cp .env.example .env                                    # defaults are fine for local
docker compose -f docker-compose.yml up -d --build      # 14 containers, H2 in-memory
docker compose ps                                        # wait for healthy

10 services + RabbitMQ + Prometheus + Grafana. Grafana is at http://localhost:3001 (anonymous) with a pre-loaded asset-tracker dashboard.

No JWT_SECRET — auth-service generates an RS256 key pair at startup and the gateway validates tokens against its /.well-known/jwks.json.

Then the console:

cd web && cp .env.example .env.local && npm install && npm run dev   # localhost:3000

Seeded logins, one per role, all Passw0rd!: admin@platform.example, tech@acme.example, poc@acme.example, hr@acme.example, and dana.reyes@acme.example (an ordinary employee). With DEMO_LOGINS_ENABLED=true the account menu switches between them — see infra/RUNBOOK.md. Seed data: 3 clients (Acme / Globex / Initech), each with its own type list, people, desks across two buildings and several floors, person + desk kits, and a spread of statuses — ~145 assets in total.

Persist data with the Postgres overlay:

docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build

Everything also runs from the repo root: ./gradlew build (compile + test + Spotless + Checkstyle + JaCoCo for all services), ./gradlew :asset-service:bootRun, etc.

The demo flow

Read with no token (401 — nothing is public) → sign in as the tech → check an asset out to a person → see it on that person → sign in as that employee and see only her own gear → try to check it out again (409) → run offboarding → the asset is back in stock, and notification-service has the ASSET_CHECKED_OUT / OFFBOARDING_COLLECTED events. Scripted in e2e/.

Testing

Layer Where
Unit (service logic, guarded transitions) each service src/test
Controller slice (@WebMvcTest + MockMvc) the REST services
Repository slice (@DataJpaTest) the JPA services with custom queries
End-to-end (REST-Assured through the gateway) e2e/, run for real by CI's e2e job
Console unit (Vitest) web/src/**/*.test.ts — roles, session/JWT, tenant cookie, BFF allow-list
Browser (Playwright) web/e2e/ — the console driven as a person drives it: sign-in per role, what each role can reach, the nav at 1100px and on a phone, and the event sign-out flow end to end
Authorization matrix (live gateway) infra/qa/api-matrix.cjs — 53 expectations that the refusals refuse

e2e/ self-skips when no stack is reachable, so ./gradlew build stays green on a bare checkout. CI's e2e job sets E2E_REQUIRED=true, which turns "unreachable" into a failure — otherwise a stack that never booted would report green.

CI

.github/workflows/ci.yml:

Job What it does
build one Gradle build — compile, unit + slice tests, Testcontainers Postgres ITs, Spotless, Checkstyle, and a JaCoCo floor per service that only ratchets up
web next build + ESLint (flat config, warnings fail) + Prettier + tsc --noEmit + 105 Vitest unit tests
secret-scan gitleaks over the full history
images on main: builds and pushes the ten service images and the console to ghcr.io/<owner>/asset-tracker-<name>, tagged by SHA
e2e pulls those exact images, brings the stack up with Compose, waits for the gateway to route, drives the demo flow through it, then builds the console and runs the Playwright browser suite against the pair

Not done yet

Per-service tenant enforcement from the forwarded X-Client-Ids; a persisted / KMS-backed token-signing key; saga compensation for a half-finished offboarding sweep; a visual floor-plan map of desks; a mobile app that scans a desk/asset QR. Cloud hosting has Kubernetes manifests (deploy/k8s/) that validate offline but no live cluster or infra-as-code yet — see docs/deployment.md.

About

Multi-client IT asset tracking: which person or desk holds each device mapped by floor, detailed per unit, with an audit trail each client can see.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages