363 lines
14 KiB
Markdown
363 lines
14 KiB
Markdown
# 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<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`
|
||
|
||
---
|
||
|
||
## 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).
|