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.
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.
- 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,
adminandstaffroles, 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.
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.
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.
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.
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.
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.
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.
- Administration → Rooms — add the rooms people will report against.
- Print labels → Room posters — print and put one up in each room.
- Administration → Service desk — set SLA targets and default assignees.
- Share
BASE_URL/reportwith staff.
BASE_URL must be correct, because it is baked into both the tracking links and
the QR posters.
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 -dOpen 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 -dThe 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.ioon 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.
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_URLis 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.
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 devMigrations 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.
| 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.
- 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/reportor 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.
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.
The image and compose stack follow container best practice out of the box:
- Minimal image — multi-stage build on
node:22-alpine(current LTS); onlynode_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
nodeuser with all Linux capabilities dropped andno-new-privilegesset; 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 cifrom 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.1to 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.
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 switch —
PUBLIC_REPORTING_ENABLED=falsereturns 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.
- Automatic — the
backupcontainer writesbackups/inventory-<timestamp>.sql.gzdaily and deletes files older thanBACKUP_RETENTION_DAYS(30 by default). Copy thebackups/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
uploadsDocker volume — include it in server-level backups if photos matter to you.
MIT — see LICENSE.