# ped-ai — Application Architecture (Deep Dive)
> **Audience.** Engineers working in this repo (and Daniel six months from now).
> Assumes familiarity with Node, Express, Postgres, and vanilla DOM. No
> framework knowledge required — there isn't one.
>
> **Scope.** Top-to-bottom mechanical description of how the app is wired:
> the IIFE frontend, the Express composition root, the schema, encryption
> at rest, migrations, deployment, and the sacred zones you must not
> casually edit.
>
> Companion docs: `docs/architecture.md` (one-page overview Daniel
> maintains), `docs/database.md`, `docs/deployment.md`,
> `docs/configuration.md`. This file is the long-form version that pulls
> the threads together with file:line citations.
---
## 1. Overview
### What this is
ped-ai (PedScribe / Pediatric AI Scribe) is a **self-hosted, single-tenant
clinical documentation platform for pediatricians**. It runs as two Docker
containers — an Express app and a PostgreSQL database — behind a
user-supplied reverse proxy. The audience is one practice (often one
clinician), not a multi-tenant SaaS. There is no per-tenant isolation
because there is no concept of a tenant: every authenticated user shares
the same Postgres schema with row-level `user_id` scoping.
### Top-level shape
```
┌────────────────────────────────────────┐
│ Reverse proxy (Caddy / Nginx / etc.) │
│ TLS termination + host routing │
└────────────────────┬───────────────────┘
│
127.0.0.1:3552
│
┌────────────────────────────────────┴─────────────────────────────────┐
│ pediatric-ai-scribe (node:20-alpine, Express 4) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ static (public/) → vanilla JS SPA shell │ │
│ │ /api/* → 28+ Express routers │ │
│ │ /api/health → Docker healthcheck │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ TCP 5432 │ outbound HTTPS │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ PostgreSQL │ │ AI provider │ │
│ │ pgvector │ │ Bedrock / Vertex / │ │
│ │ pg16 │ │ Azure / LiteLLM / │ │
│ │ │ │ OpenRouter │ │
│ └──────────────┘ └────────────────────────┘ │
│ │
│ Optional siblings: Loki (logs), Grafana (metrics), │
│ OpenBao (secrets), Nextcloud (WebDAV upload), S3 (documents) │
└──────────────────────────────────────────────────────────────────────┘
```
### Tech stack
| Layer | Choice |
| ---------------- | ------------------------------------------------------------ |
| Runtime | Node.js 20 (alpine) |
| HTTP | Express 4 + helmet + cors + cookie-parser + express-rate-limit |
| DB | PostgreSQL 16 with `pgvector` extension |
| DB driver | `pg` (pool, max 20) |
| Migrations | `node-pg-migrate` (programmatic) |
| Auth | JWT (HS256) signed locally + DB-backed `user_sessions` table |
| Secrets | argon2id (current), bcrypt (legacy / rehash on next login) |
| AI | Provider-agnostic via `src/utils/ai.js` → OpenAI SDK shape |
| STT | OpenAI Whisper API / Google Gemini / AWS Transcribe / local whisper.cpp / browser Whisper (transformers.js) |
| TTS | Google Cloud TTS / ElevenLabs / LiteLLM-routed |
| Frontend | **Vanilla JS, no framework, no bundler.** Plain `
```
Inside, it uses real `import`:
```js
// public/js/bedside/index.js
import './shared.js'; // side-effect: sets window._EM for back-compat
import * as ageWeight from './age-weight.js';
import * as subNav from './sub-nav.js';
// ... ~18 modules
[ ageWeight, subNav, ..., sutures ].forEach(function (m) {
if (m && typeof m.init === 'function') m.init();
});
```
This pocket exists because:
- Bedside has 20 sibling files (one per pathway: airway, sepsis, NRP,
etc.). Loading them all as IIFEs would mean 20 more `
```
### 5. Server route
Create `src/routes/mytab.js`:
```js
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.use(authMiddleware);
router.post('/mytab', async function (req, res) {
try {
var hello = (req.body || {}).hello;
if (!hello) return res.status(400).json({ error: 'hello required' });
res.json({ success: true, echoed: hello, user: req.user.email });
logger.audit(req.user.id, 'mytab_call', 'mytab call: ' + hello, req, { category: 'general' });
} catch (e) {
logger.error('POST /mytab', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
```
### 6. Mount the route in `server.js`
Add to the authenticated feature router list near
`server.js:279–303`:
```js
app.use('/api', require('./src/routes/mytab'));
```
### 7. Prompt entry (only if you're calling AI)
If your route calls `callAI`, add the prompt template to
`src/utils/prompts.js`:
```js
PROMPTS.mytab = function (input) {
return `You are a pediatric assistant. ${input}`;
};
```
Live-editable from the Admin Panel via `app_settings` key
`prompt.mytab`.
### 8. Encounter API integration (only if it persists like an encounter)
If your tab needs save/resume, **don't** create a new persistence
table. Use `POST /api/encounters/saved` with a new `enc_type` value
(e.g. `enc_type: 'mytab'`). The tab list at section 11 shows
existing values. Add your new type to whichever client code lists
encounter types (`public/js/encounters.js`'s rendering of saved
encounters).
**This one piece touches sacred code** — get per-change approval.
### 9. Lint check
```bash
node scripts/lint-references.js
```
Should print `✓ All JS id references resolve to an HTML id.` and
`✓ All asset paths resolve to a file on disk.`
### 10. Restart and test
```bash
docker compose restart pediatric-scribe
```
Open the app, click the new tab, click the button, see the JSON
echo. Look at `audit_log`:
```sql
SELECT * FROM audit_log WHERE action = 'mytab_call' ORDER BY timestamp DESC LIMIT 5;
```
### 11. Commit
```
feat(mytab): add demo tab end-to-end
- Sidebar button + tab placeholder
- public/components/mytab.html
- public/js/mytab.js
- src/routes/mytab.js mounted at /api/mytab
- Audit category: general
```
If the new tab is admin/moderator-only, add a role check before the
sidebar button toggle in `auth.js` (look for the existing pattern
around `cms-tab-btn` and `admin-tab-btn`).
---
## Appendix A — File map (compressed)
```
server.js # composition root (388 lines)
docker-entrypoint.sh, Dockerfile, docker-compose{,.local,.e2e,.monitoring}.yml
src/
db/ database.js, migrate.js
middleware/ auth.js, logging.js
routes/ 32 Express routers — see section 9 table
utils/ config, crypto, auditQueue, redact, logger, errors, fileType,
platform, notify, sessions, passwords, promptSafe, prompts,
models, ai, embeddings, transcribe*, tts*
migrations/ # node-pg-migrate, timestamp-prefixed
public/
index.html # ~38