A self-service kiosk for library visitors plus a statistics dashboard for staff. Built to run on the existing ACOR Kubernetes cluster (Postgres operator, ingress-nginx, cert-manager) with a tiny dependency footprint.
The librarian asked for two things that pull against each other:
- "The form should only appear the first time someone registers."
- "…display metrics and statistics, especially monthly visitor numbers."
If we only stored registrations, "monthly visitors" would go flat the moment everyone had signed up — a regular who comes weekly would generate no data. So the data model has two tables:
| Table | Written | Holds |
|---|---|---|
guest |
once, at first visit | the profile (all the librarian's fields) |
visit |
every check-in | timestamp + that day's reason for visiting |
This is what makes the dashboard real: monthly visits, unique visitors, new vs. returning, and every breakdown fall out of these two tables.
The kiosk flow follows from it:
Enter email ─▶ known guest? ─┬─ yes ─▶ "Welcome back" + reason ─▶ log visit ─▶ ✓
└─ no ─▶ full registration form ─▶ create guest + first visit ─▶ ✓
All questions live in one file — src/fields.js — which drives
both the on-screen form and server-side validation, so they can't drift. To
change a question, edit only that file.
Captured once (profile): name, email, phone, nationality, country (optional), gender, age, status, topic(s) of research, preferred medium(s), and the open "what else would you request?" prompt. Captured every visit: reason for today's visit.
Deliberate tweaks to the raw spec, all easy to revert in fields.js:
- Age → age bands (Under 18 … 65+) instead of a raw number: better privacy
and cleaner charts. (To collect an exact number, change the
age_bandfield totype: 'text'.) - Gender includes Prefer to self-describe / Prefer not to say.
- Reason / topic / medium are multi-select (people rarely have exactly one).
- Added an optional country of residence to enrich the Jordanian / International / US-citizen split without changing it.
- A required consent checkbox — this is personal data.
Option A — everything in Docker:
docker compose up --build # → http://localhost:3000 (dashboard: librarian / change-me)Option B — Node against a local Postgres:
cp .env.example .env # edit DATABASE_URL if needed
createdb guestbook
npm install # also vendors chart.js into public/vendor
npm start # migrations run automatically on boot- Kiosk: http://localhost:3000/
- Dashboard: http://localhost:3000/dashboard (basic auth)
.github/workflows/build.yml builds this repo's Dockerfile and pushes to
ghcr.io/acorjordan/kiosk on every push to main (tags: latest, the
short commit SHA, and semver on v* tags). Make the GHCR package public (or
give the cluster a pull secret) so the operator can pull it — matches the other
public ACOR images (e.g. ghcr.io/acorjordan/*).
Deployment lives in the acor/infrastructure repo, following the existing
per-app pattern. Two playbooks, in order:
# 1. Provision the Postgres instance (Zalando operator) in the kiosk namespace
ansible-playbook playbooks/k8s-postgres-kiosk.yml
# 2. Deploy the app (Secret + Deployment + Service + Ingress)
ansible-playbook playbooks/k8s-kiosk.ymlBefore the first run, add the dashboard credentials to the k8s vault:
ansible-vault edit group_vars/k8s/vault.yml
# kiosk_dashboard_user: librarian
# kiosk_dashboard_password: <a strong password>The image/tag is set in group_vars/all/containers.yml (containers.kiosk).
The app reads its DB password from the operator-managed Secret, composes
DATABASE_URL, and runs its own migrations on startup (guarded by a Postgres
advisory lock so the 2 replicas don't race). /healthz backs the k8s probes.
Served internal-only at https://kiosk.k8s.acorjordan.org — resolves via the
existing *.k8s.acorjordan.org wildcard to the private cluster nodes, so no DNS
change is needed and the kiosk is not exposed publicly.
The generic manifests in
k8s/are kept for standalone / non-ACOR use. The Ansible playbooks above are the source of truth for the ACOR deployment.
/dashboard gives the librarian headline KPIs, a 12-month visits chart, new-vs-
returning, and breakdowns by nationality/status/topic/medium/gender/age, plus
CSV export of guests and visits.
Because the numbers live in plain Postgres tables, the same data is directly
queryable from Grafana (you already run it). Point a Postgres data source at
the guestbook DB; e.g. monthly visitors:
select date_trunc('month', created_at) as t, count(*) as visits
from visit group by 1 order by 1;- Personal data is collected with explicit consent (stored as
consent_at). - The dashboard and CSV exports are behind basic auth; the app refuses to start
in production without
DASHBOARD_PASSWORDset. - Set container
TZ=Asia/Ammanso "month" boundaries match local time. - Consider a retention policy (e.g. delete
visitrows older than N years) and who on staff may access exports.
src/
fields.js one source of truth for the form (+ validation)
db.js pg pool + auto-migrations
stats.js all dashboard SQL + CSV
routes/ checkin (kiosk) + dashboard
views.js server-rendered HTML shells
public/ kiosk.js, dashboard.js, styles, vendored chart.js
migrations/ 001_init.sql
k8s/ postgres (Zalando), deployment, service, ingress, secret