Files
rosterchirp-dev/CLAUDE.md

363 lines
14 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.
# JAMA — Claude Code Development Context
## What is JAMA?
**jama** (just another messaging app) 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.25
---
## 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
```
jama/
├── 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 ← JAMA-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.25 → 0.11.26), 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.25; NEW=0.11.26
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: `jama.zip` at `/mnt/user-data/outputs/jama.zip`
Always exclude:
```bash
zip -qr jama.zip jama \
--exclude "jama/README.md" \
--exclude "jama/data/help.md" \
--exclude "jama/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`. |
JAMA-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)
---
## 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` | `'JAMA-Chat'`/`'JAMA-Brand'`/`'JAMA-Team'` | — |
JAMA-HOST always forces `JAMA-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:** Deferred. Web Push with VAPID doesn't survive Android Doze mode.
**Fix plan:** Integrate Firebase Cloud Messaging (FCM).
1. Create Firebase project (free tier)
2. Add Firebase config to `.env` and `sw.js`
3. Replace `web-push` subscription flow with Firebase SDK
4. Switch backend dispatch from `web-push` to `firebase-admin`
5. WebSocket reconnect-on-focus (frontend only, no Firebase needed)
### 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=jama
DB_USER=jama
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose)
ADMIN_EMAIL=
ADMIN_NAME=
ADMIN_PASS=
ADMPW_RESET=true|false
APP_NAME=jama
USER_PASS= # default password for bulk-created users
DEFCHAT_NAME=General Chat
JAMA_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
```
---
## Deployment
```bash
# Production: Ubuntu 22.04, Docker Compose v2
# Directory: /home/rick/jama/
./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
Previous development was conducted via Claude.ai web interface (sessions summarised in this document). Development continues in Claude Code from v0.11.25.