Skip to content

Repository files navigation

Clinical Education Equipment Inventory

A web-based inventory, loan, maintenance and service desk system for clinical education equipment.

Track every item across multiple sites, loan it out against a named borrower, book it ahead for courses, keep its servicing schedule, and audit the shelves with a phone camera. A full service desk sits alongside it: anyone can report a broken manikin, a dead projector or a cold room without an account, and the team works those reports as tickets. Responsive on desktop, tablet and mobile, multi-user with admin and staff roles, and backed by PostgreSQL with automated nightly backups.

Currently deployed across QEH, BHH, SOL and GHH.

Why it exists

Clinical education departments accumulate a great deal of expensive, portable equipment — manikins, defibrillators, ultrasound trainers — and it is usually tracked on a spreadsheet. That works until an item is loaned to a ward and not returned, a PAT test lapses, or someone needs to know how many working infusion pumps exist across four sites before booking a course.

This replaces the spreadsheet with a register that knows where each item is, who has it, when it is due back and when it was last serviced. Every item carries a QR label, so checking something out is a scan rather than a search, and a stocktake is a walk round the shelf with a phone.

The same problem applies to faults. A trainer finds a manikin with a split airway or a projector that will not wake up, and the report goes nowhere useful — a corridor conversation, a sticky note, an email to someone on annual leave. The next person to book that room discovers the fault themselves. So the system includes a proper service desk: anyone can report a problem in under a minute without an account, every report gets a reference and a tracking link, and nothing sits in a queue unnoticed because the targets are visible and overdue work is flagged.

It is built for the reality of NHS estate: several sites, staff who are not inventory clerks, people reporting faults who will never log in to anything, and an information governance team who will ask where the data lives. The answer is your own PostgreSQL container.

What it does

  • Service desk — a complete ticketing system for equipment, AV and room faults. Anyone can report a problem without an account; the team triages, assigns, discusses and resolves it against SLA targets. See Service desk for the detail.
  • Inventory — full equipment register with auto-generated asset codes (CE-01001), categories, manufacturer/model/serial, per-site locations, photos, notes and status (Available / On loan / In maintenance / Out of service / Retired). Search, filters, pagination, per-user default site, CSV export and bulk CSV import (with a downloadable template and row-by-row validation), plus one-click item duplication.
  • QR codes — every item gets a QR label (printable 3-up on A4, or downloadable PNG). Scanning with the in-app camera scanner or a phone's native camera opens the item's record. Manual code lookup as a fallback, and an optional alternate barcode per item so existing manufacturer barcodes scan too.
  • Loans — check equipment out to a named borrower (department, contact, destination, due date), check it back in (optionally straight into maintenance and/or flagged for decontamination), extend due dates, overdue tracking, full loan history per item.
  • Reservations — book items ahead for courses and training sessions, with conflict detection, a "due out now" work list, and one-click conversion into a loan.
  • Kits — group items into named bundles (an ALS training kit, for example) and check the whole kit out or in as one action.
  • Rooms — a room register per site with printable QR posters; scanning one opens the fault report form pre-filled with that room.
  • Maintenance — recurring service schedules per item ("Annual PAT test", every 12 months), a due/overdue work list, and a permanent service log (type, summary, cost, engineer) that auto-advances the schedule's next-due date.
  • Documents — attach PDFs and images (PAT certificates, manuals, warranty paperwork) to any item.
  • Stock and consumables — quantity-tracked items (manikin lungs, pads, skins) with minimum levels, low-stock alerts and a full movement history.
  • Stocktakes — scan-based physical audits per site or room: scan everything on the shelf and get a found / missing / registered-elsewhere report. Every scan stamps the item's "last verified" date.
  • Transfers and lifecycle — move items between sites with a full transfer history; track supplier, warranty expiry and planned replacement dates.
  • Reports — utilisation per item, lifetime maintenance spend against purchase cost, loans by department, and replacement planning. All downloadable as CSV for budget rounds.
  • Email notifications (optional, via SMTP) — check-out receipts, due-tomorrow and overdue reminders to borrowers, a weekly servicing/overdue/low-stock digest to admins, and for the service desk: a receipt and tracking link to whoever reported a fault, updates when the team replies or resolves it, a note to whoever a ticket is assigned to, and a daily open/overdue summary to admins.
  • Multi-user — email and password sign-in, admin and staff roles, admin-managed accounts, password changes and resets, self-service password reset by email, login rate-limiting, and a full audit trail with a searchable admin viewer.
  • Dashboard — live stats, open and overdue tickets, overdue loans, servicing due, upcoming reservations, low stock, decontamination queue, per-site breakdown and recent activity.

Service desk

A fault reporting and ticketing system in the style of a modern IT service desk, built into the same application so a broken manikin is linked to its own asset record and service history.

Reporting a fault — no account needed

The people who find broken equipment are rarely the people who own the inventory, so reporting requires no sign-in at all. There are three ways in:

Route What happens
BASE_URL/report The form, from any browser. Share the link, put it on an intranet page.
Scan an asset label Opens the form already pointed at that item.
Scan a room poster Opens the form already pointed at that room.

The reporter chooses what is broken — equipment (by asset code), AV kit in a room, the room itself, or something else — describes the problem, says how urgent it is, and leaves a name and email. That's it.

They immediately get a reference (SD-1042) on screen and an email containing a private tracking link. From that link they can see the current status, read replies from the team, and answer back — still without an account. Replying to a resolved ticket reopens it automatically, which is what people actually do when a repair does not hold.

An unrecognised asset code never blocks a report: it is kept as free text and sorted out at triage.

Working the queue

Any signed-in staff member can work tickets — there is no separate technician role to administer.

  • Queues — Equipment, AV and Room, each with its own SLA targets and optional default assignee. Admin-editable.
  • Statuses — New → Triage → In progress → Waiting on reporter → Resolved → Closed, plus Cancelled.
  • Priorities — Low / Normal / High / Critical. Reporters can flag something as urgent, but only staff can set Critical, so triage stays meaningful.
  • Views — Unassigned, Assigned to me, Waiting on reporter, Overdue, plus search across reference, title, reporter and asset code.
  • Timeline — every comment and every field change, in order.

The composer has two modes, and the distinction is deliberate:

  • Internal note — stays with the team. Costs, supplier grumbles, "this is the third time".
  • Reply to reporter — emailed to them and shown in their portal. Optionally moves the ticket to Waiting on reporter in the same action.

The two are separated server-side, not just hidden in the interface.

Closing the loop with the inventory

Resolving a ticket asks what was done, and can — in one step — write that to the item's service history as a repair, and set the item to In maintenance or Out of service so nobody loans out kit that is still broken. Open tickets appear on the item's own page and on the dashboard.

SLAs

Each queue carries a response target (time to first reply to the reporter) and a resolution target. Tickets past either are flagged in the queue, on the dashboard, and in a daily email to admins.

Note: targets are measured in elapsed time and the clock does not pause while a ticket sits in Waiting on reporter. A Friday evening report will show as overdue on Monday morning. Set the targets in Administration → Service desk with that in mind.

Email

With SMTP configured, the service desk sends: a receipt and tracking link to the reporter, an update whenever the team replies publicly or resolves the ticket, a note to whoever a ticket is assigned to, and a daily open/overdue summary to admins. Without SMTP everything still works — the reporter just sees their reference on screen and cannot track the ticket afterwards.

Setting it up

  1. Administration → Rooms — add the rooms people will report against.
  2. Print labels → Room posters — print and put one up in each room.
  3. Administration → Service desk — set SLA targets and default assignees.
  4. Share BASE_URL/report with staff.

BASE_URL must be correct, because it is baked into both the tracking links and the QR posters.

Run it

With Docker (recommended)

Requires Docker with Compose v2 on any server or VM. This deploys by pulling a prebuilt image from GitHub Container Registry — no source checkout or local build needed. It works the same in Dockge, Portainer or a plain shell.

curl -fsSL -o compose.yaml https://raw.githubusercontent.com/authorTom/clin-ed-inventory/main/compose.yaml
curl -fsSL -o .env.example https://raw.githubusercontent.com/authorTom/clin-ed-inventory/main/.env.example
cp .env.example .env
# Edit .env: set POSTGRES_PASSWORD, SESSION_SECRET, ADMIN_EMAIL/ADMIN_PASSWORD and BASE_URL

docker compose pull
docker compose up -d

Open http://your-server:3000 and sign in with the admin account from .env. If ADMIN_PASSWORD was left blank, a random one is printed once in docker compose logs app.

Updating:

docker compose pull && docker compose up -d

The image is published to GHCR by the docker-publish workflow on every push to main and every v* tag. Pin app.image to a release tag (:v1.0.0) in compose.yaml for reproducible deploys.

First publish only: a new GHCR package defaults to private — make it public once under the repo's Packages settings so servers can pull it anonymously, or run docker login ghcr.io on the server.

The stack runs three containers:

Service Purpose
app Node.js application (port 3000)
db PostgreSQL 16, data persisted in the dbdata volume
backup Nightly pg_dump to ./backups/, pruned after BACKUP_RETENTION_DAYS

To build locally instead — for development or an air-gapped build host — clone the repo, comment out app.image in compose.yaml, uncomment the build: block, then run docker compose up --build -d.

HTTPS (required for camera scanning)

Browsers only allow camera access over HTTPS (or on localhost), so put the app behind a TLS reverse proxy for the QR scanner to work on phones and tablets. The simplest option is Caddy on the same server:

inventory.example.nhs.uk {
    reverse_proxy localhost:3000
}

Then in .env:

BASE_URL=https://inventory.example.nhs.uk
COOKIE_SECURE=true
TRUST_PROXY=true

and restart with docker compose up -d. With the proxy on the same host, also set APP_BIND=127.0.0.1 so the app can only be reached through the proxy.

BASE_URL is baked into the QR codes, so set it before printing labels — if it ever changes, the labels need reprinting.

Phone-camera scanning still works without HTTPS via each item's QR URL; only the in-app scanner needs the secure context.

From source

npm install
# Point DATABASE_URL at any PostgreSQL 14+ database, then:
DATABASE_URL=postgres://user:pass@localhost:5432/inventory \
ADMIN_EMAIL=admin@example.com ADMIN_PASSWORD=changeme12 \
npm run dev

Migrations in migrations/*.sql run automatically at startup (tracked in schema_migrations); add new numbered files for schema changes. SEED_DEMO=true loads a few example items into an empty database.

Configuration

Variable Default What it does
POSTGRES_PASSWORD Database password (required)
SESSION_SECRET Cookie-signing secret (required, long and random)
ADMIN_EMAIL / ADMIN_NAME / ADMIN_PASSWORD First admin account, created once
BASE_URL Public URL — embedded in QR codes and fault-report tracking links
APP_PORT 3000 Host port
APP_BIND 0.0.0.0 Host interface to publish on — set 127.0.0.1 behind a same-host reverse proxy
COOKIE_SECURE false true when serving over HTTPS
TRUST_PROXY false true when behind a reverse proxy
SEED_DEMO false Load demo items on first start
PUBLIC_REPORTING_ENABLED true Set false to close the public fault-report form
BACKUP_RETENTION_DAYS 30 Nightly backup retention
SMTP_HOST / SMTP_PORT / SMTP_SECURE / SMTP_USER / SMTP_PASS SMTP relay for email; leave SMTP_HOST blank to disable all email
MAIL_FROM From: address on outgoing email
NOTIFY_HOUR 7 Hour of day (0–23) to send daily loan reminders

Everything else — sites, locations, categories, users, service schedules — is managed in the app.

Day-to-day use

  • Add equipment: Items → Add item, or Import CSV for bulk onboarding (download the template first). Print QR labels from the item page or in bulk via Print labels.
  • Loan out: scan the label (or open the item) → Check out → borrower and due date. If the borrower has an email address they get a receipt and automatic reminders.
  • Return: scan → Check in; tick Send to maintenance and/or Needs decontamination if it needs attention.
  • Reserve ahead: Reserve on an item (or Reservations → New reservation) with a date range; the Reservations page shows what is due out each day and checks items out in one click.
  • Kits: Kits → create a kit, add items by asset code, then Check out kit / Check in kit moves the whole bundle at once.
  • Consumables: add items with type Stock / consumable, set a minimum level, and use Adjust stock as things are used or restocked; low stock shows on the dashboard.
  • Servicing: give items schedules ("Annual PAT test, every 12 months"); the Maintenance page lists everything due or overdue — Log service records the work and rolls the next-due date forward.
  • Stocktake: Stocktakes → Start stocktake for a site or room, scan everything on the shelf, then Complete — the missing list is your action list.
  • Reports: usage, spend, department and replacement-planning tables, all downloadable as CSV.
  • Faults and tickets: send people to BASE_URL/report or let them scan a room poster; work the resulting tickets from Service desk. See Service desk for the full workflow.
  • Admin (admins only): manage users, sites, per-site locations, rooms, categories, service desk queues, and search the full audit log.

How it's built

Node.js 22 / Express, PostgreSQL 16, server-side sessions (connect-pg-simple), bcryptjs password hashing, helmet CSP, a vanilla ES-module SPA frontend with no build step, qrcode for label generation, and the native BarcodeDetector API with a jsQR fallback for scanning.

Security

The image and compose stack follow container best practice out of the box:

  • Minimal image — multi-stage build on node:22-alpine (current LTS); only node_modules, the server, migrations and static assets are copied in. No npm, docs, secrets or host scripts in the final image.
  • Unprivileged — the app runs as the non-root node user with all Linux capabilities dropped and no-new-privileges set; privilege escalation inside the container is not possible.
  • Read-only root filesystem — the app container can only write to /tmp (a size-capped tmpfs) and the uploads volume; a compromise cannot modify the application code.
  • Reproducible builds — dependencies installed with npm ci from the lockfile.
  • No exposed database — PostgreSQL is only reachable on the internal compose network, with SCRAM-SHA-256 auth; set APP_BIND=127.0.0.1 to also hide the app behind your reverse proxy.
  • Lifecycle and ops — proper PID 1 (init: true), graceful SIGTERM shutdown (in-flight requests finish, DB pool closes), container healthchecks, a PID limit, and JSON log rotation (3 × 10 MB per service).

Application-level: server-side sessions (connect-pg-simple), bcryptjs password hashing, a helmet CSP, login rate-limiting and a full audit trail.

The public reporting endpoint

Fault reporting is the only part of the app that answers without a session, so it is deliberately narrow:

  • Rate limited per IP — 5 reports an hour and 20 a day, with separate limits on tracking-link reads and replies.
  • No file uploads. Reporters describe faults in words; only signed-in staff can attach files. There is no anonymous upload endpoint to attack.
  • Minimal disclosure — the form is served only the sites and room names already printed on the doors. Asset lookup confirms an item's name and nothing else, and an unrecognised code is accepted as free text rather than rejected, so the endpoint cannot be used to enumerate the estate.
  • Tracking links are 256-bit tokens sent by email only, never returned by the API. They travel in a header or request body rather than a URL, so they cannot leak through access logs or Referer. Unknown, expired and revoked tokens all return an identical 404.
  • Internal notes never cross over. The reporter's view is an allow-list of fields built from visibility = 'public' events only; agent email addresses, SLA state and internal IDs are not exposed.
  • Honeypot field — submissions that fill it get a plausible success response and are written to the audit log instead of the ticket table.
  • Kill switchPUBLIC_REPORTING_ENABLED=false returns 503 for the whole public surface without affecting the staff app.

Set BASE_URL correctly: it is baked into tracking links and room QR posters.

Backing up

  • Automatic — the backup container writes backups/inventory-<timestamp>.sql.gz daily and deletes files older than BACKUP_RETENTION_DAYS (30 by default). Copy the backups/ folder to your normal backup target.
  • Manual./scripts/backup.sh
  • Restore./scripts/restore.sh backups/inventory-YYYYmmdd-HHMMSS.sql.gz (replaces the current database after confirmation).
  • Item photos live in the uploads Docker volume — include it in server-level backups if photos matter to you.

Licence

MIT — see LICENSE.

About

A web-based inventory, loan and maintenance system for clinical education equipment.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages