Files
rosterchirp-dev/CLAUDE.md

458 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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/ ← 001006 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<socketId>
```
**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`
---
## FCM Push Notifications
**Status:** Working on Android (v0.12.26+). iOS in progress.
### Overview
Push notifications use Firebase Cloud Messaging (FCM) — not the older web-push/VAPID approach. VAPID env vars are still present (auto-generated on first start) but are no longer used for push delivery.
### Firebase Project Setup
1. Create a Firebase project at console.firebase.google.com
2. Add a **Web app** to the project → copy the web app config values into `.env`
3. In Project Settings → Cloud Messaging → **Web Push certificates** → generate a key pair → copy the public key as `FIREBASE_VAPID_KEY`
4. In Project Settings → Service accounts → Generate new private key → download JSON → stringify it (remove all newlines) → set as `FIREBASE_SERVICE_ACCOUNT` in `.env`
Required `.env` vars:
```
FIREBASE_API_KEY=
FIREBASE_PROJECT_ID=
FIREBASE_APP_ID=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_VAPID_KEY= # Web Push certificate public key (from Cloud Messaging tab)
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
```
### Architecture
```
Frontend (browser/PWA)
└─ usePushNotifications hook (Chat.jsx or dedicated hook)
├─ GET /api/push/firebase-config → fetches SDK config from backend
├─ Initialises Firebase JS SDK + getMessaging()
├─ getToken(messaging, { vapidKey }) → obtains FCM token
└─ POST /api/push/subscribe → registers token in push_subscriptions table
Backend (push.js)
├─ sendPushToUser(schema, userId, payload) — shared helper, called from:
│ ├─ messages.js (REST POST route — PRIMARY message path)
│ └─ index.js (socket message:send handler — secondary/fallback)
└─ Firebase Admin SDK sends the FCM message to Google's servers → device
```
### Database
Table `push_subscriptions` (migration 007):
```sql
id, user_id, device ('mobile'|'desktop'), fcm_token, created_at
```
PK is `(user_id, device)` — one token per device type per user. `/api/push/subscribe` deletes the old row then inserts, so tokens stay fresh.
### Message Payload Structure
All real messages use `notification + data`:
```js
{
token: sub.fcm_token,
notification: { title, body }, // FCM shows this even if SW fails
data: { url: '/', groupId: '42' }, // SW uses for click routing
android: { priority: 'high', notification: { sound: 'default' } },
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
}
```
### Service Worker (sw.js)
`onBackgroundMessage` fires when the PWA is backgrounded/closed. Shows the notification and stores `groupId` for click routing. When the user taps the notification, the SW's `notificationclick` handler navigates to the app.
### Push Trigger Logic (messages.js)
**Critical:** The frontend sends messages via `POST /api/messages/group/:groupId` (REST), not via the socket `message:send` event. Push notifications **must** be fired from `messages.js`, not just from the socket handler in `index.js`.
- **Private group:** query `group_members`, skip sender, call `sendPushToUser` for each member
- **Public group:** query `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`, call `sendPushToUser` for each
- Image messages use body `'📷 Image'`
- The socket handler in `index.js` has identical logic for any future socket-path senders
### Debug & Test Endpoints
```
GET /api/push/debug # admin only — lists all FCM tokens for this schema + firebase status
POST /api/push/test # sends test push to own device
POST /api/push/test?mode=browser # webpush-only test (Chrome handles directly, no SW involved)
```
Use `/debug` to confirm tokens are registered. Use `/test` to verify end-to-end delivery independently of real message flow.
### Stale Token Cleanup
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for codes:
- `messaging/registration-token-not-registered`
- `messaging/invalid-registration-token`
- `messaging/invalid-argument`
---
## Outstanding / Deferred Work
### iOS Push Notifications
**Status:** In progress. Android working (v0.12.26+). iOS PWA push requires additional handling — investigation ongoing.
### 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).