Psychodiagnostics platform for a clinical institution. Specialists assign validated questionnaires, patients complete them on a phone or in a browser, and the system scores them, raises risk alerts, and keeps the clinical record around the results: appointments, conclusions, safety plans, referrals and follow-up.
Production runs at https://polypsy.ink. The repository keeps its original name, Quizzy.
| Part | Stack | Path |
|---|---|---|
| API | Bun, Hono, Drizzle ORM, PostgreSQL 16 with row-level security | apps/api |
| Web | React 19, Vite, Tailwind v4, TanStack Query | apps/web |
| Mobile app | Expo SDK 57, React Native, expo-router | apps/mobile |
| Shared core | Types, zod schemas, scoring engine, dictionaries | packages/shared |
The web app has three faces, chosen by role:
- the clinical console for staff;
- the patient cabinet;
- the tech panel (
/ops) for superadmins.
The mobile app serves patients and staff on rounds.
Requirements: Bun, PostgreSQL 16. Recording transcription also needs ffmpeg and whisper.cpp; everything else works without them.
createdb quizzy
cp apps/api/.env.example apps/api/.env # set DATABASE_URL
bun install
bun run db:migrate
bun run db:seed # demo institution, methods, patients and history; prints the demo sign-ins
bun run api # API on :3001
bun run web # web on :5199
bun run mobile # Expo on :8081, separate terminal- Outside production the secrets have development defaults. Production refuses to start without them (see
apps/api/src/env.ts). - To point the mobile app at production, run
bun run --cwd apps/mobile start:prod, or setEXPO_PUBLIC_API_URL. - To set up a clean instance for a new institution, use
bun run install:instanceinstead of the seed. It migrates the database, creates the institution and installs the method catalog.
- The seed creates demo staff and patient accounts and prints their sign-ins when it finishes. They exist only on local and test databases; the e2e stand uses the same set (
e2e/helpers.ts). - A real instance gets its first superadmin from
install:instance: the email comes fromADMIN_EMAIL, and a random password is printed once. Further superadmins are added withapps/api/src/addSuperadmin.ts.
- The interface is available in Ukrainian, Russian and English.
- Method content (questions, options, band labels) is stored in Ukrainian and Russian.
- The API answers in the language from
?lang=orAccept-Language, so clients receive plain strings. - Server messages, errors and notes are stored as codes and rendered in the reader's language.
- Tests fail on hard-coded strings in the server, web or mobile code.
- Patient: takes assigned methods and sees own results, appointments, messages and safety plan.
- Staff:
- positions form a ladder: specialist → head of department → chief;
- on top of their role, staff get fine-grained permissions (
patients.read,conclusions.sign,users.manage,ops.read, …) and personal exceptions; - what a staff member sees is limited to their groups and departments.
- Superadmin runs the institution and the tech panel.
Rules that hold everywhere:
- Any action on another account follows one rule, whether it comes through the web, the console or a bulk action: you need the permission, the account can't be your own, it must be strictly below your position, and only a superadmin may touch a superadmin. This covers role changes, password reset, disabling or deleting an account, sessions, the second factor, signing in as another user, and roles.
- Reading a patient's clinical data goes through one check: conclusions, results, printouts and one-time print links all use it.
- The database enforces the same boundaries with row-level security under a dedicated application role. Tests run the patient and staff flows under that role.
- TOTP is the second factor. A policy makes it mandatory for superadmins and tech-panel users; other staff can turn it on themselves.
- Patients join through an invitation link. Self-registration without an invitation (
OPEN_REGISTRATION) is off in production unless set to1, and on by default in development; it always creates a patient, never staff. With it off, the refusal is the same whether or not the phone or email is already known. - Pseudonymous patient accounts store no name.
Assessment
- A method constructor with a JSON view for large instruments. Edits create versions, so every result is scored by the version the patient actually saw.
- Assignment: individually, by patient group, by battery with step order, by schedule, or by cascade (a result band assigns follow-up methods).
- Lists act on selected rows, with "select all" across pages. In the catalog, selected methods can be assigned, moved to a group and folder, retired or restored. Access, battery assignments and invitations can be revoked in bulk.
- Patients take methods on the phone (with an offline queue) or in the browser. A clinician can fill in a method on the patient's behalf.
- Critical answers raise risk alerts on autosave, not at the end. Alerts become cases with an owner and a history.
Clinical work
- Reception and the day screen, appointment booking with Google Calendar / Meet links, and session recordings with local transcription.
- Conclusions with revisions and signatures, visit notes, safety plans, referrals, and dispensary follow-up with due dates.
- Messages and mailings to patients, with push notifications.
- Printouts: a result sheet, answer key sheet, blank form, visit certificate, the full outpatient chart and a case extract. The mobile app prints through a one-time link.
Analytics
- Patient dynamics with the reliable change index (RCI) and percentiles.
- Psychometrics: Cronbach's alpha and item-total correlations, answer-quality flags (too fast, straight-lining), and local norms.
- Cohorts, statistical models, a statistics builder, and export to CSV and SPSS with stable subject codes.
Tech panel (/ops)
- Requests, errors, logs, traces, database and slow queries, jobs, sessions, the audit log, integrity of the audit chain, and read-only SQL.
- Releases, feature flags, keys, mobile devices and push, recordings, client errors, web vitals, suspicious activity, grants, "who viewed" and second-factor status.
- Alerts go to Telegram and email.
Built both for the instruments in the manuals of the Research Center for Humanitarian Problems
of the Armed Forces of Ukraine and for international screeners. The engine lives in packages/shared/src/scoring.ts; the
server and the clients score the same way.
| Mechanism | Example |
|---|---|
| Key by item numbers, one item feeding several scales | SR-45, MLO, Mini-mult |
| Ratio scales (share of keyed answers) | SR-45 Sr = N/35 |
| Scale-on-scale corrections | Mini-mult K-correction Hs + 0.5K |
| T-scores by sex and age, sten tables | Mini-mult, MLO |
| Validity scales that gate interpretation | lie and validity scales |
| Reversed grading, clinical recommendation per band | SR-45 |
| Clinician-administered methods | SAD PERSONS, HAM-D-17, HAM-A, CGI-S, CGI-I, BPRS-18 |
| Minimum answered share per scale; items hidden by display logic excluded | ASSIST, PC-PTSD-5 |
Scoring runs in a fixed order:
- raw scores for every scale;
- corrections, applied only after all raw scores exist;
- normalization;
- picking the band;
- the validity check.
When several norms or sten rows fit a person, the most specific one wins: sex first, then age, then the narrower age range.
validateSurvey blocks publishing a method that cannot be scored correctly: items out of range, contradictory keys, overlapping bands, T-scores without norms, and so on. Drafts can still be saved.
Shared catalog, 73 methods in ten areas. The catalog is code (apps/api/src/instruments/catalog.ts; areas in catalogDomains.ts), and the catalog screen filters by area.
| Area | Methods |
|---|---|
| Mood, anxiety, distress (20) | PHQ-9, PHQ-8, PHQ-4, PHQ-2, PHQ-15, GAD-7, GAD-2, PSS-10, PSS-4, CESD-R, SRQ-20, GDS-15, DASS-42, DASS-21, K10, K6, CORE-10, AAQ-II, HAM-D-17, HAM-A |
| Trauma (8) | PCL-5, PC-PTSD-5, ITQ, CES, MIOS, TGI-SR+, CTSR, LEC-5 |
| Suicide risk (6) | C-SSRS screener, ASQ, SBQ-R, SIDAS, SR-45, SAD PERSONS |
| Sleep (4) | SCI, SCI-02, RIS, FAS |
| Addictions (7) | AUDIT, AUDIT-C, CAGE, ASSIST, CUDIT-R, CIWA-Ar, COWS |
| Resources and quality of life (12) | WHO-5, SWLS, Flourishing Scale, SPANE, MLQ, MSPSS, OSSS-3, RSES, UCLA-3, Brief COPE, SCS-SF, MAAS |
| Cognitive functions (3) | ASRS-v1.1 (6 items), 4AT, ADEXI |
| Personality (4) | IPIP-50 (Big Five), TIPI, MLO "Adaptivnist-200", Mini-mult |
| Staff (4) | CBI, BAT-23, BAT-12, ProQOL 5 |
| Other (5) | PQ-16, AQ-10, CGI-S, CGI-I, BPRS-18 |
- Install and update. Every deploy installs the catalog and updates it by content fingerprint.
- Local edits survive. If an institution edited a method locally, its edit is kept: a locally published version blocks the content update, and local settings (visibility, instructions, time limit, area and the like) survive a regular update while the rest of the method is refreshed. The
catalog-statusandcatalog-forcemaintenance actions show the situation and let you override it. - Who sees the catalog. Catalog methods live in the survey group «Каталог методик», a library shared by all staff: every clinician finds, reads, assigns and administers them and puts them into batteries without being an administrator of the group. Their results are shown only for patients who are already in the clinician's scope (department, own appointments, the department's own groups); the catalog never widens that scope, so taking a catalog method does not make a person visible to everyone. Editing catalog methods (builder, versions, settings, retiring) stays with superadmins and explicit administrators of the group. The same rule is enforced by row-level security.
- Clinical passport and stepped screening. Every catalog method has a role (full instrument, short screening form, clinician-rated), item count, typical minutes, what it measures and its cut-offs (
apps/api/src/instruments/catalogPassport.ts). The catalog screen groups methods by area with full instruments first and short forms nested under them (GET /api/surveys?sort=catalog). Ten screening steps with cited thresholds (PHQ-2 ≥ 3 → PHQ-9, GAD-2 ≥ 3 → GAD-7, AUDIT-C ≥ 4 → AUDIT, PC-PTSD-5 ≥ 4 → PCL-5, …) are installed once assuggest_surveyrules: a positive short form suggests the full instrument on the result, in the patient's plan and in the assign drawer, and one click assigns it. - Battery templates. There are nine: initial screening, trauma in depth, suicide risk in depth, addictions, sleep and fatigue, resources and recovery, staff burnout and stress, the psychiatrist's initial examination (C-SSRS, HAM-D-17, HAM-A, CGI-S, all filled in by the doctor), and an in-depth psychological examination (SR-45, MLO, Mini-mult). Each is created once and then belongs to the institution: later deploys do not overwrite edits or bring back a deleted template.
The institution's methods, 4: SR-45, SAD PERSONS, Mini-mult and MLO "Adaptivnist-200" from the manuals of the Research Center for Humanitarian Problems of the Armed Forces of Ukraine. Since 28 October 2026 they are part of the shared catalog by the product owner's decision: the institution uses them and confirms its right to; there is no licence statement from the rights holders, and the dossiers, the staff notes and the install log say so. SR-45, MLO and Mini-mult are given to a patient only by assignment, as part of an examination; SAD PERSONS is filled in by a specialist and is never offered to patients. The seed keeps its own copies under the same catalog keys, so a stand with both the seed and the catalog shows each method once.
The methods have not been clinically validated yet. docs/instruments/ holds the validation package for a clinical psychologist:
- one sheet per method in Ukrainian and Russian;
- generated from the live engine, with "answers → result" examples at every band boundary;
- a test fails whenever a sheet and the engine disagree. To regenerate the sheets, run
bun run --cwd apps/api docs:instruments.
Licensing and sources are tracked in the dossiers under docs/instruments/dossiers/ and in docs/INSTRUMENTS.md.
- One server, one institution. Each institution gets its own database; there are no tenant columns.
- Row-level security. The app connects as
quizzy_app, and policies limit every table. - Audit log.
- Append-only, and it records reads of patient data, not just changes.
- Each row is chained by hash. The chain is verified on a schedule, and from the tech panel or
bun run --cwd apps/api audit:verify.
- Field encryption. Names, phone numbers, free-text answers and clinical texts are encrypted with
ENCRYPTION_KEY(v1:<base64 32 bytes>[,v2:…]; the first key is the active one). Phone numbers can be searched through a blind index with its own secret. - Separate secrets. Session signing, the phone index, export subject codes and field encryption each have their own secret, so rotating one does not break the others.
- Concurrency. State changes are atomic: conclusions, referrals, mailings and safety plans check the revision the user saw and answer 409 instead of overwriting someone else's edit.
The architecture, invariants and threat model are in docs/ARCHITECTURE.md.
bun run typecheck
bun run lint
bun run test # all unit and API tests (API tests need DATABASE_URL)
bun run --cwd apps/api test:app-role # patient and staff flows under the application role
bun run e2e # Playwright on its own stand: DB quizzy_e2e, ports 3199/4199- API tests recreate
<database>_teston every run, so use your own database when running in parallel. - A test may take up to 30 s (
--timeout 30000in the scripts and in CI). Where speed matters, dedicated tests check it with explicit thresholds (dynamicsScale,rlsScale). - CI checks every pull-request commit once and cancels the run of a superseded commit; pushes are checked on
mainonly, and release tags by the deploy workflow. - CI runs test files in a different order than macOS does. Tests use unique data and never rely on "only my rows in the table".
migrationJournal.test.tswalks the history since the last release and fails if a migration was added before one that already existed.
Deploys go through GitHub Actions. A pushed v* tag starts .github/workflows/deploy.yml:
- CI runs typecheck, lint, the API, app-role and client tests, e2e and the Docker build.
- Images are built and pushed to GHCR.
- The compose file and scripts are uploaded to the server and validated.
- A database snapshot is taken.
- The new image loads the server's environment as a pre-check. If it fails, the old version keeps running.
compose upstartsprovision. It runs the migrations, checks every migration by hash (a skipped one stops the deploy), and grants the application role its rights.- The shared catalog is installed, and the deploy confirms that the running version, commit and site names match the release.
- Health checks run inside the server and from outside over HTTPS.
What search engines and AI assistants see:
- The root page carries the landing text in the HTML itself, for clients that don't run scripts; browsers hide it and render the app as usual. Every other path gets
app.html, the same shell without that text and withnoindex(apps/web/seoHtml.ts,apps/web/Caddyfile). /robots.txtallows everything except/api/./llms.txtdescribes the product for language models.- At container start
apps/web/entry.shfills in the site address (Open Graph, schema.org, theSitemapline) and writes/sitemap.xml, but only whenSITE_ADDRESSis a domain.
.github/workflows/maintenance.yml runs one-off actions on the server:
- catalog install, status and force;
- demo data fill and purge;
- the RLS check;
- audit chain verification;
- leftovers;
- API and web logs;
- backup status and a restore drill (also runs monthly);
- slot resync;
- drafts of already submitted attempts (#197): a dry run that lists them, and an apply step that closes their duplicate alerts with a journal row and removes them.
Backups:
- A nightly
pg_dump, encrypted with gpg and verified by reading it back. - Kept as 7 daily, 4 weekly and 12 monthly copies (
scripts/backup.sh). The snapshot taken before each deploy goes to its ownpredeploy/folder (last 10 kept) and never pushes out the scheduled copies. - A weekly timer and the monthly workflow restore the newest copy into a throwaway database and compare it with production (
scripts/verify-backup.sh). - Each copy is also uploaded to an S3-compatible bucket at another provider when
OFFSITE_URLis set (scripts/offsite.sh); theoffsite-statusaction checks that every local copy exists there.
Server setup, TLS, Google sign-in and incident handling are covered in docs/DEPLOY.md and docs/RUNBOOK.md.
apps/api/src/routes HTTP routes, one file per area
apps/api/src/lib domain logic: access rules, scoring glue, scheduler, notifications
apps/api/src/db schema, RLS context, migration runner
apps/api/drizzle SQL migrations and their journal
apps/api/src/instruments method definitions (catalog and seed)
apps/web/src/pages console, patient cabinet and /ops screens
apps/mobile/app expo-router screens: patient tabs, rounds, surveys, analytics
packages/shared/src types, schemas, scoring, dictionaries (uiStrings, errorStrings, serverStrings)
scripts backup, restore, restore check, instance upgrade, VPS setup
e2e Playwright scenarios and API snapshots
docs architecture, deploy, runbook, roadmap, instruments
Most documents are in Russian; the validation package is in Ukrainian and Russian.
| File | Contents |
|---|---|
docs/ARCHITECTURE.md |
data map, invariants, threat model |
docs/DEPLOY.md |
VPS installation, domain and TLS, deploys, backups, Google sign-in |
docs/RUNBOOK.md |
day-to-day operations and incident handling |
docs/instruments/ |
validation package for the clinical psychologist |
docs/INSTRUMENTS.md |
origin and legal status of the methods (older; the validation package is current) |
docs/OMR.md |
feasibility of reading paper forms from a photo |
docs/ROADMAP.md, docs/REWRITE-PLAN.md, docs/REDESIGN.md |
plans and design migration notes (historical, not kept fully up to date) |
Stage: pilot, late beta. Production runs v2.2.0 since 27 October 2026. Version 2.0 brought the console, the patient cabinet and the mobile app to the new «Виразна» design; 2.1 added the shared catalog by area with battery templates; 2.2 reworked the console wording for clinicians.
- Done: clinical console, patient cabinet, mobile app, tech panel, 73 methods in the shared catalog (since 28 October 2026 including 4 of the institution's own and 5 clinician-rated scales), CI with app-role and browser tests, automated deploys with pre-checks, encrypted and verified backups, audit chain, second factor, three interface languages.
- Hardening: four external code reviews (about 90 findings, including privilege escalation, races and data leaks) are fixed and covered by tests. Every pull request also gets an automated code review.
- Not done: clinical validation of the methods by a psychologist (the package is ready and waiting), off-site backups configured on the server, SSH hardening on the host, mobile store publication, real users and a second institution.
- Scale: one VPS (4 cores, 8 GB) serves one institution at near-zero load; capacity is not the constraint, process is.
- The methods have not been clinically validated; the validation package is waiting for a psychologist's review.
- Several instruments are copyrighted. Check their licensing in the dossiers before clinical use.
- The mobile app is not published to the App Store or Google Play; builds are run locally through Expo.
- The off-site backup copy (
scripts/offsite.sh, any S3-compatible storage) is built but not yet configured on the production server. - One server serves one institution. More institutions mean more instances (
scripts/upgrade-instances.sh).