# RosterChirp — Claude Code Development Context ## What is RosterChirp? **RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments. **Current version:** 0.11.26 --- ## Stack | Layer | Technology | |---|---| | Backend | Node.js + Express + Socket.io | | Frontend | React + Vite (PWA) | | Database | PostgreSQL 16 via `pg` npm package | | Deployment | Docker Compose v2, Caddy reverse proxy (SSL) | | Auth | JWT (cookie + localStorage), bcryptjs | --- ## Repository Layout ``` rosterchirp/ ├── CLAUDE.md ← this file ├── KNOWN_LIMITATIONS.md ├── Dockerfile ├── build.sh ├── docker-compose.yaml ├── docker-compose.host.yaml ├── Caddyfile.example ├── .env.example ├── about.json.example ├── backend/ │ ├── package.json ← version bump required │ └── src/ │ ├── index.js ← Express app, Socket.io, tenant middleware wiring │ ├── middleware/ │ │ └── auth.js ← JWT auth, teamManagerMiddleware │ ├── models/ │ │ ├── db.js ← Postgres pool, query helpers, migrations, seeding │ │ └── migrations/ ← 001–006 SQL files, auto-applied on startup │ ├── routes/ │ │ ├── auth.js │ │ ├── groups.js ← receives io │ │ ├── messages.js ← receives io │ │ ├── usergroups.js ← receives io │ │ ├── schedule.js ← receives io (as of v0.11.14) │ │ ├── users.js │ │ ├── settings.js │ │ ├── push.js │ │ ├── host.js ← RosterChirp-Host control plane only │ │ ├── about.js │ │ └── help.js │ └── utils/ │ └── linkPreview.js └── frontend/ ├── package.json ← version bump required ├── vite.config.js ├── index.html ├── public/ │ ├── manifest.json │ ├── sw.js ← service worker / push │ └── icons/ └── src/ ├── App.jsx ├── main.jsx ├── index.css ├── contexts/ │ ├── AuthContext.jsx │ ├── SocketContext.jsx │ └── ToastContext.jsx ├── pages/ │ ├── Chat.jsx ← main shell, page routing, all socket wiring │ ├── Login.jsx │ ├── ChangePassword.jsx │ ├── UserManagerPage.jsx │ ├── GroupManagerPage.jsx │ └── HostAdmin.jsx ← DEAD CODE (safe to delete) ├── components/ │ ├── Sidebar.jsx ← conversation list, groupMessagesMode prop │ ├── ChatWindow.jsx ← message thread + header │ ├── MessageInput.jsx ← free-text compose, onTextChange prop │ ├── Message.jsx ← single message renderer │ ├── NavDrawer.jsx ← hamburger menu │ ├── SchedulePage.jsx ← full schedule (~1600 lines, desktop+mobile views) │ ├── MobileEventForm.jsx← mobile event create/edit │ ├── Avatar.jsx ← avatar with consistent colour algorithm │ ├── PasswordInput.jsx ← reusable show/hide password input │ ├── GroupInfoModal.jsx │ ├── ProfileModal.jsx │ ├── SettingsModal.jsx │ ├── BrandingModal.jsx │ ├── HostPanel.jsx │ ├── NewChatModal.jsx │ ├── UserFooter.jsx │ ├── GlobalBar.jsx │ └── [others] └── utils/ └── api.js ← all API calls + parseTS helper ``` --- ## Version Bump — Files to Update When bumping the version (e.g. 0.11.26 → 0.11.27), update **all three**: ``` backend/package.json "version": "X.Y.Z" frontend/package.json "version": "X.Y.Z" build.sh VERSION="${1:-X.Y.Z}" ``` One-liner: ```bash OLD=0.11.26; NEW=0.11.27 sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh ``` The `.env.example` has no version field. There is no fourth location. --- ## Output ZIP When packaging for delivery: `rosterchirp.zip` at `/mnt/user-data/outputs/rosterchirp.zip` Always exclude: ```bash zip -qr rosterchirp.zip rosterchirp \ --exclude "rosterchirp/README.md" \ --exclude "rosterchirp/data/help.md" \ --exclude "rosterchirp/backend/src/data/help.md" ``` --- ## Application Modes (APP_TYPE in .env) | Mode | Description | |---|---| | `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. | | `host` | Multi-tenant — one schema per tenant. Requires `HOST_DOMAIN` and `HOST_ADMIN_KEY`. | RosterChirp-Host tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`. --- ## Database Architecture - **Pool:** `db.js` — `query(schema, sql, params)`, `queryOne`, `queryResult`, `exec`, `withTransaction` - **Schema resolution:** `tenantMiddleware` sets `req.schema` from the HTTP `Host` header before any route runs. `assertSafeSchema()` validates all schema names against `[a-z_][a-z0-9_]*`. - **Migrations:** Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.** - **Seeding:** `seedSettings → seedEventTypes → seedAdmin → seedUserGroups` on startup. All use `ON CONFLICT DO NOTHING`. - **Current migrations:** 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand) --- ## Socket Room Naming (tenant-isolated) All socket rooms are prefixed with the tenant schema to prevent cross-tenant leakage: ```js const R = (schema, type, id) => `${schema}:${type}:${id}`; // e.g. R('tenant_acme', 'group', 42) → 'tenant_acme:group:42' ``` Room types: `group:{id}`, `user:{id}`, `schema:all` (tenant-wide broadcast). Routes that emit socket events receive `io` as a function argument: - `auth.js(io)`, `groups.js(io)`, `messages.js(io)`, `usergroups.js(io)`, `schedule.js(io)` --- ## Online User Tracking ```js const onlineUsers = new Map(); // `${schema}:${userId}` → Set ``` **Critical:** The map key is `${schema}:${userId}` — not bare `userId`. Integer IDs are per-schema, so two tenants can have the same user ID. Without the schema prefix, push notifications and online presence would leak across tenants. --- ## Active Sessions Table: `active_sessions(user_id, device, token, ua)` — PK `(user_id, device)` Device classes: `mobile` | `desktop` (from user-agent). One session per device type per user — logging in on the same device type displaces the previous session (socket receives `session:displaced`). --- ## Feature Flags & Plans Stored in `settings` table per schema: | Key | Values | Plan | |---|---|---| | `feature_branding` | `'true'`/`'false'` | Brand+ | | `feature_group_manager` | `'true'`/`'false'` | Team | | `feature_schedule_manager` | `'true'`/`'false'` | Team | | `app_type` | `'RosterChirp-Chat'`/`'RosterChirp-Brand'`/`'RosterChirp-Team'` | — | RosterChirp-Host always forces `RosterChirp-Team` on the public schema at startup. --- ## Avatar Colour Algorithm **Must be consistent across all three locations** — `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`: ```js const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7']; const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length]; ``` If you add a new surface that renders user avatars without a custom photo, use this exact algorithm. --- ## Key Frontend Patterns ### Page Navigation (Chat.jsx) `page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'` **Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)` to clear the selected conversation and reset the unsaved-text guard. ### Group Messages vs Messages (Sidebar) - `groupMessagesMode={false}` → shows public groups + non-managed private groups (PRIVATE MESSAGES section) - `groupMessagesMode={true}` → shows only `is_managed` private groups (PRIVATE GROUP MESSAGES section) - New chats always go to the Messages view; creating from Group Messages switches `setPage('chat')` ### Unsaved Text Guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx) - `MessageInput` fires `onTextChange(val)` on every keystroke and after send - `ChatWindow` converts to boolean via `onHasTextChange?.(!!val.trim())` - `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if true and switching conversations - `MessageInput` resets all state (text, image, link preview) on `group?.id` change via `useEffect` ### Date/Time Utilities Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of: - `roundUpToHalfHour()` — default start time for new events - `parseTypedTime(raw)` — parses free-text time entry - `fmt12(val)` — formats HH:MM as 12-hour display - `toTimeIn(iso)` — extracts exact HH:MM from ISO (no rounding) - `buildISO(date, time)` — builds timezone-aware ISO string for Postgres `TimeInput` (desktop) and `TimeInputMobile` (mobile) are in-file components — free-text input with 5-slot scrollable dropdown showing only :00/:30 slots. --- ## User Deletion Behaviour Deleting a user (v0.11.11+): 1. Email scrubbed to `deleted_{id}@deleted` — frees the address immediately 2. Name → `'Deleted User'`, display_name/avatar/about_me nulled, password cleared 3. All their messages set `is_deleted=TRUE, content=NULL, image_url=NULL` 4. Direct messages they were part of set `is_readonly=TRUE` 5. Group memberships, sessions, push subscriptions, notifications, event availability purged Migration 006 back-fills this for pre-v0.11.11 deleted users. Suspended users: sessions killed, login blocked, but all data intact and reversible. --- ## Notification Rules (Group Member Changes) Handled in `usergroups.js` when Group Manager saves a user group's member list: - **1 user added/removed** → named system message: `"{Name} has joined/been removed from the conversation."` - **2+ users added/removed** → single generic message: `"N new members have joined/been removed from the conversation."` Single-user add/remove via `groups.js` (GroupInfoModal) always uses the named message. --- ## Schedule / Event Rules - **Date/time storage:** `TIMESTAMPTZ` in Postgres. All ISO strings from frontend must include timezone offset via `buildISO(date, time)`. - **toTimeIn** preserves exact minutes (no half-hour snapping) for edit forms. - **Default start time for new events:** `roundUpToHalfHour()` — current time rounded up to next :00 or :30. - **Past start time rule:** New events (not edits) cannot have a start date/time in the past. - **Recurring events:** `expandRecurringEvent` returns only occurrences within the requested range — never the raw original event as a fallback. Past occurrences are not shown. - **Keyword filter:** Unquoted terms use `\bterm` (word-boundary prefix — `mount` matches `mountain`). Quoted terms use `\bterm\b` (exact whole-word — `"mount"` does not match `mountain`). - **Type filter:** Does not shift the date window to today-onwards (unlike keyword/availability filters). Shows all matching events in the current month including past ones (greyed). - **Clearing keyword:** Also resets `filterFromDate` so the view returns to the normal full-month display. --- ## Dead Code (safe to delete) - `frontend/src/pages/HostAdmin.jsx` — replaced by `HostPanel.jsx` - `frontend/src/components/UserManagerModal.jsx` - `frontend/src/components/GroupManagerModal.jsx` - `frontend/src/components/MobileGroupManager.jsx` --- ## Outstanding / Deferred Work ### Android Background Push (KNOWN_LIMITATIONS.md) **Status:** Implemented (v0.11.26+). Replaced web-push/VAPID with Firebase Cloud Messaging (FCM). Requires Firebase project setup — see .env.example for required env vars and sw.js for the SW config block. ### WebSocket Reconnect on Focus **Status:** Deferred. Socket drops when Android PWA is backgrounded. **Fix:** Frontend-only — listen for `visibilitychange` in `SocketContext.jsx`, reconnect socket when `document.visibilityState === 'visible'`. --- ## Environment Variables (.env.example) Key variables: ``` APP_TYPE=selfhost|host HOST_DOMAIN= # host mode only HOST_ADMIN_KEY= # host mode only JWT_SECRET= DB_HOST=db DB_NAME=rosterchirp DB_USER=rosterchirp DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose) ADMIN_EMAIL= ADMIN_NAME= ADMIN_PASS= ADMPW_RESET=true|false APP_NAME=rosterchirp USER_PASS= # default password for bulk-created users DEFCHAT_NAME=General Chat ROSTERCHIRP_VERSION= # injected by build.sh into Docker image VAPID_PUBLIC= # auto-generated on first start if not set VAPID_PRIVATE= # auto-generated on first start if not set FIREBASE_API_KEY= # FCM web app config FIREBASE_PROJECT_ID= # FCM web app config FIREBASE_MESSAGING_SENDER_ID= # FCM web app config FIREBASE_APP_ID= # FCM web app config FIREBASE_VAPID_KEY= # FCM Web Push certificate public key FIREBASE_SERVICE_ACCOUNT= # FCM service account JSON (stringified, backend only) ``` --- ## Deployment ```bash # Production: Ubuntu 22.04, Docker Compose v2 # Directory: /home/rick/rosterchirp/ ./build.sh # builds Docker image docker compose up -d # starts all services ``` Build sequence: `build.sh` → Docker build → `npm run build` (Vite) → `docker compose up -d` --- ## Session History Development continues in Claude Code from v0.11.26 (rebranded from jama to RosterChirp).