# KVH PULSE

**Preop Unified Live Secured Exchange** - A HIPAA-compliant PWA for KVH Anesthesia group communication and preoperative evaluation tracking.

© 2025-2026 Adam Powell / Adam Powell LLC. All Rights Reserved.

---

## Quick Start

```bash
# Server
cd pulse/server && npm install && npm start

# Client (dev)
cd pulse/client && npm install && npm run dev
```

**Live URL**: https://adampowell.pro/pulse/

---

## File Structure

```
pulse/
├── server/
│   ├── index.js              # Main server (Express + Socket.io + Web Push + document conversion + VirusTotal scanning, ~3800 lines)
│   ├── generate-vapid-keys.js # VAPID key generator utility
│   ├── test-push.js          # Push notification test utility
│   ├── package.json
│   └── uploads/              # File upload directory
├── client/
│   ├── src/
│   │   ├── main.jsx          # App entry point (ErrorBoundary + ToastProvider wrapper + styles)
│   │   ├── styles.css        # Dark theme styling (~10100 lines)
│   │   ├── components/
│   │   │   ├── App.jsx              # Main orchestrator (state, socket, routing, code splitting, view transitions, login alerts)
│   │   │   ├── LoginScreen.jsx      # Login form + passkey authentication
│   │   │   ├── RegisterScreen.jsx   # Invite-based registration
│   │   │   ├── Sidebar.jsx          # Channel list, collapsible sections, DnD reorder (@dnd-kit), nav + tools rows, channel long-press menu, online users, mute toggle, Tips & Features guide, integrated user panel (avatar, status, notifications, logout)
│   │   │   ├── ChatView.jsx         # Chat messages, search, GIF picker, virtualized list, infinite scroll, export, panels, image lightbox, PHI detection, jump-to-latest, context menu, pull-to-refresh, rich paste, smooth channel transitions, VirusTotal scan overlay
│   │   │   ├── MessageItem.jsx      # Individual message bubble (reactions, long-press menu, swipe-to-reply, replies, link previews, threads, unread separator, file attachments, slide viewer, auto-collapse, image thumbnails)
│   │   │   ├── FormatToolbar.jsx    # Markdown formatting toolbar (bold, italic, code, etc.)
│   │   │   ├── MentionAutocomplete.jsx # @mention user autocomplete dropdown (@channel/@here + users)
│   │   │   ├── TypingIndicator.jsx  # Typing indicator display
│   │   │   ├── ConfirmDialog.jsx    # Reusable confirmation dialog modal
│   │   │   ├── ConnectionStatus.jsx # Socket connection status banner (connected/reconnecting/disconnected)
│   │   │   ├── EmptyState.jsx       # Configurable empty state illustrations (channels, messages, preop, search)
│   │   │   ├── ErrorBoundary.jsx    # React error boundary with retry button
│   │   │   ├── LinkPreview.jsx      # OG metadata link preview cards (client-cached)
│   │   │   ├── SessionLock.jsx      # HIPAA idle timeout lock screen (15min timeout, password re-auth)
│   │   │   ├── Skeleton.jsx         # Shimmer loading skeletons (messages, channels, preop cards)
│   │   │   ├── Toast.jsx            # Toast notification system (ToastProvider context, useToast hook, auto-dismiss)
│   │   │   ├── PreopView.jsx        # Preop Case Consultation Board (status workflow, modal detail view, bottom sheets, swipe-to-status, @mentions, PHI detection)
│   │   │   ├── AdminPanel.jsx       # Admin user management, audit log viewer, VirusTotal scan monitor, color picker, invite
│   │   │   ├── UserPanel.jsx        # (Legacy — merged into Sidebar.jsx) User info, status picker, notifications, logout
│   │   │   ├── InviteModal.jsx      # Admin invite creation modal
│   │   │   ├── PasskeySettings.jsx  # WebAuthn passkey management modal (register, list, delete)
│   │   │   ├── PasskeyEnrollment.jsx # Post-login passkey enrollment prompt (required for admins)
│   │   │   ├── PinnedMessagesPanel.jsx # Right-side panel for browsing pinned messages per channel
│   │   │   ├── GlobalSearchPanel.jsx   # Right-side panel for cross-channel message search
│   │   │   ├── ThreadPanel.jsx         # Right-side panel for thread/reply view (root + replies)
│   │   │   ├── ProfileCard.jsx         # Click-to-view user profile card (role, specialty, status, contact)
│   │   │   ├── MedCalcPanel.jsx        # Medication calculator (doses, drips, TIVA, McLott mix recipes)
│   │   │   ├── SlideViewer.jsx        # Document viewer for PPTX + DOCX (carousel with prev/next, lightbox navigation, download, polls for conversion status)
│   │   │   ├── PollCard.jsx            # Poll display card with voting bars and results
│   │   │   ├── CreatePollModal.jsx     # Poll creation form (question, options, deadline)
│   │   │   ├── VoiceRecorder.jsx       # MediaRecorder-based voice message recorder
│   │   │   ├── WalkieTalkie.jsx        # Push-to-talk walkie-talkie (Socket.io audio streaming)
│   │   │   ├── HandoffView.jsx         # Shift handoff management (create, acknowledge, patient list)
│   │   │   └── MarkdownText.jsx     # Cached markdown rendering (DOMPurify sanitized, HR support)
│   │   ├── hooks/
│   │   │   ├── useDebouncedValue.js      # Debounce hook for search inputs
│   │   │   ├── useChannelPreferences.js  # localStorage-backed channel mute preferences
│   │   │   └── useSocket.js              # Socket.io connection manager (all event handlers)
│   │   └── utils/
│   │       ├── formatters.js     # Display names, timestamps, viewport helpers
│   │       ├── normalizers.js    # Server data normalization
│   │       ├── markdown.js       # Discord-style markdown parser (LRU cached, HR support, smart list grouping, block-level <br> cleanup, @channel/@here broadcast highlights) + HTML→markdown converter for rich paste
│   │       ├── auth.js           # Auth storage (localStorage + httpOnly cookie session)
│   │       ├── audio.js          # Web Audio API notification sounds
│   │       ├── notifications.js  # Push notification subscription
│   │       ├── haptic.js         # Haptic feedback utility
│   │       ├── phiDetect.js     # Client-side PHI pattern detector (MRN, SSN, DOB, patient names)
│   │       └── medcalc.js       # Anesthesia drug reference + dose/drip/TIVA calculations + McLott mix
│   ├── public/
│   │   ├── manifest.json
│   │   ├── service-worker.js # Push + cache-busting SW (network-first navigation, clears caches on activate)
│   │   ├── favicon.ico
│   │   └── icon-*.png        # PWA icons (144, 192, 512)
│   ├── dist/                 # Production build (code-split chunks)
│   ├── index.html
│   ├── vite.config.js
│   ├── generate-icons.py     # PWA icon generation script
│   └── package.json
├── database/
│   ├── schema.sql            # PostgreSQL schema
│   ├── migration-push-subscriptions.sql # Push notification subscriptions
│   └── migration-unread-tracking.sql    # Server-side unread tracking (last_read_message_id, notification indexes, channel_id on notifications)
├── TODO.md                   # GUI/UX polish pass spec (7 phases, all complete)
├── .env.example
└── webhook.js                # GitHub auto-deploy webhook (npm install, build, deploy, CF purge)
```

---

## Tech Stack

| Layer | Technology |
|-------|------------|
| Frontend | React 18 (lazy/Suspense code splitting), Socket.io Client 4.7, Vite 5.2, PWA (vite-plugin-pwa) |
| UI | react-virtuoso (virtualized chat + infinite scroll), DOMPurify (XSS sanitization), @dnd-kit (drag-and-drop channel reorder) |
| Backend | Node.js 18, Express 4.19, Socket.io 4.7 |
| Database | PostgreSQL 11+ |
| Auth | JWT (jsonwebtoken, role-based expiry: Admin 365d / users 24h) + bcrypt (10 rounds) + httpOnly cookies (cookie-parser) + WebAuthn passkeys (@simplewebauthn) + post-login enrollment prompt |
| Security | Helmet 7.1, express-rate-limit, CORS, SSRF protection on link previews, VirusTotal file scanning |
| Link Previews | cheerio (OG metadata scraping), node-fetch v2 |
| Push | Web Push API (VAPID) via web-push, Service Worker |
| Logging | Winston |
| File Upload | Multer |
| CDN | Cloudflare (auto-purge on deploy) |

---

## Features

### Communication
- **Real-time Chat** — Instant messaging via Socket.io with private and public channels, message editing, deletion, and reply threading
- **Thread / Reply View** — Reply to any message; dedicated right-side thread panel shows root message + all replies chronologically with real-time sync
- **@Mentions** — Type `@` for user autocomplete; `@channel` notifies all members, `@here` notifies online members; distinct highlight colors
- **Typing Indicators** — Live "X is typing..." with animated dots, throttled emit (2s), auto-expires after 3s
- **Emoji Reactions** — Quick react bar (👍 ❤️ 😂 🔥) on hover + full emoji picker; aggregated counts with user lists
- **Message Pinning** — Admin pins messages; dedicated pinned messages panel with "Jump to message"
- **GIF Picker** — GIPHY-powered search and send
- **Link Previews** — Server-side OG metadata scraping with in-memory cache (1hr TTL), SSRF-protected
- **Message Formatting** — Discord-style markdown (bold, italic, strikethrough, code, underline, headers, lists, blockquotes, horizontal rules, spoilers, @mentions) with visual format toolbar. Slack/Discord-style flat message layout (no bubbles, hover highlight, rounded avatars). Smart list grouping across blank lines, block-level `<br>` cleanup. Rich paste (Ctrl+V converts HTML clipboard to markdown; Ctrl+Shift+V for plain text). Auto-collapse on messages longer than 10 lines with gradient fade and "Show more"/"Show less" toggle
- **Global Search** — Cross-channel message search (ILIKE, respects private membership) with highlighted matches
- **Unread Separator** — Slack-style red "New messages" divider between last-read and first unread message when switching channels; uses server-side read watermark queried before upsert
- **Read Receipts** — Per-channel read watermarks; "Read by N" indicator with user names on hover
- **Message Export** — Admin can export channel messages as CSV or JSON (audit-logged)

### Media
- **Image Thumbnails + Lightbox** — Images and GIFs display as compact thumbnails (240px max); click any image for full-screen overlay view (95vw/95vh auto-fit); embedded markdown images also clickable via event delegation
- **File Upload** — Paste images (Ctrl+V, all users), drag-and-drop files, or use 📎 attach button (admin); file preview bar before sending; supports PPTX, PDF, DOC, XLS and more (25MB limit). **VirusTotal Antivirus Scanning** — images from non-admin users are automatically uploaded to VirusTotal and scanned by 70+ antivirus engines before being accepted. Full-screen scan overlay with shield animation, progress bar, and status updates. Malicious files are deleted and rejected with detection count
- **Document Viewer** — Upload .pptx or .docx files and view pages inline with prev/next carousel navigation; server-side conversion via LibreOffice headless (PPTX/DOCX → PDF → PNG); click pages for lightbox; auto-polls for conversion completion; falls back to download link on error
- **Voice Messages** — Record and send voice messages via MediaRecorder API (WebM/Opus); audio player in chat with native controls
- **Walkie-Talkie (Push-to-Talk)** — Hold-to-talk real-time audio streaming via Socket.io; pre-acquired mic for instant start/stop; active speaker indicator; Pointer Events API for unified touch/mouse

### Collaboration
- **Quick Polls** — Create polls with 2-6 options, optional time deadline, anonymous/named votes; animated progress bars; auto-close at deadline
- **Preop Case Consultation Board** — Full case decision workflow with 5 statuses (Pending, Proceed, Hold, Cancelled, Completed). **Proceed/Hold voting system** — each user votes proceed or hold (toggle on/off, switch votes), counts displayed on cards with voter names on hover. Status-themed card borders and badges. **Compact list layout** — collapsed cards render as tight scannable rows; click to open focused modal overlay (desktop) or bottom sheet (mobile). Conversation-first layout with real user avatars and grouped comments. **Consecutive comment merging** — same-user comments within 2 minutes auto-append. System comments for status changes. Tab filtering (Today/All/Active/Hold/Completed, default: Active) with stats bar. Swipe-to-vote on mobile (right=proceed, left=hold). Overflow menu for status actions (Set Proceed/Hold/Pending/Completed/Cancelled). @mention autocomplete in comments. Back button/Escape closes expanded cards
- **Shift Handoffs** — Structured handoff forms with patient list (name, room, status, notes), critical notes, pending tasks with priority levels; assignable to specific users; acknowledgment workflow
- **User Profile Cards** — Click any username to see role, specialty, status, email, phone; viewport-aware positioning

### Medication Calculator
- **35+ drugs across 9 categories** — Induction, Opioids, Muscle Relaxants, Vasopressors, Reversal, TIVA, Common Drips, Local Anesthetics, Special Mixes
- **Weight-based dosing** — Enter patient weight (kg/lbs) for automatic dose range calculations
- **Drip rate calculator** — Enter desired dose rate → calculates mL/hr based on standard concentrations
- **TIVA section** — Propofol infusion, Remifentanil, Dexmedetomidine (Precedex), Ketamine adjunct, Lidocaine infusion, Magnesium infusion — all with mixing instructions and rate calculators
- **McLott Mix (Opioid-Free)** — Both 20 mL syringe and 100 mL bag formulations with ingredients, preparation, and dosing
- **Inhalational Agents Reference** — Sevoflurane, Isoflurane, Desflurane, Nitrous Oxide with physical properties (blood:gas coefficient, vapor pressure, boiling point, tissue solubilities, metabolism %), MAC values by age, MAC clinical levels (MAC-Awake, 1.0 MAC, 1.3 MAC, MAC-BAR), factors that increase/decrease/don't affect MAC, and PK/PD accordion reference (speed of induction, blood:gas solubility, cardiac output effects, concentration/second gas effect, ventilation/FRC, tissue distribution groups, pediatric considerations, V/Q mismatch)

### Security & Compliance (HIPAA)
- **Authentication** — JWT + bcrypt (10 rounds) + httpOnly secure cookies + WebAuthn passkeys (FIDO2/biometrics)
- **Passkey Enrollment** — Post-login prompt; required for admin accounts, optional (dismissible) for regular users
- **Session Lock** — 15-minute idle timeout with 2-minute warning countdown; full-screen lock overlay; password re-auth required; Admin role exempt (no idle timeout)
- **Admin Persistent Auth** — Admin accounts receive 365-day JWT + cookie (regular users: 24h); eliminates repeated logins for trusted devices
- **Audit Log** — Comprehensive HIPAA compliance trail; admin viewer with action/date filters and pagination
- **Invite-Only Registration** — Admin creates email-bound invite tokens with role assignment and expiration
- **Row-Level Security** — PostgreSQL RLS on preop patient data
- **Rate Limiting** — Express rate-limit on auth endpoints
- **PHI Detection Warning** — Client-side scanner warns when chat or preop comment input may contain PHI (MRN, SSN, DOB, patient names); debounced 500ms; dismissible warning bar; does not block sending
- **SSRF Protection** — Link preview fetcher blocks private IPs and internal hosts
- **VirusTotal File Scanning** — All image uploads from non-admin users are scanned via VirusTotal API (70+ antivirus engines) before being stored or broadcast. Scans poll for up to 120 seconds. Malicious files are immediately deleted from disk. Admin uploads bypass scanning (trusted)

### Channels & Administration
- **Channel Management** — Create, rename, drag-and-drop reorder (@dnd-kit sortable, admin only), and delete channels; public and private channels with membership
- **Channel Sections** — Admin-created collapsible section headers to organize channels (e.g., "Surgeon Preferences", "General"). Collapse/expand state persisted in localStorage. Collapsed sections show aggregate unread badge. Admin CRUD (create, rename, delete). Drag-and-drop section reorder (admin). Move channels between sections via long-press context menu. Section delete makes channels unsectioned (preserves all data)
- **Channel Mute** — Per-channel mute toggle (localStorage-backed); muted channels dim in sidebar, suppress badges and notification sounds
- **Admin Panel** — User management (edit role, display name, color, disable/enable, password reset, delete); audit log viewer; VirusTotal scan monitor (summary cards, status badges, filters, pagination)
- **Server-Side Unread Tracking** — Database-backed unread counts computed from `channel_read_receipts` watermarks. On socket connect, server sends `unread:sync` with per-channel unread + mention counts from DB (source of truth). Client uses optimistic local updates for instant UI, but server state wins on reconnect. Fixes double-counting bug (users now leave previous channel room on join; `message:notification` only sent to users NOT in the channel room)
- **Reply Notifications** — When a user replies to a message, the original author receives a socket `mention:notification` (type: reply) + Web Push notification + persistent DB entry in `notifications` table
- **Mention Persistence** — All @mentions (individual, @channel, @here) and reply notifications are stored in the `notifications` table with `channel_id` for per-channel mention badge counting. Notifications are marked read when user joins the channel
- **Push Notifications** — Web Push via VAPID; push on new messages, mentions, replies, preop activity; grouped by channel tag; reply/mention pushes show individually with double vibration pattern and requireInteraction; notification click routes to correct channel. Preop chart creation and comment notifications include sender name and patient info

### Performance & UX
- **PWA** — Installable progressive web app with manifest, icons, and cache-busting service worker (network-first navigation ensures fresh HTML on every open; Vite content-hashed assets auto-update)
- **Code Splitting** — React.lazy() + Suspense for ChatView, PreopView, AdminPanel, MedCalcPanel; main chunk ~42 KB
- **Virtualized Chat** — react-virtuoso for rendering thousands of messages with infinite scroll pagination (50 per page); smooth channel switching (old messages stay visible during transition, no empty flash)
- **Tips & Features Guide** — Collapsible sidebar section with 4 categories (Chat, Formatting, Preop Board, Tools & Settings) covering all user-facing features
- **Skeleton Loading** — Shimmer loading skeletons for messages, channels, and preop cards
- **Empty States** — Contextual empty state illustrations (no channels, no messages, no search results, etc.)
- **Connection Status** — Live socket connection banner (connected/reconnecting/disconnected) with retry button
- **Toast Notifications** — Context-based toast system (success/error/warning/info) with progress bar countdown, auto-dismiss, click-to-dismiss, animated enter/exit; wired to preop status changes, chart CRUD, file upload errors, and AOL-style login alerts ("User signed on")
- **Error Boundaries** — React error boundaries wrapping each view + entire app with retry button
- **View Transitions** — CSS fade-in animation (0.2s) on preop, handoffs, and admin view switches
- **Scroll Shadows** — Gradient mask-image fade indicators on scrollable containers (channels list, comments list, med calc body)
- **Button Feedback** — CSS `:active` scale micro-interactions on send, submit, and action buttons (0.88–0.95x)
- **Dark Theme** — Full dark mode UI with accent color system and status-themed glassmorphism cards
- **Mobile Optimized** — Responsive breakpoints, 16px minimum inputs (no zoom), haptic feedback, iOS safe area insets, touch-optimized controls (36px min touch targets), bottom sheet overlays

---

## Production Deployment

| Item | Value |
|------|-------|
| Host | DigitalOcean Debian 10 |
| IP | 198.211.114.12 |
| Server Path | `/var/www/pulse/server/` |
| Client Path | `/var/www/adampowell.pro/html/pulse/` |
| Port | 3006 (localhost, nginx proxy) |
| Service | `kvh-pulse.service` (systemd) |
| Database | `kvh_pulse` on PostgreSQL 11.22 |

### SSH Access

```bash
# Windows (PowerShell)
ssh -i "$env:USERPROFILE\.ssh\id_ed25519" root@198.211.114.12

# Linux/Mac
ssh -i ~/.ssh/id_ed25519 root@198.211.114.12
```

| Item | Value |
|------|-------|
| User | root |
| IP | 198.211.114.12 |
| Key (local) | `~/.ssh/id_ed25519` |
| Key (server) | `/root/.ssh/id_ed25519` |
| Authorized Keys | `/root/.ssh/authorized_keys` |

### GitHub Webhook (Auto-Deploy)

On push to `main`, the webhook automatically:
1. `git reset --hard` + `git pull` (clean pull, no merge conflicts)
2. `npm install` + `npm run build` in `pulse/client/`
3. Copies dist to `/var/www/adampowell.pro/html/pulse/`
4. Copies `server/index.js` + `server/package.json` to `/var/www/pulse/server/` + runs `npm install --production`
5. Self-updates `webhook.js`
6. `systemctl kill -s SIGKILL kvh-pulse.service` + `systemctl start` (avoids SIGTERM hang)
7. Purges Cloudflare cache (`purge_everything`)
8. Sends email notification (success/failure)

| Item | Value |
|------|-------|
| Payload URL | `https://adampowell.pro/gitwebhook` |
| Secret | `payload-url-is-so-chicken-peepoo-poop` |
| Service | `kvh-pulse-webhook.service` |
| Webhook Path | `/var/www/pulse/webhook/` |
| Webhook Env | `/var/www/pulse/webhook/.env` |

### Server File Paths

```
/var/www/pulse/
├── server/
│   ├── index.js              # Main server
│   ├── .env                  # Production environment
│   ├── package.json
│   ├── logs/                 # Application logs
│   └── node_modules/
├── webhook/
│   ├── webhook.js            # GitHub webhook handler
│   ├── .env                  # Webhook secrets (CF_API_TOKEN, CF_ZONE_ID, etc.)
│   └── node_modules/
└── logs/                     # Shared logs

/var/www/adampowell.pro/html/pulse/
├── index.html
├── assets/                   # JS/CSS bundles (hashed, code-split chunks)
├── manifest.json
├── manifest.webmanifest
├── registerSW.js
├── icon-*.png                # PWA icons
├── favicon.ico
└── service-worker.js         # Push + cache-busting service worker (network-first navigation)
```

### Config File Locations

| File | Path |
|------|------|
| Nginx Config | `/etc/nginx/sites-available/adampowell.pro` |
| Nginx Enabled | `/etc/nginx/sites-enabled/adampowell.pro` |
| PULSE Service | `/etc/systemd/system/kvh-pulse.service` |
| Webhook Service | `/etc/systemd/system/kvh-pulse-webhook.service` |
| SSL Cert | `/etc/letsencrypt/live/adampowell.pro/fullchain.pem` |
| SSL Key | `/etc/letsencrypt/live/adampowell.pro/privkey.pem` |
| Server Env | `/var/www/pulse/server/.env` |
| Webhook Env | `/var/www/pulse/webhook/.env` |
| Webhook Logs | `/var/log/kvh-pulse-webhook.log` |

### Service Commands

```bash
# PULSE App
systemctl status kvh-pulse          # Check status
systemctl restart kvh-pulse         # Restart
journalctl -u kvh-pulse -f          # View logs

# Webhook
systemctl status kvh-pulse-webhook
systemctl restart kvh-pulse-webhook
tail -f /var/log/kvh-pulse-webhook.log

# Nginx
nginx -t && systemctl reload nginx
```

---

## Environment Variables

```env
# Server
PORT=3006
NODE_ENV=production

# Database
DB_HOST=localhost
DB_PORT=5432
DB_NAME=kvh_pulse
DB_USER=kvh_pulse_user
DB_PASSWORD=KVH_Pulse_2026_Secure!
DB_SSL=false

# Security
JWT_SECRET=kvh_pulse_jwt_secret_258bda7d79b022e0e3e126880bbdc8b0aaff0e1abc0238b6e4d47737f09799b8
SESSION_SECRET=kvh_pulse_session_secret_f9b07b01b6f7e8a809a1d75d0ef21d10a7b8ac8420f1d1fa0201f8af3f98e92e

# URLs
CORS_ORIGIN=https://adampowell.pro
CLIENT_URL=https://adampowell.pro/pulse
API_URL=https://adampowell.pro/pulse/api

# HIPAA
ENABLE_AUDIT_LOG=true
SESSION_TIMEOUT=1800000
MAX_FILE_SIZE=52428800

# Web Push Notifications (VAPID)
VAPID_PUBLIC_KEY=BJrzG1WZFgmdOcmbtfNtXlTN72-j40MAtSnpd1bjvVqXCwBH3dGT_e1O5pb1XWEA5YlaRtpUMf06B3v89crq1-o
VAPID_PRIVATE_KEY=-iieEbmFAL5y5nWt7NtG5QNDqbMC65UcbAl20nBnwvk
VAPID_EMAIL=mailto:adamtpowell92@gmail.com

# File Scanning
VIRUSTOTAL_API_KEY=<your-virustotal-api-key>
```

### Webhook Environment

```env
GITHUB_WEBHOOK_SECRET=payload-url-is-so-chicken-peepoo-poop
CF_API_TOKEN=<cloudflare-api-token>
CF_ZONE_ID=<cloudflare-zone-id>
CF_ALLOW_PURGE_EVERYTHING=1
WEBHOOK_EMAIL_USER=adam@adamantanesthesia.com
WEBHOOK_EMAIL_PASS=<app-password>
WEBHOOK_EMAIL_TO=adam@adamantanesthesia.com
```

---

## Admin Account

```
Username: Adam
Password: admin123
Email: adamtpowell92@gmail.com
```

> **Change password after first login!**

---

## REST API Endpoints

### Public (No Auth)

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/login` | POST | User login — returns JWT + user object, sets `pulse_token` httpOnly cookie |
| `/api/logout` | POST | Clears `pulse_token` httpOnly cookie |
| `/api/session` | GET | Check active session via httpOnly cookie — returns user + token if valid |
| `/api/register` | POST | Register with invite token |
| `/api/validate-invite/:token` | GET | Validate invite token before registration |
| `/api/health` | GET | Enhanced health check (uptime, DB ping latency, memory usage, socket count) |
| `/api/gifs/search?q=` | GET | GIPHY search (query + limit params) |
| `/api/push/vapid-public-key` | GET | Get VAPID public key for push registration |
| `/api/passkey/auth-options` | POST | Generate WebAuthn authentication challenge (discoverable credentials) |
| `/api/passkey/auth-verify` | POST | Verify passkey authentication — returns JWT + user + sets httpOnly cookie |

### Authenticated (JWT or Cookie)

| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/search?q=&limit=` | GET | Token | Global message search across accessible channels (ILIKE, grouped by channel, respects private membership) |
| `/api/link-preview?url=` | GET | Token | Fetch OG metadata for URL (server-cached 1hr, SSRF-protected) |
| `/api/push/subscribe` | POST | Token | Subscribe to push notifications (endpoint + keys) |
| `/api/push/unsubscribe` | POST | Token | Unsubscribe from push (full or specific endpoint) |
| `/api/passkey/register-options` | POST | Token | Generate WebAuthn registration challenge (exclude existing credentials) |
| `/api/passkey/register-verify` | POST | Token | Verify passkey registration and store credential |
| `/api/passkey/list` | GET | Token | List user's registered passkeys (id, device_name, dates) |
| `/api/passkey/:id` | DELETE | Token | Remove a registered passkey |
| `/api/slides?file=` | GET | Token | Get slide images for a PPTX/DOCX file (returns URLs + status: ready/pending) |

### Admin Only

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/admin/create-invite` | POST | Create invite with email, role, expiration |
| `/api/admin/invites` | GET | List active/used invites (up to 50) |
| `/api/admin/users` | GET | List all users with roles, status, disable state |
| `/api/admin/users/:id` | PATCH | Update user (displayName, username, email, role, color, disable) |
| `/api/admin/users/:id` | DELETE | Delete user (cascades messages, reactions, comments, subscriptions) |
| `/api/admin/users/:id/password` | POST | Admin password reset |
| `/api/admin/audit-log` | GET | Paginated audit log with filters (action, userId, date range) |
| `/api/admin/virustotal-scans` | GET | Paginated VirusTotal scan log with summary stats, status/date filters. Returns `{ scans, total, page, totalPages, summary: { total, clean, blocked, errors, last_24h } }` |
| `/api/admin/channels/:channelId/export` | GET | Export channel messages as CSV or JSON (`?format=csv\|json`) |
| `/uploads/*` | GET | Serve uploaded files (express.static) |

### Authentication Flow

All endpoints accept authentication via:
1. **httpOnly cookie** (`pulse_token`) — set automatically on login, checked first
2. **Authorization header** (`Bearer <token>`) — fallback for backward compatibility

**Passkey (WebAuthn) login**: Alternative passwordless login via FIDO2/biometrics. Uses discoverable credentials with `rpID: adampowell.pro`. On successful passkey verification, issues the same JWT token + httpOnly cookie as password login.

**Passkey enrollment prompt**: After password login, users without a registered passkey are prompted to set one up. Admin accounts **must** register a passkey (cannot dismiss). Regular users can click "Not now" to skip — dismissal is remembered in `localStorage` (`pulse_passkey_dismissed`) so the prompt won't reappear.

All client fetch calls include `credentials: 'include'` to send cookies cross-origin.

---

## Socket.io Events

**Path**: `/pulse/socket.io/`

Socket auth accepts token via `auth: { token }` handshake **or** `pulse_token` httpOnly cookie from handshake headers.

### Authentication
| Event | Payload | Response |
|-------|---------|----------|
| `auth:login` | `{ username, password }` | `{ success, user, token }` |

### Channels
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `channels:list` | `{}` | Callback: channels array with message counts |
| `channel:create` | `{ name, isPrivate, sectionId? }` | `channel:created` |
| `channel:join` | `{ channelId }` | Callback: `{ messages, hasMore, lastReadAt }` (up to 50, with reactions + reply context + reply counts). Leaves previous channel room (prevents double-counting). Returns previous read watermark before upserting. Upserts read receipt + broadcasts `channel:readreceipts`. Marks all `notifications` for this channel as read |
| `channel:history` | `{ channelId, before, limit }` | Callback: `{ success, messages, hasMore }` — pagination for older messages |
| `channel:delete` | `{ channelId }` | `channel:deleted` (admin only, cascades members + messages) |
| `channel:update` | `{ channelId, name?, sectionId? }` | `channel:updated` (admin only) |
| `channel:reorder` | `{ orderedIds }` | `channel:reordered` (admin only) |

### Channel Sections
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `sections:list` | `{}` | Callback: sections array ordered by sort_order |
| `section:create` | `{ name }` | `section:created` (admin only) |
| `section:update` | `{ sectionId, name }` | `section:updated` (admin only) |
| `section:delete` | `{ sectionId }` | `section:deleted` (admin only, channels become unsectioned via ON DELETE SET NULL) |
| `section:reorder` | `{ orderedIds }` | `section:reordered` (admin only) |

### Messages
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `message:send` | `{ channelId, content, type, fileData?, fileName?, fileType?, replyTo? }` | `message` (to channel room). Types: `text`, `gif`, `image` (all users, VT-scanned for non-admins), `file` (Adam only), `voice` (all users). Targeted `message:notification` to users NOT in channel room. `mention:notification` for @mentions and replies. Persists mentions/replies to `notifications` table |
| `message:edit` | `{ messageId, content }` | `message:updated` (own or admin) |
| `message:delete` | `{ messageId }` | `message:deleted` (own or admin) |
| `message:react` | `{ messageId, emoji }` | `message:reactions` (aggregated by emoji with user list) |
| `message:pin` | `{ messageId, isPinned }` | `message:pinned` (admin only) |
| `channel:pinned` | `{ channelId }` | Callback: `{ success, messages }` — all pinned messages in channel with reactions |
| `thread:load` | `{ messageId }` | Callback: `{ success, rootId, messages }` — root message + all replies chronologically |
| `message:markread` | `{ channelId }` | Upserts read receipt watermark, broadcasts `channel:readreceipts` to channel |
| `message:readstatus` | `{ messageId }` | Callback: `{ success, readBy }` — users whose watermark >= message timestamp |

### Read Receipts
| Event | Payload |
|-------|---------|
| `channel:readreceipts` | Broadcast: `{ channelId, receipts: [{ userId, lastReadAt, displayName }] }` — sent on channel join + mark read |

### Preop Charts (Case Consultation Board)
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `preop:list` | `{}` | Callback: charts array (up to 100, with nested comments + status fields) |
| `preop:create` | `{ patientInitials, surgeon, surgeryDate, surgeryType }` | `preop:created` |
| `preop:update` | `{ chartId, updates }` | `preop:updated` (includes completion, status, status_changed_by/at) |
| `preop:status` | `{ chartId, status }` | `preop:updated` — changes case status (pending/proceed/hold/cancelled/completed), inserts system comment (`comment_type = 'status_change'`), sends push notification |
| `preop:vote` | `{ chartId, vote }` | `preop:updated` — cast/toggle/switch proceed or hold vote. Same vote = retract, different vote = switch. Chart payload includes aggregated `votes: { proceed: [], hold: [] }` |
| `preop:delete` | `{ chartId }` | `preop:deleted` |
| `preop:comment` | `{ chartId, comment }` | `preop:comment` (new comment) or `preop:comment:updated` (merged — same user within 2 min appends to previous) |
| `preop:comment:edit` | `{ commentId, content }` | `preop:comment:updated` (own or admin) |
| `preop:comment:delete` | `{ commentId }` | `preop:comment:deleted` (own or admin) |

### Typing Indicator
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `typing:start` | `{ channelId }` | `typing` — broadcasts `{ channelId, user: { id, username, displayName } }` to channel room |

Client emits `typing:start` throttled (once per 2s). Server broadcasts `typing` to all other users in the channel. Client auto-expires typing users after 3s of inactivity.

### Polls
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `poll:create` | `{ channelId, question, options[], deadline?, isAnonymous? }` | `poll:created` (to channel) |
| `poll:vote` | `{ pollId, optionIndex }` | `poll:updated` (to channel, with aggregated votes) |
| `poll:close` | `{ pollId }` | `poll:closed` (creator or admin) |
| `poll:list` | `{ channelId }` | Callback: polls array (up to 20, with vote data) |

### Shift Handoffs
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `handoff:create` | `{ toUserId?, patients[], criticalNotes, pendingTasks[] }` | `handoff:created` (global) |
| `handoff:acknowledge` | `{ handoffId }` | `handoff:updated` (global) |
| `handoff:list` | `{}` | Callback: handoffs array (up to 50) |

### Push-to-Talk (Walkie-Talkie)
| Event | Payload | Broadcast |
|-------|---------|-----------|
| `ptt:start` | `{ channelId }` | `ptt:started` (to channel, with user info) |
| `ptt:chunk` | `{ channelId, chunk }` | `ptt:chunk` (base64 audio data to channel) |
| `ptt:stop` | `{ channelId }` | `ptt:stopped` (to channel) |

### Slide Conversion
| Event | Payload |
|-------|---------|
| `slides:ready` | `{ fileUrl, slideCount }` — broadcast to channel when PPTX/DOCX conversion completes |

### VirusTotal File Scanning
| Event | Direction | Payload |
|-------|-----------|---------|
| `upload:status` | Server → Client | `{ fileName, stage, progress?, message }` — scan progress (stages: `uploading`, `scanning`, `complete`). Progress is 0-95% during scanning |
| `upload:rejected` | Server → Client | `{ fileName, reason }` — file rejected by antivirus scan with detection count or error reason |

### User Status & Notifications
| Event | Payload |
|-------|---------|
| `user:status` | `{ status }` (online/away/dnd/invisible) |
| `users:online` | Broadcast: online users array (socketId, username, displayName, status, userId, role) |
| `unread:sync` | Server → Client on connect: `{ unreads: { channelId: count }, mentions: { channelId: count } }` — DB-computed unread + mention counts (source of truth on connect/reconnect) |
| `message:notification` | Targeted (not broadcast): `{ channelId, messageId, senderId }` — sent only to users NOT currently in the channel room (prevents double-counting with `message` event) |
| `mention:notification` | Targeted: `{ userId, channelId, channelName, senderName, preview, mentionType? }` — @mention or reply notification. `mentionType`: `'reply'` for reply notifications, `'channel'`/`'here'` for broadcast mentions, undefined for individual @mentions. Persisted to `notifications` table |
| `notification` | General notification event (preop charts, system alerts) |

---

## Database Schema

### Core Tables (19 active)

| Table | Columns | Purpose |
|-------|---------|---------|
| `users` | id, username, display_name, email, password_hash, role, avatar, status, is_disabled, disabled_at, disabled_by, last_seen, specialty*, phone*, display_color*, created_at, updated_at | User accounts |
| `channels` | id, name, description, is_private, sort_order, section_id*, created_by, created_at, updated_at | Chat channels |
| `channel_sections` | id, name, sort_order, created_by, created_at | Collapsible channel groups (auto-migrated) |
| `channel_members` | id, channel_id, user_id, role, joined_at — UNIQUE(channel_id, user_id) | Channel membership |
| `messages` | id, channel_id, user_id, content, type, file_url, file_name, file_type (VARCHAR 255), reply_to_message_id, is_pinned, created_at, edited_at | Chat messages |
| `message_reactions` | id, message_id, user_id, emoji, created_at — UNIQUE(message_id, user_id, emoji) | Emoji reactions |
| `preop_charts` | id, patient_name, mrn, dob, surgery_date, surgery_type, asa, allergies, medications, medical_history, lab_results, notes, is_completed, completed_at, completed_by, status*, status_changed_by*, status_changed_at*, created_by, created_at, updated_at | HIPAA-protected preop data (RLS enabled) |
| `preop_comments` | id, chart_id, user_id, comment, comment_type*, created_at | Chart discussion comments (user/system/status_change) |
| `invite_tokens` | id, token, created_by, role, email, expires_at, used_by, used_at | Invite-only registration tokens |
| `audit_log` | id, user_id, action, resource_type, resource_id, details (JSONB), ip_address (INET), user_agent, created_at | HIPAA compliance audit trail |
| `push_subscriptions` | id, user_id, endpoint, p256dh, auth, device_info, created_at — UNIQUE(user_id, endpoint) | Web Push subscriptions |
| `passkey_credentials` | id, user_id, credential_id, public_key, counter, transports (TEXT[]), device_name, created_at, last_used_at | WebAuthn FIDO2 passkeys |
| `channel_read_receipts` | id, channel_id, user_id, last_read_at, last_read_message_id* — UNIQUE(channel_id, user_id) | Per-channel read watermarks (auto-migrated) |
| `notifications` | id, user_id, type, title, message, read, data (JSONB), channel_id*, created_at | Persistent notification store — mentions, replies, broadcast mentions. Marked read on channel:join |
| `polls` | id, channel_id, user_id, question, options (JSONB), deadline, is_anonymous, is_closed, created_at | Channel polls (auto-migrated) |
| `poll_votes` | id, poll_id, user_id, option_index, created_at — UNIQUE(poll_id, user_id) | Poll votes (auto-migrated) |
| `preop_votes` | id, chart_id, user_id, vote, created_at — UNIQUE(chart_id, user_id) | Preop proceed/hold votes (auto-migrated) |
| `handoffs` | id, from_user_id, to_user_id, patients (JSONB), critical_notes, pending_tasks (JSONB), status, created_at | Shift handoffs (auto-migrated) |
| `virustotal_scans` | id, user_id, file_name, file_size, analysis_id, status, detections, total_engines, stats (JSONB), created_at | VirusTotal scan results log (auto-migrated) |

*\* = added via ALTER TABLE migration (ensureUserProfileColumns for users; ensureChannelSectionsTable for channels.section_id + channel_sections table; manual migration for preop status/comment_type columns; ensureVirusTotalScansTable for virustotal_scans)*

### Schema Placeholder Tables (5 — in schema.sql, not yet used by server)

| Table | Columns | Purpose |
|-------|---------|---------|
| `direct_messages` | id, sender_id, recipient_id, content, type, file_url, read, created_at | Direct messages |
| `roles` | id, name, description, permissions (JSONB), created_at | Role definitions |
| `user_roles` | id, user_id, role_id, assigned_at — UNIQUE(user_id, role_id) | User-role junction |
| `events` | id, title, description, start_time, end_time, location, created_by, created_at | Scheduled events |
| `event_attendees` | id, event_id, user_id, status — UNIQUE(event_id, user_id) | Event RSVP tracking |

### Indexes (24)

| Index | Table | Column(s) |
|-------|-------|-----------|
| `idx_messages_channel` | messages | channel_id |
| `idx_messages_user` | messages | user_id |
| `idx_messages_created` | messages | created_at |
| `idx_preop_surgery_date` | preop_charts | surgery_date |
| `idx_preop_mrn` | preop_charts | mrn |
| `idx_notifications_user` | notifications | user_id |
| `idx_notifications_read` | notifications | read |
| `idx_audit_log_user` | audit_log | user_id |
| `idx_audit_log_created` | audit_log | created_at |
| `idx_channel_members_user` | channel_members | user_id |
| `idx_channel_members_channel` | channel_members | channel_id |
| `idx_push_subscriptions_user` | push_subscriptions | user_id |
| `idx_passkey_user` | passkey_credentials | user_id |
| `idx_passkey_credential` | passkey_credentials | credential_id |
| `idx_read_receipts_channel` | channel_read_receipts | channel_id |
| `idx_read_receipts_user` | channel_read_receipts | user_id |
| `idx_messages_channel_created` | messages | (channel_id, created_at) |
| `idx_notifications_type` | notifications | type |
| `idx_notifications_created` | notifications | created_at |
| `idx_notifications_user_unread` | notifications | (user_id, read) WHERE read = FALSE |
| `idx_notifications_channel` | notifications | channel_id |
| `idx_preop_votes_chart` | preop_votes | chart_id |
| `idx_vt_scans_user` | virustotal_scans | user_id |
| `idx_vt_scans_created` | virustotal_scans | created_at |

### Triggers & Functions

| Trigger/Function | Tables | Purpose |
|------------------|--------|---------|
| `update_updated_at_column()` | users, channels, preop_charts | Auto-updates `updated_at` on row modification |
| `audit_trigger_func()` | preop_charts, messages | Auto-logs INSERT/UPDATE/DELETE to `audit_log` |

### Extensions

| Extension | Purpose |
|-----------|---------|
| `uuid-ossp` | UUID generation (`uuid_generate_v4()`) |
| `pgcrypto` | Encryption utilities |

### RLS Policy

| Policy | Table | Rule |
|--------|-------|------|
| `preop_access_policy` | preop_charts | Restricts access to users with role Admin/CRNA/Anesthesiologist/Nurse |

### User Roles

| Role | Permissions |
|------|-------------|
| Admin | Full system access |
| CRNA | View/create/edit charts |
| Anesthesiologist | View/create/edit/approve charts |
| Nurse | View-only |

### Default Channels

general, preop-discussions, case-reviews, emergencies, scheduling

---

## User Registration (Invite-Only)

### 1. Admin Creates Invite
Via Admin Panel or API:
```bash
curl -X POST https://adampowell.pro/pulse/api/admin/create-invite \
  -H "Authorization: Bearer <JWT>" \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "role": "CRNA", "expiresIn": 604800000}'
```

### 2. User Registers
Visit invite URL → Create account with password meeting requirements:
- 12+ characters
- Uppercase + lowercase letters
- Number
- Special character

---

## Nginx Configuration

**File**: `/etc/nginx/sites-available/adampowell.pro`

```nginx
location /pulse/api/ {
    proxy_pass http://127.0.0.1:3006/api/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

location /pulse/socket.io/ {
    proxy_pass http://127.0.0.1:3006/socket.io/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
}

# Block probing for source/config files - return 404 instead of SPA shell
location ~ ^/pulse/(src/|server/|\.env|\.git|vite\.config|package\.json|tsconfig|node_modules) {
    return 404;
}

location /pulse/ {
    alias /var/www/adampowell.pro/html/pulse/;
    try_files $uri $uri/ /pulse/index.html;
}
```

---

## Development Setup

### 1. Database
```bash
sudo -u postgres createdb kvh_pulse
sudo -u postgres psql -d kvh_pulse -f database/schema.sql
```

### 2. Server
```bash
cd server
npm install
cp ../.env.example .env  # Edit with your settings
npm start               # Port 3006
```

### 3. Client
```bash
cd client
npm install
npm run dev             # http://localhost:5173
npm run build           # Production build
```

---

## Troubleshooting

| Issue | Solution |
|-------|----------|
| WebSocket fails | Check nginx has `Upgrade` headers; verify port 3006 is listening |
| DB connection error | Verify PostgreSQL running; check `.env` credentials |
| Service won't start | Check `journalctl -u kvh-pulse`; verify `.env` exists |
| File upload fails | Check `uploads/` directory permissions; verify disk space |
| Webhook deploy fails | Check `/var/log/kvh-pulse-webhook.log`; Node 18 on server may warn about deps needing Node 20+ |
| Service won't stop (SIGTERM hang) | `systemctl kill -s SIGKILL kvh-pulse.service` then `systemctl start` |
| Stale cache after deploy | Verify CF_API_TOKEN/CF_ZONE_ID in webhook `.env`; manual purge: `curl -X POST cloudflare-purge-url` |
| Merge conflicts in dist/ | Webhook uses `git reset --hard` to avoid this; manual fix: `git reset --hard HEAD && git pull` |
| `cheerio` / `undici` crash on Node 18 | Use `cheerio@1.0.0-rc.12` (not 1.2.0+) — undici in 1.2+ requires Node >= 20 |
| Health check | `curl https://adampowell.pro/pulse/api/health` — returns uptime, DB ping, memory, socket count |
| DOCX conversion fails | Install `libreoffice-writer` (`apt-get install libreoffice-writer`). PPTX only needs `libreoffice-impress` but DOCX requires Writer |
| File upload saves but message disappears | Check `messages.file_type` column width — long MIME types (DOCX is 71 chars) need `VARCHAR(255)` not `VARCHAR(50)` |
| `@simplewebauthn` Crypto error on Node 18 | Add `if (!globalThis.crypto) { globalThis.crypto = require('crypto').webcrypto; }` at top of server |
| `@simplewebauthn` "String values for userID no longer supported" | v11 requires `Uint8Array` — use `new TextEncoder().encode(user.id)` instead of passing UUID string directly |

---

## HIPAA Compliance

- End-to-end encryption (HTTPS/WSS)
- JWT + bcrypt authentication with httpOnly secure cookies + WebAuthn passkey support (required for admins)
- Role-based access control
- Row-level security on preop data
- Comprehensive audit logging with admin viewer
- Session lock (15-minute idle timeout with 2-minute warning, password re-auth; Admin exempt)
- Strong password requirements
- Message export for compliance (CSV/JSON, admin only, audit-logged)

---

## Support

**Developer**: Adam Powell, CRNA
**Company**: Adam Powell LLC
**Email**: Adam@adamantanesthesia.com
**GitHub**: https://github.com/t3h28/adampowell.pro

---

## Changelog

### v2.14.0 (Mar 1, 2026) — User Panel Merged into Sidebar, No Auto-Collapse
- **User panel merged into sidebar**: Moved user avatar, display name, role, status picker, notification bell, and logout button from the standalone right-side `UserPanel` component into the bottom of the `Sidebar` component. User info is now always accessible inside the sidebar regardless of screen size (previously hidden below 1024px). Status menu and notifications panel open upward from the sidebar bottom
- **Sidebar never auto-closes on desktop**: Removed all `setSidebarOpen(false)` calls triggered by channel selection, view switching, opening Med Calc, or opening Passkey settings. Sidebar collapse/expand is now always manual via hamburger button on desktop. Auto-close is preserved on mobile only (<=768px) for overlay behavior
- **Removed UserPanel component from App.jsx**: `UserPanel.jsx` is no longer rendered as a separate component; all its functionality lives in `Sidebar.jsx` now

### v2.13.0 (Mar 1, 2026) — Slack-Style Chat UI, Collapsible Sidebar, Text Selection Fix, Preop Status Fix
- **Slack/Discord-style flat messages**: Removed card-style message bubbles; messages now sit flat on background with subtle hover highlight. Rounded-square avatars (8px radius). Baseline-aligned headers with dimmer timestamps. Cleaner action bar, compact reactions
- **Markdown rendering fixes**: Fixed bullet lists rendering as separate `<ul>` blocks with huge gaps — items separated by blank lines now group into single list. Added horizontal rule (`---`) support via `<hr class="md-hr">`. Cleaned up stray `<br>` tags between block elements (headings, lists, HRs). Tighter heading margins, compact list spacing
- **Collapsible sidebar on desktop**: Hamburger button (☰ SVG) always visible in all view headers. Sidebar collapses/expands on desktop with smooth width transition. Starts open on desktop, closed on mobile. Toggle works across Chat, Preop, Admin, and Handoff views. Sidebar never auto-closes on desktop — collapse/expand is always manual. On mobile (<=768px), sidebar auto-closes on channel select and view change
- **Text selection enabled on desktop**: `user-select: none` moved to mobile-only (`@media max-width: 768px`). Right-click context menu re-enabled on desktop (only blocked on touch devices). Users can now select and copy message text on PC
- **Fix: Preop status changes failing**: PostgreSQL error `inconsistent types deduced for parameter $1` — the `UPDATE preop_charts` query reused `$1` in both `SET` and `CASE WHEN` contexts causing type ambiguity. Added explicit `$1::text` casts to resolve

### v2.12.1 (Feb 18, 2026) — DOCX Conversion Fix
- **Fix: DOCX upload silently failing**: Word document uploads showed "Converting slides..." then the message disappeared from chat. Two root causes:
  1. **Missing `libreoffice-writer` package**: Only `libreoffice-impress` was installed on the production server. LibreOffice needs the Writer component to convert DOCX files (Impress handles PPTX only). Installed `libreoffice-writer` via apt
  2. **`file_type` column too narrow**: The `messages.file_type` column was `VARCHAR(50)` but the DOCX MIME type (`application/vnd.openxmlformats-officedocument.wordprocessingml.document`) is 71 characters. The DB INSERT silently failed, so the message was never saved — the optimistic client message disappeared on socket reconnect. Widened column to `VARCHAR(255)`
- **Fix: SlideViewer polling with data URL**: Optimistic (pending) messages pass the raw base64 data URL as `fileUrl`. SlideViewer was attempting to poll `/api/slides?file=data:application/...base64...` causing massive URLs and HTTP 520 errors. Added guard to skip polling when `fileUrl` starts with `data:`

### v2.12.0 (Feb 17, 2026) — Word Document Inline Preview
- **DOCX/DOC Inline Viewer**: Word documents (.docx, .doc) now render as inline page previews in chat, identical to the existing PPTX slide viewer. Server-side conversion via LibreOffice headless (DOCX → PDF → PNG pages). Carousel with prev/next navigation, click-to-lightbox, download button, polling for conversion status
- **Expanded conversion pipeline**: `convertPptxToSlides()` function now handles both PPTX and DOCX formats (regex expanded from `/\.pptx?$/i` to `/\.(pptx?|docx?)$/i`). Same fire-and-forget pattern — conversion runs after message delivery, emits `slides:ready` socket event when done
- **Server dependency**: Requires `libreoffice-writer` package on production server (was previously only `libreoffice-impress` for PPTX)

### v2.11.1 (Feb 13, 2026) — VirusTotal Admin Monitoring
- **VirusTotal Scan Monitor Tab**: New "VirusTotal" tab in Admin Panel alongside Users and Audit Log. Displays all scan results with summary cards, filterable scan entries, and pagination
- **Summary Cards**: 4 glassmorphism stat cards at top — Total Scans, Clean (green), Blocked (red), Last 24h — with real-time counts from the database
- **Scan Entry Rows**: Each scan shows timestamp, username, filename, file size, status badge (green=clean, red=malicious, amber=error/timeout), detection count vs total engines, and clickable VirusTotal analysis link
- **Filters**: Status dropdown (All/Clean/Malicious/Error/Timeout) and date filter to narrow scan results
- **Pagination**: 50 scans per page with page navigation (reuses existing audit-pagination pattern)
- **New DB table**: `virustotal_scans` (id, user_id, file_name, file_size, analysis_id, status, detections, total_engines, stats JSONB, created_at) — auto-migrated via `ensureVirusTotalScansTable()`
- **New indexes**: `idx_vt_scans_user` (user_id), `idx_vt_scans_created` (created_at)
- **New REST endpoint**: `GET /api/admin/virustotal-scans` — paginated scan log with summary stats, status/date filters (admin only, `requireAdmin` middleware)
- **New component**: `VirusTotalScansView` in `AdminPanel.jsx`
- **CSS additions**: `.vt-summary-row`, `.vt-summary-card`, `.vt-status-badge` (clean/malicious/error variants), `.vt-scan-entry`, `.vt-filename`, `.vt-filesize`, `.vt-engines`, `.vt-link`

### v2.11.0 (Feb 13, 2026) — VirusTotal Antivirus Scanning
- **Image Upload for All Users**: All users can now paste images (Ctrl+V) into chat. Previously image upload was restricted to admin (Adam) only. Document uploads (PPTX, PDF, DOC, etc.) remain admin-only
- **VirusTotal Integration**: Images uploaded by non-admin users are automatically scanned by VirusTotal's 70+ antivirus engines before being accepted. Server uploads the file to `POST /api/v3/files`, polls `GET /api/v3/analyses/{id}` every 5 seconds for up to 120 seconds until all engines report results
- **Scan Overlay UI**: Full-screen overlay with animated shield icon, spinning scan ring, progress bar (0-95%), and status text ("Uploading to VirusTotal..." → "Scanning with 70+ antivirus engines..." → "Image cleared — 0 threats detected"). Slide-up entrance animation with backdrop blur
- **Malicious File Rejection**: If any antivirus engine flags a file as malicious or suspicious, the file is immediately deleted from the server, the pending message is removed from the chat, and a toast error shows the detection count
- **Admin Bypass**: Admin (Adam) uploads skip VirusTotal scanning entirely for trusted, instant uploads
- **Server changes**: `scanWithVirusTotal()` function in `index.js`, `VIRUSTOTAL_API_KEY` env var, `form-data` npm package for multipart uploads, new `upload:status` and `upload:rejected` socket events
- **Client changes**: Image paste ungated for all users in `ChatView.jsx`, `canUploadFiles` (admin) vs `canUploadImages` (all) distinction, `scanStatus` state in `App.jsx`, VT scan overlay component, `upload:status` and `upload:rejected` socket handlers in `useSocket.js`
- **CSS additions**: `.vt-scan-overlay`, `.vt-scan-card`, `.vt-shield-container`, `.vt-scan-ring` (spinning border), `.vt-progress-track/fill`, `@keyframes vt-slide-up`, `@keyframes vt-spin`, `@keyframes vt-draw-check`
- **New dependency**: `form-data` (server)
- **New env var**: `VIRUSTOTAL_API_KEY`

### v2.10.1 (Feb 13, 2026) — Preop Board Compact Layout
- **Compact List Layout**: Preop cards now render as tight, scannable rows instead of heavy cards. Reduced padding (0.5rem vs 1rem), lighter box-shadow, no hover lift, smaller initials badge (30px), `flex-wrap: nowrap` for single-line rows. Switched from CSS grid to flex column with 0.4rem gap
- **Desktop Modal Overlay**: Clicking a preop card now opens a centered modal (680px wide, 85vh max, backdrop blur) instead of expanding inline. Keeps the list stable — no cards pushing each other around. Modal has scale-in animation and inherits status border-left color
- **Mobile Unchanged**: Mobile still uses the existing bottom sheet overlay for expanded cards
- **CSS additions**: `.preop-card-modal-overlay`, `.preop-card-modal`, `@keyframes modal-scale-in`, `.preop-chart-card.collapsed` overrides (lighter shadow, no `::after` gradient, no hover transform)

### v2.10.0 (Feb 13, 2026) — Chat Polish & UX Enhancements
- **Smooth Channel Switching**: Eliminated empty flash when switching channels. Old messages stay visible during the ~200ms server response, with a subtle opacity transition. Removed `setMessages([])` synchronous clear from `joinChannel` in `useSocket.js`
- **Image Thumbnails**: All chat images (uploaded, GIF, embedded markdown) now render as compact 240×180px thumbnails. Click any image to open full-screen lightbox (95vw/95vh auto-fit). Embedded markdown images now also support click-to-lightbox via event delegation on the message text container
- **Auto-Collapse Long Messages**: Messages exceeding 10 lines (~250px) auto-collapse with a gradient fade overlay and "Show more"/"Show less" toggle button. Keeps the chat timeline scannable for long pastes or detailed messages
- **Rich Paste (HTML → Markdown)**: Ctrl+V converts formatted clipboard content (from websites, Word, etc.) to Discord-style markdown via `htmlToMarkdown()` in `markdown.js` (DOMParser-based recursive DOM walker). Ctrl+Shift+V pastes as plain text. Supports bold, italic, underline, strikethrough, code, lists, links, blockquotes, headers
- **Section Drag Reorder**: Admin can drag sections by the ≡ handle to reorder them in the sidebar. Uses separate `DndContext` from channel reorder. New `section:reorder` socket event + `handleSectionDragEnd` handler
- **AOL-Style Login Alerts**: Toast notification ("User signed on") when users come online. Tracks previous online user set via ref; skips initial load to avoid flood. Uses existing toast system (`toast.info`)
- **Tips & Features Guide**: Collapsible sidebar section with 4 categories — Chat (mentions, reply, reactions, threads, search, GIFs, polls, voice, walkie-talkie), Formatting (bold/italic/underline/code/spoiler/quote/rich paste), Preop Board (consultations, voting, comments, filters), Tools & Settings (med calc, passkeys, mute, profiles, push, drafts)
- **CSS additions**: `.channel-transitioning` opacity transition, `.message-text.clamped` (16em max-height + gradient fade), `.message-expand-btn`, `.section-drag-handle`, image thumbnail sizing (240px max-width, 180px max-height)

### v2.9.0 (Feb 12, 2026) — Collapsible Channel Sections
- **Channel Sections**: Admin can create collapsible section headers in the sidebar to organize channels into groups (e.g., "Surgeon Preferences", "General", "Cases"). Sections appear below unsectioned channels with a collapse/expand arrow
- **Section CRUD**: Admin-only create, rename, and delete sections. Delete makes channels unsectioned (ON DELETE SET NULL — all messages and data preserved)
- **Move Channels**: Long-press a channel → "Move to section" submenu lets admin assign channels to any section or remove from section ("None")
- **Collapse Persistence**: Collapsed/expanded state saved in localStorage (`pulse_collapsed_sections`) — persists across page reloads and sessions
- **Aggregate Badges**: Collapsed sections show total unread + mention count badge so no notifications are missed
- **Create Channel in Section**: Channel creation form includes optional section dropdown when sections exist
- **New DB table**: `channel_sections` (id, name, sort_order, created_by, created_at) — auto-migrated via `ensureChannelSectionsTable()`
- **Altered table**: `channels` gains `section_id` UUID FK column (nullable, ON DELETE SET NULL) — auto-migrated
- **5 new socket events**: `sections:list`, `section:create`, `section:update`, `section:delete`, `section:reorder`
- **New normalizer**: `normalizeSection()` in `normalizers.js`

### v2.8.1 (Feb 10, 2026) — Chat Scroll Fix & Jump Button
- **Fix: Channel switch scroll jump**: Reverted to original Virtuoso scroll behavior (`initialTopMostItemIndex` + `followOutput="smooth"` + `scrollToBottom('auto')` in socket callback). Previous attempts using direct DOM `scrollTop`, multi-timeout corrections, key-based remount, and visibility hiding all fought with Virtuoso's internal scroll management and caused worse issues (yanking users back to bottom when scrolling up, flickering). The clean fix: restore `initialTopMostItemIndex={filteredMessages.length - 1}`, restore `scrollToBottom('auto')` in joinChannel socket callback, and clear messages synchronously before socket emit to prevent stale data flash
- **Fix: Jump-to-latest button overlapping send button**: Button was positioned `absolute` relative to `.chat-view` (includes input area). Wrapped Virtuoso + jump button in `.messages-scroll-area` container with `position: relative` so button positions relative to messages area only

### v2.8.0 (Feb 9, 2026) — Admin Persistent Auth & Cache-Busting
- **Admin 365-day auth**: Admin role gets 365-day JWT + httpOnly cookie (all 3 sign locations: socket login, REST login, passkey verify). Regular users remain at 24h. Eliminates repeated logins for trusted admin devices
- **Session lock exemption**: Admin users fully exempt from 15-minute idle timeout — no warning countdown, no lock screen
- **Network-first service worker**: Added `fetch` event listener for `navigate` mode — always fetches HTML from network (falls back to cache on network failure). Combined with Vite's content-hashed filenames, ensures users always get fresh code on every app open without manual cache clearing
- **Note**: Existing sessions keep their old expiry. Admin must log out and back in once after deploy to receive the new 365-day token

### v2.7.0 (Feb 9, 2026) — PowerPoint Slide Viewer
- **PPTX Upload & Inline Viewer**: Upload `.pptx` files in chat — server converts to slide images via LibreOffice headless (PPTX → PDF → PNG via `pdftoppm`). Inline carousel viewer with prev/next navigation and slide counter
- **SlideViewer component**: New `SlideViewer.jsx` — fetches `/api/slides?file=`, polls every 5s while conversion is pending (max 60s), renders image carousel with click-to-lightbox with full prev/next navigation in lightbox view. Download button on controls bar. Falls back to download link on error or timeout
- **File type rendering**: `MessageItem.jsx` now renders `type === 'file'` messages — PPTX files get the slide viewer, all other files get a styled download link (previously rendered nothing)
- **Server conversion pipeline**: `convertPptxToSlides()` function — creates temp dir, runs `libreoffice --headless --convert-to pdf`, then `pdftoppm -png -r 200`, stores slides in `uploads/{basename}-slides/`. Fire-and-forget after file save, emits `slides:ready` socket event to channel on completion
- **`GET /api/slides`** endpoint: Authenticated endpoint returns slide image URLs and conversion status (`ready`/`pending`)
- **File size limit**: Bumped from 10MB to 25MB to accommodate PPTX files with embedded images
- **File input**: Added `.ppt,.pptx` to accepted file types; PPTX files show 📊 icon in pending preview
- **Server deps**: Requires `libreoffice-core libreoffice-impress libreoffice-writer poppler-utils` on production server

### v2.6.0 (Feb 9, 2026) — Inhalational Agents Reference
- **Inhalational Agents tab** in Medication Calculator — new "Inhalational Agents" tab alongside existing drug categories and special mixes
- **4 agents**: Sevoflurane, Isoflurane, Desflurane, Nitrous Oxide — each as expandable reference cards with MAC (age 40), blood:gas coefficient, boiling point, and metabolism % visible at a glance
- **Physical properties**: Full property grid per agent (blood:gas, brain:blood, fat:blood, muscle:blood solubility, vapor pressure, boiling point, molecular weight, metabolized %)
- **MAC by age**: Per-agent MAC values from neonate through 80 yr (age-tagged pill layout)
- **MAC clinical levels**: MAC-Awake (0.3-0.5), 1.0 MAC, 1.3 MAC (ED95), MAC-BAR (1.7-2.0) with clinical descriptions
- **MAC factors**: Three-column reference — factors that increase MAC (red hair, young age, chronic ethanol, cocaine, hyperthermia, etc.), decrease MAC (age, pregnancy, opioids, alpha-2 agonists, hypothermia, hypoxia, etc.), and factors with no effect (gender, height/weight, thyroid, duration of anesthesia)
- **PK/PD accordion**: 8 expandable concept cards — Speed of Induction, Blood:Gas Solubility, Cardiac Output & Uptake, Concentration & Second Gas Effect, Ventilation & FRC, Tissue Groups & Distribution, Pediatric Considerations, V/Q Mismatch Effects
- **New exports in `medcalc.js`**: `INHALATIONAL_AGENTS`, `MAC_LEVELS`, `MAC_FACTORS`, `PK_CONCEPTS`

### v2.5.0 (Feb 9, 2026) — Draft Persistence, Copyright & Security Hardening
- **Chat Draft Persistence**: Typed messages survive channel switches, view changes, and PWA close/reopen. Saved to `localStorage` with 300ms debounce (`pulse_draft_ch_${channelId}`). Restored on channel join, cleared on send. 24hr auto-cleanup on app mount
- **Preop Comment Draft Persistence**: Comment drafts per chart survive card collapse/expand and app restarts (`pulse_draft_preop_${chartId}`). Same 300ms debounce, cleared on submit
- **Draft Indicators**: "Draft" label in sidebar channel list (italic, muted) for channels with unsent text. "Draft" label on collapsed preop cards with saved comment drafts
- **Copyright Headers**: Added `© 2025-2026 Adam Powell LLC` proprietary headers to `main.jsx`, `App.jsx`, and `server/index.js`
- **Source Maps**: Confirmed `sourcemap: false` in Vite config, no `.map` files in production
- **Nginx Source Hardening**: Added regex location block to both HTTP and HTTPS server blocks returning 404 for probing paths (`src/`, `server/`, `.env`, `.git`, `vite.config`, `package.json`, `tsconfig`, `node_modules`). Previously these returned 200 with the SPA index.html shell (no actual source exposed, but 200 status encouraged probing)
- **Copyright Overlay**: Fixed-position footer on every page displaying `© 2025-2026 Adam Powell LLC` proprietary notice. Subtle (0.35 opacity, 0.6rem), non-interactive (`pointer-events: none`), works in PWA and desktop browser

### v2.4.0 (Feb 9, 2026) — Sidebar & UI Cleanup
- **Sidebar Reorganization**: Merged view-switcher and utility buttons into one unified section with two rows — Nav (Chat, Preop, Admin) and Tools (Med Calc, Passkeys). Removed separate `sidebar-invite` section
- **Invite Moved to Admin Panel**: Invite button relocated from sidebar to Admin panel header where it belongs with other admin actions
- **Channel Long-Press Menu**: Replaced always-visible Rename/Delete buttons on channels with long-press context menu (500ms hold). Positioned at pointer on desktop, centered on mobile. Works with both mouse and touch
- **Duplicate Button Cleanup**: Removed duplicate Logout and Settings (status picker) from ChatView header — UserPanel handles these. Removed dead ⚙️ Settings button (no handler) and duplicate Invite from UserPanel
- **Med Calc Fix**: Removed duplicate "Special Mixes" tab caused by `mixes` entry in `DRUG_CATEGORIES` array (the real tab is hardcoded separately)
- **Handoffs Hidden**: Removed Handoffs button from sidebar nav (not in use)

### v2.3.0 (Feb 9, 2026) — Mobile UX Features (6 Features)
- **Jump to Latest Button**: Floating `↓` pill with unread count badge appears when scrolled up in chat. Smooth-scrolls to bottom on click. Uses Virtuoso `atBottomStateChange` callback
- **Long-Press Context Menu**: 500ms hold on messages opens centered overlay menu with Reply, Edit, Delete, Pin, and emoji reactions. Haptic feedback on open. Replaces always-visible action buttons on mobile (hidden via CSS at ≤768px). Desktop hover behavior unchanged
- **Swipe-to-Reply**: Right-swipe on messages (50px threshold, max 100px) triggers reply mode. Visual slide with reply arrow indicator. Haptic feedback at threshold. Uses Pointer Events with `touch-action: pan-y` to not block vertical scroll
- **Offline Message Queue**: Messages sent while disconnected are queued in state and auto-flushed on reconnect. Pending messages show with reduced opacity + clock icon. Toast notification on queue flush ("X queued messages sent")
- **Pull-to-Refresh**: Triggers channel refresh when scrolling past the top of chat history (no more older messages). Shows animated spinner in Virtuoso header
- **Swipe to Open Sidebar**: Left-edge swipe (within 25px of left edge, 80px threshold) opens sidebar on mobile. Sidebar follows finger during swipe. Right-to-left swipe closes open sidebar. Uses Pointer Events on app-container

### v2.2.1 (Feb 9, 2026) — Mobile Viewport & Layout Fixes
- **View Fade Wrapper Fix**: `.view-fade-enter` animation wrapper lacked `flex: 1; display: flex; flex-direction: column` — caused handoff, admin, and preop views to be ~97px short of viewport width on mobile
- **Sidebar Toggle Touch Target**: Bumped `.sidebar-toggle` (Menu button) from 18px height to 32px min-height with proper padding
- **Viewport Overflow Fix**: Changed `.sidebar-overlay` from `width: 100vw` to `width: 100%` (100vw includes scrollbar, causing horizontal overflow). Added `overflow-x: hidden` on html element
- **Chat Header Overflow**: Reverted icon-button to 28px at 480px (36px × 6+ buttons overflowed header). Added `max-width: 100%`, `overflow: hidden`, channel-title truncation with ellipsis
- **Handoffs Mobile Styles**: Added comprehensive 480px media query for handoffs — header, form, cards, buttons, inputs all properly sized with 44px min-height touch targets and 16px font-size on inputs (prevents iOS zoom)
- **Admin Panel Mobile Styles**: Rewrote 480px admin styles — search/filter inputs 16px font + 40px min-height, action buttons 36px min-height, user cards with proper spacing, audit log entries stacked vertically on mobile, modal dialogs 95% width
- **384px Fixes**: Bumped admin minimums (action buttons 32px min-height, user name 0.85rem, tabs 38px min-height)

### v2.2.0 (Feb 8, 2026) — GUI/UX Polish Pass (7 Phases)
- **CSS Cascade Fix**: Fixed 384px media query block with unreadable font sizes (0.48rem–0.55rem bumped to 0.68rem–0.8rem minimums). Fixed 480px block touch targets and font sizes
- **Mobile Touch Targets**: Enlarged `.icon-button` (26px → 36px), `.action-btn` (added 36px min), `.send-btn` (30px → 36px), `.format-btn` (26px → 32px) for proper mobile tap targets
- **Toast Notification System**: New `Toast.jsx` with `ToastProvider` context + `useToast` hook. Four variants (success/error/warning/info) with animated progress bar, auto-dismiss (4s), click-to-dismiss. Wired into ChatView (file upload errors) and PreopView (status changes, chart create/update/delete)
- **PHI Detection Warning**: New `phiDetect.js` utility scans for MRN (6-10 digit numbers), SSN (XXX-XX-XXXX), DOB (MM/DD/YYYY), and patient name patterns. Debounced 500ms detection on chat message input and preop comment input. Dismissible warning bar — warns but does not block sending
- **View Transitions**: CSS fade-in animation (0.2s opacity) on preop, handoffs, and admin view switches
- **Scroll Shadows**: Gradient mask-image fade indicators added to channels list, comments list, and med calc body
- **Button Feedback**: CSS `:active` scale micro-interactions on send (0.88x), comment submit (0.92x), and action buttons (0.95x)
- **New components**: `Toast.jsx`
- **New utils**: `phiDetect.js`
- **Replaced**: `window.alert()` in ChatView with `toast.error()`

### v2.1.1 (Feb 9, 2026) — Voting System, Comment Merging, Active Tab Default
- **Proceed/Hold Voting**: Users vote proceed or hold on each preop case (toggle on/off, switch votes). Vote counts displayed on both collapsed and expanded cards with voter names on hover. Swipe-to-vote on mobile. New `preop:vote` socket event + `preop_votes` table (auto-migrated via `ensurePreopVotesTable`)
- **Consecutive Comment Merging**: Same-user comments within 2 minutes auto-append to previous comment (newline-separated) instead of creating separate entries. Server emits `preop:comment:updated` for merged comments
- **Default Tab → Active**: Preop board defaults to "Active" tab instead of "Today"
- **New DB table**: `preop_votes` (chart_id, user_id, vote, UNIQUE constraint)
- **New sub-component**: `VoteButtons` (full + compact modes)

### v2.1.0 (Feb 8, 2026) — Preop Case Consultation Board Overhaul (6 Phases)
- **Case Decision Status**: 5-status workflow (Pending → Proceed / Hold / Cancelled / Completed) with `preop:status` socket event, system comments for status changes (`comment_type = 'status_change'`), push notifications to all users
- **Card Redesign**: Conversation-first layout — compact header (initials + surgeon + status badge), last comment preview on collapsed cards, real user avatars with chat colors, grouped comments (same author within 5 min)
- **Mobile Bottom Sheets**: Expanded cards render as full-screen bottom sheet overlays on mobile (≤480px) with slide-up animation, sheet close button, and backdrop
- **Swipe-to-Status**: Swipe right on collapsed cards → Proceed, swipe left → Hold (80px threshold, visual color hints during swipe)
- **Status-Themed Styles**: `data-status` attribute drives card border colors, badges, and overflow menu styling. Status badges with emoji indicators. Hold cards pulse animation
- **Tab Filtering**: Today (default), All, Active, Hold, Completed tabs with stats bar showing counts per status
- **Overflow Menu**: Three-dot menu on cards with all status actions (Proceed, Hold, Cancel, Complete, Reopen)
- **@Mentions in Comments**: MentionAutocomplete wired to preop comment inputs for @user references
- **Back Button / Escape**: History API pushState for PWA back button; Escape key closes expanded cards
- **Auto-Scroll**: Comments scroll to bottom on expand and on new comment arrival
- **Status-Aware Sorting**: Hold first, then Pending, Proceed, Completed, Cancelled
- **Mobile Typography Fix**: All preop fonts at 480px breakpoint fixed (was 0.52-0.58rem/8-9px, now minimum 0.75rem with 44px touch targets)
- **DB Migration**: `preop_charts` gains `status`, `status_changed_by`, `status_changed_at` columns; `preop_comments` gains `comment_type` column
- **Sub-components**: StatusBadge, SystemComment, CommentItem, SwipeableCard (all within PreopView.jsx)

### v2.0.5 (Feb 9, 2026) — Phase 11: Unread Separator, DnD Channels, Image Lightbox, Notification Grouping
- **Unread separator**: Slack-style red "New messages" divider between last-read and first unread message when switching channels. Server queries previous read watermark BEFORE upserting `last_read_at`, returns `lastReadAt` in `channel:join` callback. Client compares message timestamps against watermark to place separator
- **Drag-and-drop channel reorder**: Admin users can drag channels to reorder them in the sidebar using `@dnd-kit/sortable`. Replaced up/down arrow buttons with a drag handle (≡). Non-admins see plain list. Uses `PointerSensor` with 8px activation constraint to prevent accidental drags
- **Image lightbox**: Click any image or GIF in chat to view full-screen in a dark overlay. Close by clicking outside or the ✕ button. Added cursor pointer and hover opacity to images
- **Push notification grouping**: Notifications grouped by channel — instead of 5 separate push notifications, the service worker counts existing notifications with the same tag and shows "N new messages in #channel". Changed push tags from per-message (`message-${id}`) to per-channel (`channel-${id}`) with `renotify: true`
- **New dependencies**: `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`

### v2.0.4 (Feb 8, 2026) — TIVA Section in Med Calculator
- **TIVA category added**: New "TIVA" tab in Medication Calculator with 6 infusion agents for total intravenous anesthesia — Propofol Infusion (50-200 mcg/kg/min), Remifentanil Infusion (0.05-0.3 mcg/kg/min), Dexmedetomidine/Precedex (0.2-1.4 mcg/kg/hr), Ketamine TIVA Adjunct (0.1-0.5 mg/kg/hr), Lidocaine Infusion (1-2 mg/kg/hr), Magnesium Infusion (10-15 mg/kg/hr). All include standard concentrations, mixing instructions, and drip rate calculator

### v2.0.3 (Feb 8, 2026) — Wider Input Bar
- **Input bar layout**: Moved 📎 attach, GIF, and 📊 poll buttons from inside the message form to the `.media-controls-row` (right-aligned via flex spacer). The textarea now spans the full width of the input bar for better typing visibility on mobile

### v2.0.2 (Feb 7, 2026) — PTT Rewrite + Voice Recorder Layout
- **Fix: PTT walkie-talkie completely rewritten**: Root cause was `getUserMedia` being async inside press handler — tap/hold would race between start/stop. Now pre-acquires mic on enable (shows "Connecting..." until ready). Start/stop are fully synchronous. Replaced separate mouse+touch handlers with unified Pointer Events API (`onPointerDown`/`onPointerUp`/`onPointerCancel`). Added `micReady` state and `disabled` prop while acquiring. No more spam.
- **Voice recorder moved**: Moved VoiceRecorder out of the message form (was clipped off-screen on mobile) into a new `.media-controls-row` alongside the PTT button, above the format toolbar
- **Esmolol added to Med Calculator**: 1-2 mg/kg IV, beta-1 selective, for blunting SNS response to laryngoscopy

### v2.0.1 (Feb 7, 2026) — Bugfixes
- **Fix: Admin panel hidden in sidebar**: View-switcher now wraps buttons with `flex-wrap: wrap` and tighter padding so all 4 tabs (Chat, Preop Charts, Handoffs, Admin) are visible without overflow
- **Fix: PTT walkie-talkie spamming on/off**: Added ref-based `transmittingRef` guard to prevent async re-entry during `getUserMedia`, added `isTouchRef` to prevent synthetic mouse events from firing after touch events on mobile — no more rapid on/off toggling

### v2.0.0 (Feb 7, 2026) — Phase 10: Major Feature Expansion (6 Features)
- **User Profile Cards**: Click any username (in messages or online users list) to see role, specialty, status, contact info. Server auto-migrates `specialty` and `phone` columns. `users:list` returns email/specialty/phone. New `ProfileCard.jsx` component with viewport-aware positioning
- **Medication Calculator**: Full anesthesia drug reference with weight-based dose calculator. 35+ drugs across 9 categories (induction, opioids, relaxants, vasopressors, reversal, TIVA, drips, local anesthetics, special mixes). Drip rate calculator with concentration parsing. **McLott Mix** (opioid-free anesthesia) — both 20mL syringe and 100mL bag formulations. New `MedCalcPanel.jsx` + `medcalc.js` utility
- **Quick Polls with Deadlines**: Create polls in any channel with 2-6 options and optional time deadline. Real-time voting with animated progress bars. Anonymous/named votes. Creator or admin can end polls. Auto-closes at deadline. New DB tables: `polls`, `poll_votes` (auto-migrated). New `PollCard.jsx` + `CreatePollModal.jsx`
- **Voice Messages**: Record and send voice messages via MediaRecorder API (WebM/Opus). All users can send voice messages (unlike file uploads which are Adam-only). Audio player in chat with native controls. Server stores voice files in `uploads/`. New `VoiceRecorder.jsx` with recording pulse animation and duration display
- **Smart Shift Handoff**: Structured handoff forms with patients (name, room, status, notes), critical notes, and pending tasks (with priority levels). Assign to specific user or general handoff. Incoming staff can acknowledge. New sidebar view tab. New DB table: `handoffs` (auto-migrated). New `HandoffView.jsx`
- **Walkie-Talkie Push-to-Talk**: Hold-to-talk real-time audio streaming via Socket.io. Records audio chunks and relays base64 data to channel members. Shows active speaker indicator. Enable/disable toggle. Works on mobile (touch events). Server relay events: `ptt:start`, `ptt:chunk`, `ptt:stop`. New `WalkieTalkie.jsx`
- **New components**: `ProfileCard.jsx`, `MedCalcPanel.jsx`, `PollCard.jsx`, `CreatePollModal.jsx`, `VoiceRecorder.jsx`, `WalkieTalkie.jsx`, `HandoffView.jsx`
- **New utils**: `medcalc.js` (drug database, dose calculations, drip rate formulas, McLott mix recipes)
- **New DB tables**: `polls`, `poll_votes`, `handoffs` (all auto-migrated on server start)
- **Users table expanded**: `specialty` and `phone` columns (auto-migrated)
- **Socket.io maxHttpBufferSize**: Increased to 15MB for base64 audio/file uploads

### v1.9.0 (Feb 7, 2026) — Phase 8: Chat Enhancements
- **@channel/@here Mentions**: Broadcast mentions that notify all channel members (`@channel`) or online members (`@here`) — distinct amber/orange highlight, push notifications, filtered from individual user-mention lookup
- **Enhanced @Mention Autocomplete**: `@channel` and `@here` synthetic entries appear at top of autocomplete list with broadcast icon, filtered by query match (`@c` shows `@channel`, `@h` shows `@here`)
- **Pinned Messages Panel**: Right-side slide-out panel (`PinnedMessagesPanel.jsx`) — browse all pinned messages per channel, "Jump to message" scrolls Virtuoso to the pinned message, live updates on pin/unpin
- **Global Message Search**: `GET /api/search` endpoint with ILIKE across all accessible channels (respects private membership) — new `GlobalSearchPanel.jsx` right-side panel with debounced search, results grouped by channel with highlighted matches, click-to-navigate across channels
- **Thread/Reply View**: `thread:load` socket event fetches root message + all replies — new `ThreadPanel.jsx` right-side panel with chronological thread view, inline reply input, real-time sync for new replies/edits/deletes/reactions. `reply_count` subquery added to `channel:join` and `channel:history`. "View Thread (N replies)" link on messages
- **Read Receipts**: `channel_read_receipts` table (auto-migrated via `ensureReadReceiptsTable()`), per-channel watermark upserted on join + visibility change. `message:markread` and `message:readstatus` socket events. "Read by N" indicator below last message with user names on hover. `channel:readreceipts` broadcast keeps all clients in sync
- **New components**: `PinnedMessagesPanel.jsx`, `GlobalSearchPanel.jsx`, `ThreadPanel.jsx`
- **Panel mutual exclusion**: Opening any right-side panel (pinned, search, thread) closes the others

### v1.8.2 (Feb 7, 2026) — Passkey Registration Fix
- **Fix**: `generateRegistrationOptions` userID must be `Uint8Array` in `@simplewebauthn/server` v11 — was passing string UUID, now uses `new TextEncoder().encode(user.id)`

### v1.8.1 (Feb 7, 2026) — Passkey Enrollment Prompt
- **Post-login enrollment**: After password login, users without passkeys see a prompt to register one
- **Admin requirement**: Admin accounts must set up a passkey (no skip option) — enforced via full-screen overlay
- **User dismissal**: Regular users can click "Not now" — dismissal stored in localStorage, prompt won't reappear
- **New component**: `PasskeyEnrollment.jsx` — uses existing registration endpoints, success auto-closes after 1.5s

### v1.8.0 (Feb 7, 2026) — WebAuthn Passkeys
- **Passkey Login**: Sign in with biometrics, security keys, or device PIN via WebAuthn/FIDO2 — "Sign in with Passkey" button on login screen using discoverable credentials
- **Passkey Registration**: Add passkeys from sidebar "Passkeys" button — names device, stores credential_id + public_key in `passkey_credentials` table
- **Passkey Management**: Modal UI to list registered passkeys (device name, created date, last used) and remove them
- **Server**: 6 new endpoints (`/api/passkey/*`) — lazy-loads `@simplewebauthn/server` via dynamic ESM import, in-memory challenge store (5min TTL), `globalThis.crypto` polyfill for Node 18
- **Database**: New `passkey_credentials` table (credential_id, public_key, counter, transports, device_name)

### v1.7.0 (Feb 7, 2026) — Phase 5-7
- **Message Pagination**: Infinite scroll with `channel:history` socket event — loads 50 messages per page, Virtuoso `firstItemIndex` pattern for seamless prepending
- **Connection Status**: Live socket connection indicator (connected/reconnecting/disconnected) — auto-hides after 3s, retry button, shown in all views
- **Link Previews**: Server-side OG metadata scraping via cheerio — in-memory cache (1hr TTL, 500 max), SSRF protection, CSP updated for external images
- **Error Boundaries**: React error boundaries wrapping each view + entire app — class component with retry button
- **Code Splitting**: `React.lazy()` + `Suspense` for ChatView, PreopView, AdminPanel — main chunk dropped from ~164KB to ~42KB
- **Skeleton Loading**: Shimmer loading skeletons for messages, channels, and preop cards
- **Channel Mute**: Per-channel mute toggle (localStorage-backed) — muted channels dim in sidebar, suppress badges and notification sounds
- **Empty States**: Configurable empty state illustrations for channels, messages, no-channel, preop, filtered preop, and search
- **Session Lock**: HIPAA-compliant idle timeout — 15min inactivity lock with 2min warning countdown, full-screen lock overlay, password re-auth
- **Audit Log Viewer**: Admin panel tab with paginated audit log — filter by action type and date, shows user/action/details/IP
- **httpOnly Cookie Auth**: Server sets `pulse_token` httpOnly secure cookie on login — `GET /api/session` for cookie-based session restore, `POST /api/logout` to clear, all middleware checks cookie first then header fallback, socket auth parses cookie from handshake
- **Health Check**: Enhanced `GET /api/health` — returns uptime, DB ping latency, memory (RSS/heap), connected socket count
- **Message Export**: Admin can export channel messages as CSV or JSON — downloads via blob, audit-logged

### v1.6.0 (Feb 7, 2026)
- **Component Refactor**: Extracted MessageItem, FormatToolbar, MentionAutocomplete, TypingIndicator, ConfirmDialog into standalone components
- **useSocket Hook**: Centralized all Socket.io connection management and event handlers into a single custom hook
- **Virtualized Chat**: Replaced manual scroll with react-virtuoso for performant message rendering
- **DOMPurify**: Added XSS sanitization for markdown-rendered content
- **@Mention Autocomplete**: Type `@` in chat input to see matching online users
- **Format Toolbar**: Visual toolbar for bold, italic, code, strikethrough, links, lists
- **Confirm Dialog**: Reusable modal for destructive actions (delete message, delete chart, etc.)
- **Webhook Overhaul**: Auto `npm install` before build, `git reset --hard` (no merge conflicts), Cloudflare cache purge (`purge_everything`), self-update, SIGKILL restart (avoids SIGTERM hang), email notifications
- **CSS Cleanup**: Removed duplicate inline styles from index.html, eliminated `-webkit-fill-available` viewport issues, fixed mobile preop card layout constraints
- **Chat Fix**: Removed `alignToBottom` from Virtuoso (messages no longer pinned to bottom with empty space)
- **Typing Indicator**: Live "X is typing..." with animated dots — server broadcasts to channel, client auto-expires after 3s, throttled emit (2s), clears on channel switch
- **Mobile**: Preop cards unconstrained on mobile (no max-height cap), disabled hover transform on mobile

### v1.5.0 (Feb 6, 2026)
- **Real-time Fix**: Fixed socket handler re-registration bug where `currentChannel` in useEffect deps caused all handlers to teardown/rebuild on channel switch, breaking reconnect and message delivery
- **Refresh Buttons**: Added manual refresh (↻) buttons to chat and preop headers as fallback for connectivity issues
- **Badge Notifications**: App badge support via Badging API — automatic dot badges on Android from undelivered notifications, `setAppBadge`/`clearAppBadge` in window context for Desktop Chrome/Edge; iOS badge limited by platform (SW push context not supported)
- **Notification Click Routing**: Clicking a push notification now switches to the correct channel via Service Worker `postMessage` → App handler
- **Admin User Delete**: Admin can delete users (cascades messages, reactions, comments, push subscriptions, channel memberships)
- **Admin User Edit**: Admin can update user role and display color from the admin panel
- **Service Worker**: Converted to push-only — clears ALL caches on activate, no precaching or runtime caching (Workbox removed)
- **iOS PWA**: Safe area insets for iPhone 16 Pro Max, viewport-fit=cover, status bar background color fix, removed aggressive auto-reload causing login loop
- **PWA Updates**: Automatic update checks with periodic polling, `SKIP_WAITING` message to service worker
- **Mobile**: Fixed preop comment input row styles at mobile breakpoints

### v1.4.0 (Feb 6, 2026)
- **Architecture**: Split monolithic main.jsx (3400 lines) into 10 components, 1 hook, 7 utils
- **Performance**: LRU markdown parsing cache, debounced search inputs, CSS containment, will-change hints, scrollbar-gutter stability, font preloading
- **iOS PWA**: Fixed background gradient behind status bar, overscroll-behavior, input zoom prevention, touch-action manipulation
- **Chat UI**: Slack/Discord-style flat messages (no card bubbles, hover highlight, rounded-square avatars), message grouping by author (5-min window), date separators, hover timestamps, floating action bar, unified input region with focus ring, compact reply preview, flush reactions with own-reaction highlight, dynamic send button glow, textarea scroll fade
- **Preop UI**: Compact collapsed card row with initials/surgeon/type, surgery urgency color indicators (urgent/upcoming/scheduled/past), comment count badges, unread comment dot indicators with localStorage tracking, smooth expand/collapse animation
- **Mobile**: Fixed 480px breakpoint fonts (removed blanket 0.55rem), all inputs 16px minimum, haptic feedback on send/react/comment
- **PWA**: Gated nuclear SW cleanup behind DEV flag to preserve production caching

### v1.3.0 (Feb 5, 2026)
- Push notifications for iOS 16.4+/Android PWA (Web Push API + VAPID)
- Notifications for all new messages, preop charts, and comments
- Edit/delete comments on preop evaluations (own comments or admin)
- Service Worker push event handling
- Mobile audio notification sounds (Web Audio API)

### v1.2.0 (Jan 17, 2026)
- Simplified preop form (initials, surgeon, date, type)
- Discussion board comments on charts
- GIF picker with GIPHY integration
- Admin channel deletion
- Admin user management panel (disable/enable, password reset)

### v1.1.0 (Jan 17, 2026)
- Production deployment
- Invite-only registration
- Strong password requirements
- HIPAA audit logging

### v1.0.0 (Jan 2025)
- Initial release

---

**Made with care by Adam Powell, CRNA / Adam Powell LLC**
