14 KiB
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:
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:
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:
tenantMiddlewaresetsreq.schemafrom the HTTPHostheader before any route runs.assertSafeSchema()validates all schema names against[a-z_][a-z0-9_]*. - Migrations: Auto-run on startup via
runMigrations(schema). Files inmigrations/applied in order, tracked inschema_migrationstable per schema. Never edit an applied migration — add a new numbered file. - Seeding:
seedSettings → seedEventTypes → seedAdmin → seedUserGroupson startup. All useON 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:
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
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:
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 onlyis_managedprivate 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)
MessageInputfiresonTextChange(val)on every keystroke and after sendChatWindowconverts to boolean viaonHasTextChange?.(!!val.trim())Chat.jsxstores aschatHasText;selectGroup()showswindow.confirmif true and switching conversationsMessageInputresets all state (text, image, link preview) ongroup?.idchange viauseEffect
Date/Time Utilities
Both SchedulePage.jsx and MobileEventForm.jsx maintain their own copies of:
roundUpToHalfHour()— default start time for new eventsparseTypedTime(raw)— parses free-text time entryfmt12(val)— formats HH:MM as 12-hour displaytoTimeIn(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+):
- Email scrubbed to
deleted_{id}@deleted— frees the address immediately - Name →
'Deleted User', display_name/avatar/about_me nulled, password cleared - All their messages set
is_deleted=TRUE, content=NULL, image_url=NULL - Direct messages they were part of set
is_readonly=TRUE - 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:
TIMESTAMPTZin Postgres. All ISO strings from frontend must include timezone offset viabuildISO(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:
expandRecurringEventreturns 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 —mountmatchesmountain). Quoted terms use\bterm\b(exact whole-word —"mount"does not matchmountain). - 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
filterFromDateso the view returns to the normal full-month display.
Dead Code (safe to delete)
frontend/src/pages/HostAdmin.jsx— replaced byHostPanel.jsxfrontend/src/components/UserManagerModal.jsxfrontend/src/components/GroupManagerModal.jsxfrontend/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).
- Create Firebase project (free tier)
- Add Firebase config to
.envandsw.js - Replace
web-pushsubscription flow with Firebase SDK - Switch backend dispatch from
web-pushtofirebase-admin - 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
# 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.