claude code instructions
This commit is contained in:
362
CLAUDE.md
Normal file
362
CLAUDE.md
Normal file
@@ -0,0 +1,362 @@
|
||||
# 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/ ← 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 ← 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.
|
||||
Reference in New Issue
Block a user