Files
rosterchirp-dev/CLAUDE.md

14 KiB
Raw Blame History

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:

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.jsquery(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:

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 locationsAvatar.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 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

# 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.