v.0.13.1 fixed minor UI issues, updated rules (bumped from v0.12.53)

This commit is contained in:
2026-04-07 11:27:26 -04:00
parent dbea35abe2
commit c9d6a4d9d4
9 changed files with 903 additions and 742 deletions

View File

@@ -1,729 +0,0 @@
# jama — Complete Vibe-Code Build Prompt (v0.1.0)
> **How to use this document**
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document was reverse-engineered from the real build history of jama across ~9 sessions.
---
## Part 1 — What to Build (Product Brief)
Build a **self-hosted team chat Progressive Web App** called **jama**.
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. There is no cloud dependency — everything (database, uploads, API, frontend) lives in one Docker image.
### Core philosophy
- Simple to self-host (one `docker compose up`)
- No external services required (no Firebase, no Pusher, no S3)
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
- Instant real-time messaging via WebSockets (Socket.io)
- Push notifications via Web Push (VAPID), works when app is backgrounded
---
## Part 2 — Tech Stack
| Layer | Technology |
|---|---|
| Backend runtime | Node.js 20 (Alpine) |
| Backend framework | Express.js |
| Real-time | Socket.io (server + client) |
| Database | SQLite via `better-sqlite3` |
| Auth | JWT in HTTP-only cookies + `jsonwebtoken` |
| Password hashing | `bcryptjs` |
| Push notifications | Web Push (`web-push` npm package, VAPID) |
| Image processing | `sharp` (avatar/logo resizing) |
| Frontend framework | React 18 + Vite |
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
| Frontend fonts | Google Sans + Roboto (via Google Fonts CDN) |
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
| Markdown rendering | `marked` (for help modal) |
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
| Orchestration | `docker-compose.yaml` with named volumes |
### Key npm packages (backend)
```
express, socket.io, better-sqlite3, bcryptjs, jsonwebtoken,
cookie-parser, cors, multer, sharp, web-push
```
### Key npm packages (frontend)
```
react, react-dom, vite, socket.io-client, @emoji-mart/react,
@emoji-mart/data, marked
```
---
## Part 3 — Project File Structure
```
jama/
├── Dockerfile # Multi-stage: Vite build → Node runtime
├── docker-compose.yaml # Single service, two named volumes
├── build.sh # Docker build + optional push script
├── .env.example # All configurable env vars documented
├── about.json.example # Optional About modal customisation
├── data/ # Gitignored — runtime DB lives in Docker volume
├── backend/
│ ├── package.json
│ └── src/
│ ├── index.js # Express app, Socket.io server, all socket events
│ ├── middleware/
│ │ └── auth.js # JWT cookie auth middleware — exports { authMiddleware }
│ ├── models/
│ │ └── db.js # Schema init, migrations, seed functions
│ ├── routes/
│ │ ├── auth.js # Login, logout, register, change password
│ │ ├── users.js # User CRUD, avatar upload, profile update
│ │ ├── groups.js # Group CRUD, members, direct messages
│ │ ├── messages.js # Message history, image upload
│ │ ├── settings.js # App settings (name, logo, icons)
│ │ ├── push.js # VAPID keys, subscription save, send helper
│ │ ├── about.js # Serves about.json for About modal
│ │ └── help.js # Serves help.md for Getting Started modal
│ ├── utils/
│ │ └── linkPreview.js # Fetches og: meta for URL previews
│ └── data/
│ └── help.md # Help content (NOT in /app/data volume)
└── frontend/
├── package.json
├── vite.config.js
├── index.html
└── src/
├── main.jsx
├── App.jsx
├── index.css # Global styles, CSS variables, dark mode
├── contexts/
│ ├── AuthContext.jsx # User state, login/logout
│ ├── SocketContext.jsx # Socket.io connection, reconnect logic
│ └── ToastContext.jsx # Global toast notifications
├── pages/
│ ├── Login.jsx
│ ├── Login.css
│ ├── Chat.jsx # Main app shell — groups state, socket events
│ ├── Chat.css
│ └── ChangePassword.jsx
├── components/
│ ├── Sidebar.jsx # Group/DM list, unread badges
│ ├── Sidebar.css
│ ├── ChatWindow.jsx # Message list, typing indicator, header
│ ├── ChatWindow.css
│ ├── Message.jsx # Individual message — reactions, reply, delete, link preview
│ ├── Message.css
│ ├── MessageInput.jsx # Text input, image upload, @mention, emoji
│ ├── MessageInput.css
│ ├── Avatar.jsx # Circular avatar with initials fallback
│ ├── GlobalBar.jsx # Top bar (mobile only)
│ ├── GroupInfoModal.jsx # Group details, members, custom name
│ ├── ProfileModal.jsx # User profile, display name, avatar, about
│ ├── SettingsModal.jsx # Admin: app name, logo, icons, VAPID, users
│ ├── UserManagerModal.jsx # Admin: user list, create, suspend, reset pw
│ ├── NewChatModal.jsx # Create group or start DM
│ ├── HelpModal.jsx # Getting Started modal (renders help.md)
│ ├── AboutModal.jsx # About this app modal
│ ├── SupportModal.jsx # Contact support (sends message to Support group)
│ ├── ImageLightbox.jsx # Full-screen image viewer
│ └── UserProfilePopup.jsx # Click avatar → see profile, start DM
└── utils/
└── api.js # All fetch calls to /api/*, parseTS helper
└── public/
├── sw.js # Service worker: cache, push, badge
├── manifest.json # Static fallback (dynamic manifest served by Express)
├── favicon.ico
└── icons/ # PWA icons: 192, 192-maskable, 512, 512-maskable, jama.png, logo-64.png
```
---
## Part 4 — Database Schema
Use SQLite with WAL mode and foreign keys enabled.
```sql
-- Core tables (CREATE TABLE IF NOT EXISTS for all)
users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- login/real name
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL, -- bcrypt hash
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
is_default_admin INTEGER DEFAULT 0,
must_change_password INTEGER DEFAULT 1,
avatar TEXT, -- relative URL e.g. /uploads/avatars/x.webp
about_me TEXT,
display_name TEXT, -- user-set public display name (nullable)
hide_admin_tag INTEGER DEFAULT 0,
last_online TEXT, -- datetime('now') string
help_dismissed INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT DEFAULT 'public', -- 'public' | 'private'
owner_id INTEGER REFERENCES users(id),
is_default INTEGER DEFAULT 0, -- 1 = General Chat (cannot be deleted)
is_readonly INTEGER DEFAULT 0,
is_direct INTEGER DEFAULT 0, -- 1 = user-to-user DM
direct_peer1_id INTEGER, -- DM: first user ID
direct_peer2_id INTEGER, -- DM: second user ID
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
group_members (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TEXT DEFAULT (datetime('now')),
UNIQUE(group_id, user_id)
)
messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
type TEXT DEFAULT 'text', -- 'text' | 'system'
image_url TEXT,
reply_to_id INTEGER REFERENCES messages(id),
is_deleted INTEGER DEFAULT 0, -- soft delete
link_preview TEXT, -- JSON string of og: meta
created_at TEXT DEFAULT (datetime('now'))
)
reactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(message_id, user_id, emoji) -- one reaction type per user per message
)
notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
)
active_sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT NOT NULL DEFAULT 'desktop', -- 'desktop' | 'mobile'
token TEXT NOT NULL,
ua TEXT,
created_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (user_id, device)
)
push_subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
endpoint TEXT NOT NULL,
p256dh TEXT NOT NULL,
auth TEXT NOT NULL,
device TEXT DEFAULT 'desktop',
created_at TEXT DEFAULT (datetime('now')),
UNIQUE(user_id, device)
)
settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
)
-- Default settings keys: app_name, logo_url, pw_reset_active, icon_newchat,
-- icon_groupinfo, pwa_icon_192, pwa_icon_512, vapid_public, vapid_private
user_group_names (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
)
-- Allows each user to set a personal display name for any group/DM
```
### Migration pattern
All schema changes must be additive `ALTER TABLE` statements wrapped in try/catch (column-already-exists errors are ignored). Never drop or recreate tables in migrations — this is a live production DB.
```javascript
try {
db.exec("ALTER TABLE users ADD COLUMN last_online TEXT");
} catch (e) { /* already exists */ }
```
---
## Part 5 — Auth & Session System
- JWT stored in **HTTP-only cookie** (`token`), 30-day expiry
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
- **Per-device sessions**: one session per `(user_id, device)` pair — logging in on mobile doesn't kick out desktop
- Device is detected from User-Agent: mobile if `/Mobile|Android|iPhone/i.test(ua)`
- `active_sessions` table stores current tokens for invalidation on logout/suspend
- `must_change_password = 1` redirects to `/change-password` after login
- Admin can reset any user's password; `ADMPW_RESET=true` env var resets default admin password on container start
---
## Part 6 — Real-time Architecture (Socket.io)
### Connection
- Socket auth: JWT token in `socket.handshake.auth.token`
- On connect: user joins `group:{id}` rooms for all their groups, and `user:{id}` for direct notifications
- `onlineUsers` Map: `userId → Set<socketId>` (multiple tabs/devices)
### Socket events (server → client)
| Event | Payload | When |
|---|---|---|
| `message:new` | full message object with reactions | new message in a group |
| `message:deleted` | `{ messageId, groupId }` | message soft-deleted |
| `reaction:updated` | `{ messageId, reactions[] }` | reaction toggled |
| `typing:start` | `{ userId, groupId, user }` | user started typing |
| `typing:stop` | `{ userId, groupId }` | user stopped typing |
| `notification:new` | `{ type, groupId, fromUser }` | mention or private message |
| `group:updated` | group object | group renamed, settings changed |
| `group:removed` | `{ groupId }` | user removed from group |
### Socket events (client → server)
| Event | Payload |
|---|---|
| `message:send` | `{ groupId, content, replyToId, imageUrl }` |
| `message:delete` | `{ messageId }` |
| `reaction:toggle` | `{ messageId, emoji }` |
| `typing:start` | `{ groupId }` |
| `typing:stop` | `{ groupId }` |
| `group:join-room` | `{ groupId }` |
| `group:leave-room` | `{ groupId }` |
### Reconnect strategy (SocketContext.jsx)
```javascript
// Aggressive reconnect for mobile PWA (which drops connections when backgrounded)
reconnectionDelay: 500,
reconnectionDelayMax: 3000,
timeout: 8000
// Also: on visibilitychange → visible, call socket.connect() if disconnected
// Also: on socket 'connect' event, reload groups (catches missed messages)
```
---
## Part 7 — Push Notifications (Web Push / VAPID)
- Generate VAPID keys once (stored in `settings` table as `vapid_public` / `vapid_private`)
- Endpoint: `POST /api/push/subscribe` — saves subscription per `(user_id, device)`
- Push is sent when the target user has **no active socket connections** (truly offline)
- Push payload: `{ title, body, url, groupId }`
- `groupId` in payload enables per-conversation notification grouping via `tag` in `showNotification`
- Service worker skips notification if app window is currently visible (already open)
- Re-register push subscription on every `visibilitychange → visible` event (mobile browsers drop subscriptions when PWA is backgrounded)
- `navigator.setAppBadge(count)` updates home screen badge (Chrome/Edge/Android — not iOS)
---
## Part 8 — API Routes
All routes require `authMiddleware` except login/register/health.
### Auth (`/api/auth`)
- `POST /login` — returns JWT cookie, user object
- `POST /logout` — clears cookie, removes active_session
- `POST /register` — admin only
- `POST /change-password`
- `GET /me` — returns current user from JWT
### Users (`/api/users`)
- `GET /` — admin only: full user list with last_online
- `POST /` — admin: create user
- `PATCH /:id` — admin: update role/status/password
- `PATCH /me/profile` — update own display_name, about_me, hide_admin_tag
- `POST /me/avatar` — multipart: upload + sharp-resize to 200×200 webp
- `GET /check-display-name?name=` — returns `{ taken: bool }`
### Groups (`/api/groups`)
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
- `POST /` — create group or start DM (checks for existing DM between pair)
- `PATCH /:id` — rename group
- `DELETE /:id` — delete group (admin only, not default group)
- `POST /:id/members` — add member
- `DELETE /:id/members/:userId` — remove member
- `GET /:id/members` — list members
- `POST /:id/custom-name` — set per-user custom group name
- `DELETE /:id/custom-name` — remove custom name
### Messages (`/api/messages`)
- `GET /?groupId=&before=&limit=` — paginated message history (50 per page)
- `POST /image` — multipart image upload, returns `{ url }`
### Settings (`/api/settings`)
- `GET /` — returns all settings (public: app_name, logo_url; admin: everything)
- `PATCH /` — admin: update settings
- `POST /logo` — admin: upload logo
- `POST /icon/:key` — admin: upload PWA icon
### Push (`/api/push`)
- `GET /vapid-public` — returns VAPID public key (unauthenticated)
- `POST /subscribe` — save push subscription
### Help (`/api/help`)
- `GET /` — returns rendered help.md content
- `GET /status` — returns `{ dismissed: bool }` for current user
- `POST /dismiss` — set help_dismissed flag
---
## Part 9 — Frontend Architecture
### State management
No Redux. Three React contexts:
1. **AuthContext**`user`, `login()`, `logout()`, `updateUser()`
2. **SocketContext**`socket` instance, handles connection lifecycle
3. **ToastContext**`toast(message, type)` for notifications
### Chat.jsx (main shell)
- Holds `groups` state: `{ publicGroups[], privateGroups[] }`
- Handles all Socket.io group-level events and dispatches to child components
- On `message:new`: updates `last_message`, `last_message_at`, `last_message_user_id`, re-sorts private groups newest-first
- Tracks `unreadGroups: Map<groupId, count>` — increments on new messages in non-active groups (or active group when window is hidden)
- Manages `activeGroupId`, `notifications[]`, PWA badge count
- Re-registers push on `visibilitychange → visible`
- Shows HelpModal if user hasn't dismissed it
### Sidebar.jsx
- Renders public groups (fixed order: default first, then alphabetical)
- Renders private groups (sorted newest-message first)
- DM entries show peer's avatar (if set), `Display Name (real name)` format when peer has a display name
- Unread badge counts (blue dot for unread, numbered badge for @mentions)
- Page title format: `(N) App Name` where N is total unread count
### ChatWindow.jsx
- Renders message list with infinite scroll upward (load older messages on scroll to top)
- Typing indicator with bouncing dots animation
- Header: for DMs shows peer avatar + `Display Name (real name)` title
- Marks messages as read when group becomes active
### Message.jsx features
- Date separators between days
- Consecutive messages from same user collapse avatar (show once)
- Emoji-only messages render larger
- Quick reactions bar (👍❤️😂😮😢🙏) + full emoji picker
- Reply-to preview with quoted content
- Link preview cards (og: meta, fetched server-side to avoid CORS)
- Image messages with lightbox on click
- @mention highlighting
- Delete button (own messages + admin/owner can delete any)
- Long-press on mobile = show action menu
### MessageInput.jsx
- Auto-expanding textarea
- `@` triggers mention picker (searches group members)
- Image attach button (uploads immediately, inserts URL into content)
- Emoji picker button
- `Enter` to send, `Shift+Enter` for newline
- Typing events: emit `typing:start` on input, `typing:stop` after 2s idle
---
## Part 10 — CSS Design System
### Variables (`:root` — day mode)
```css
--primary: #1a73e8;
--primary-dark: #1557b0;
--primary-light: #e8f0fe;
--surface: #ffffff;
--surface-variant: #f8f9fa;
--background: #f1f3f4;
--border: #e0e0e0;
--text-primary: #202124;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--error: #d93025;
--success: #188038;
--bubble-out: #1a73e8; /* own message bubble */
--bubble-in: #f1f3f4; /* other's message bubble */
--radius: 8px;
--radius-lg: 16px;
--radius-xl: 24px;
--shadow-sm: 0 1px 3px rgba(0,0,0,0.12);
--shadow-md: 0 2px 8px rgba(0,0,0,0.15);
--font: 'Google Sans', 'Roboto', sans-serif;
```
### Dark mode (`[data-theme="dark"]`)
```css
--primary: #4d8fd4;
--primary-light: #1a2d4a;
--surface: #1e1e2e;
--surface-variant: #252535;
--background: #13131f;
--border: #2e2e45;
--text-primary: #e2e2f0;
--text-secondary: #9898b8;
--text-tertiary: #606080; /* NOTE: exactly 6 hex digits — a common typo is 7 */
--bubble-out: #4d8fd4;
--bubble-in: #252535;
```
Dark mode is toggled by setting `document.documentElement.setAttribute('data-theme', 'dark')` and saved to `localStorage`.
### Layout
- Desktop: sidebar (320px fixed) + chat area (flex-1) side by side
- Mobile (≤768px): sidebar and chat stack — only one visible at a time, back button navigates
- Chat area: header + scrollable message list (flex-1, overflow-y: auto) + input fixed at bottom
---
## Part 11 — Docker & Deployment
### Dockerfile (multi-stage)
```dockerfile
# Stage 1: Build frontend
FROM node:20-alpine AS builder
WORKDIR /app
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
# Stage 2: Runtime
FROM node:20-alpine
RUN apk add --no-cache sqlite python3 make g++
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --omit=dev
RUN apk del python3 make g++
COPY backend/ ./
COPY --from=builder /app/frontend/dist ./public
RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images
EXPOSE 3000
CMD ["node", "src/index.js"]
```
**Critical**: `help.md` lives at `backend/src/data/help.md` → copied to `/app/src/data/help.md` in the image. It must NOT be placed in `/app/data/` which is volume-mounted and will hide baked-in files.
### docker-compose.yaml
```yaml
version: '3.8'
services:
jama:
image: jama:${JAMA_VERSION:-latest}
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
environment:
- ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- ADMPW_RESET=${ADMPW_RESET:-false} # set true to reset admin pw on next start
- JWT_SECRET=${JWT_SECRET:-changeme}
- APP_NAME=${APP_NAME:-jama}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
volumes:
- jama_db:/app/data # SQLite + uploads persist here
- jama_uploads:/app/uploads
volumes:
jama_db:
jama_uploads:
```
### Volume gotcha
Named Docker volumes shadow any files baked into that directory at image build time. Any file that must survive across rebuilds AND be editable at runtime goes in the volume. Any file that is bundled with the app and should not be overridden by the volume (like `help.md`) must be stored outside the volume mount path.
---
## Part 12 — PWA / Service Worker
- `manifest.json` is **dynamically served by Express** (not static) so app name and icons update without a rebuild
- Service worker (`sw.js`) handles:
- Asset caching for offline support
- Web Push notifications
- Per-conversation notification grouping by `tag: 'jama-group-{groupId}'`
- Skip notification if app window is currently visible (`clients.matchAll`)
- App badge sync via `SET_BADGE` message from the app
- Clear badge on notification click
---
## Part 13 — Features Checklist
### Messaging
- [x] Text messages with URL auto-linking
- [x] @mention with inline picker (type `@` to trigger)
- [x] Image upload + display with lightbox
- [x] Link preview cards (og: meta, server-side fetch)
- [x] Reply-to with quoted preview
- [x] Emoji reactions (quick bar + full picker)
- [x] Message delete (soft delete — shows "deleted" placeholder)
- [x] Typing indicator with animated dots
- [x] Date separators
- [x] System messages (e.g. "User joined")
- [x] Emoji-only messages render at larger size
- [x] Infinite scroll (load older messages on scroll to top)
### Groups & DMs
- [x] Public channels (# icon, auto-join for all users)
- [x] Private groups (lock icon, invite-only)
- [x] Read-only channels (📢, only admins can post)
- [x] User-to-user direct messages
- [x] Support group (private, all admins auto-added)
- [x] Per-user custom group display name (only visible to that user)
- [x] Group message list sorted newest-first (re-sorted on live messages)
- [x] DM shows peer's avatar and `Display Name (real name)` format
### Users & Profiles
- [x] User display name (separate from login name)
- [x] User avatar (upload, resize to 200×200 webp)
- [x] About me text
- [x] Hide admin tag option
- [x] Last online timestamp in admin user list
- [x] Suspend/unsuspend users
### Admin
- [x] Settings modal: app name, logo, PWA icons, VAPID key generation
- [x] User manager: create, edit, suspend, reset password
- [x] Admin can delete any message
- [x] Custom icons for UI elements (new chat button, group info button)
### PWA / Notifications
- [x] Installable PWA (manifest, service worker, icons)
- [x] Web Push notifications (VAPID, works when app is closed)
- [x] App badge on home screen icon (Chrome/Edge/Android)
- [x] Page title unread count `(N) App Name`
- [x] Per-conversation notification grouping
- [x] Push re-registration on mobile resume (prevents subscription loss)
### UX
- [x] Day / dark mode toggle (saved to localStorage)
- [x] Getting Started help modal (markdown, per-user dismiss)
- [x] About modal (customisable via about.json)
- [x] Support modal (sends message to Support group)
- [x] User profile popup (click any avatar)
- [x] Mobile-responsive layout (sidebar ↔ chat toggle)
- [x] `overscroll-behavior-y: none` (prevents pull-to-refresh viewport shift in PWA)
---
## Part 14 — Known Gotchas & Decisions
These are things that will catch you out if you don't know them upfront.
| Gotcha | Solution |
|---|---|
| `auth.js` exports `{ authMiddleware }` as an object | Always destructure: `const { authMiddleware } = require('../middleware/auth')` |
| Docker named volume shadows baked-in files | Never put runtime-required bundled files in `/app/data/` |
| Dark mode `--text-tertiary` hex typo | Value must be exactly 6 hex digits: `#606080` not `#6060808` |
| `last_message_user_id` not set on live updates | Must include `last_message_user_id: msg.user_id` in the `setGroups` update — not just content and timestamp |
| Mobile push subscriptions drop when PWA is backgrounded | Re-register push on every `visibilitychange → visible` event |
| Socket.io reconnect on mobile PWA resume | Add `visibilitychange` handler that calls `socket.connect()` if disconnected |
| Minimized window doesn't count unread in active chat | Check `document.visibilityState === 'hidden'` before skipping unread increment |
| JSX edits with `sed` break on special characters | Use Python string replacement for all JSX file edits |
| Multiple named exports vs default exports | Routes that need to share `io` accept it as a parameter: `module.exports = (io) => router` |
| Page title overwritten on settings refresh | Preserve `(N)` prefix: `const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] \|\| ''` |
| `navigator.setAppBadge` not in service worker scope | Use `self.navigator.setAppBadge` inside `sw.js` |
---
## Part 15 — Prompt Engineering Notes
When using this prompt with an AI coding assistant, these practices produced the best results across the 9 build sessions:
### Do
- **Give the full schema upfront** — the AI can design all routes consistently without asking
- **Specify the exact npm packages** — prevents the AI choosing different libs mid-build
- **Name every socket event** — socket event naming inconsistencies between client/server are silent bugs
- **Describe the volume gotcha explicitly** — this caused a real production bug
- **Ask for migrations, not table drops** — always say "wrap in try/catch, column-already-exists is OK"
- **Specify `module.exports` patterns** — named vs default exports is a common source of `is not a function` crashes
- **Request CSS variable names explicitly** — prevents the AI inventing colour values instead of using the system
- **For JSX edits, request Python string replacement** — sed with special characters destroys JSX
### Avoid
- Asking for partial implementations and filling in later — the AI will make different assumptions
- Letting the AI choose the DB (it will pick Postgres — SQLite is intentional here)
- Letting the AI choose the state management (it will add Redux/Zustand — contexts are intentional)
- Vague feature requests like "add notifications" — specify: socket-based for online users, push for offline, badge for all
### Iteration pattern that worked
1. **Session 1**: Stack + schema + auth + basic messaging
2. **Session 2**: Groups, DMs, reactions, replies
3. **Session 3**: Push notifications, service worker, PWA manifest
4. **Session 4**: Admin panel, user management, settings
5. **Session 5**: Mobile fixes, reconnect logic, offline resilience
6. **Session 6+**: Feature additions and bug fixes
Each session started by re-reading the previous session summary so the AI had full context on decisions made.
---
## Part 16 — One-Shot Prompt (Copy-Paste to Start)
> Use this if you want to try building jama in a single session. It is dense by design — the AI needs all of it.
```
Build a self-hosted team chat PWA called "jama". Single Docker container.
No external services.
STACK: Node 20 + Express + Socket.io + SQLite (better-sqlite3) + JWT
(HTTP-only cookie) + bcryptjs + React 18 + Vite.
Push via web-push (VAPID). Images via multer + sharp.
Frontend: plain CSS with CSS custom properties, no Tailwind.
STRUCTURE:
- backend/src/index.js — Express app + all Socket.io events
- backend/src/middleware/auth.js — exports { authMiddleware }
- backend/src/models/db.js — schema, migrations (additive ALTER TABLE in try/catch only, never drop), seed
- backend/src/routes/ — auth, users, groups, messages, settings, push, about, help
- frontend/src/ — React app with AuthContext, SocketContext, ToastContext
- frontend/public/sw.js — service worker for push + badge + cache
- Dockerfile — multi-stage (Vite builder → Node runtime)
- docker-compose.yaml — single service, volumes: jama_db:/app/data, jama_uploads:/app/uploads
DATABASE TABLES: users (name, email, password, role, status, is_default_admin,
must_change_password, avatar, about_me, display_name, hide_admin_tag, last_online,
help_dismissed), groups (name, type, owner_id, is_default, is_readonly, is_direct,
direct_peer1_id, direct_peer2_id), group_members, messages (content, type, image_url,
reply_to_id, is_deleted, link_preview), reactions (UNIQUE message+user+emoji),
notifications, active_sessions (PRIMARY KEY user_id+device), push_subscriptions
(UNIQUE user_id+device), settings, user_group_names (PRIMARY KEY user_id+group_id).
AUTH: JWT in HTTP-only cookie. Per-device sessions (desktop/mobile detected from UA).
must_change_password redirects to /change-password. ADMPW_RESET env var resets admin.
SOCKET EVENTS: message:new, message:deleted, reaction:updated, typing:start/stop,
notification:new, group:updated, group:removed. Server tracks onlineUsers Map for
push fallback. Reconnect: reconnectionDelay 500ms, re-register push on visibilitychange.
FEATURES: Public/private/readonly channels, user-to-user DMs, @mentions, emoji
reactions (toggle — same emoji off, different emoji replaces), reply-to, image
upload, link previews (server-side og: fetch), message soft-delete, typing indicator
(bouncing dots — use CSS var(--text-tertiary) for dot colour), unread badges,
page title (N) count, PWA badge via navigator.setAppBadge, Web Push with per-group
notification tags, per-user custom group names, user display names, avatars (200x200
webp), day/dark mode (CSS vars, saved localStorage), getting-started help modal
(renders help.md via marked), admin settings (app name, logo, PWA icons, VAPID
keygen), admin user manager, Support group (auto-created, all admins added).
LAYOUT: Desktop: 320px sidebar + flex chat. Mobile ≤768px: sidebar/chat toggle.
CSS system: Google Sans font, --primary #1a73e8, dark mode on [data-theme="dark"].
GOTCHAS:
- help.md must be at backend/src/data/help.md (NOT /app/data — that path is
volume-mounted and will shadow baked-in files)
- auth.js must export { authMiddleware } as named export
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
- last_message_user_id must be included in real-time setGroups update
- Use Python string replacement for JSX file edits, never sed
- self.navigator.setAppBadge (not navigator) inside service worker scope
```
```

View File

@@ -0,0 +1,880 @@
# RosterChirp — Complete Vibe-Code Build Prompt (v0.12.53)
> **How to use this document**
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document reflects the real production build of RosterChirp as of v0.12.53.
---
## Part 1 — What to Build (Product Brief)
Build a **self-hosted team chat Progressive Web App** called **RosterChirp**.
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. It supports two deployment modes:
| Mode | Description |
|---|---|
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
| `host` | Multi-tenant — one Postgres schema per tenant, provisioned at `{slug}.{HOST_DOMAIN}`. |
### Core philosophy
- Simple to self-host (one `docker compose up`)
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
- Instant real-time messaging via WebSockets (Socket.io)
- Push notifications via Firebase Cloud Messaging (FCM), works when app is backgrounded
- Multi-tenant via Postgres schema isolation (not separate databases)
---
## Part 2 — Tech Stack
| Layer | Technology |
|---|---|
| Backend runtime | Node.js 20 (Alpine) |
| Backend framework | Express.js |
| Real-time | Socket.io (server + client) |
| Database | PostgreSQL 16 via `pg` npm package |
| Auth | JWT in HTTP-only cookies + localStorage, `jsonwebtoken`, `bcryptjs` |
| Push notifications | Firebase Cloud Messaging (FCM) — Firebase Admin SDK (backend) + Firebase JS SDK (frontend) |
| Image processing | `sharp` (avatar/logo resizing) |
| Frontend framework | React 18 + Vite (PWA) |
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
| Markdown rendering | `marked` (for help modal) |
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
| Orchestration | `docker-compose.yaml` (selfhost) + `docker-compose.host.yaml` (multi-tenant) |
| Reverse proxy | Caddy (SSL termination) |
### Key npm packages (backend)
```
express, socket.io, pg, bcryptjs, jsonwebtoken,
cookie-parser, cors, multer, sharp,
firebase-admin, node-fetch
```
### Key npm packages (frontend)
```
react, react-dom, vite, socket.io-client,
@emoji-mart/react, @emoji-mart/data, marked,
firebase
```
---
## Part 3 — Project File Structure
```
rosterchirp/
├── CLAUDE.md
├── Dockerfile
├── build.sh # VERSION="${1:-X.Y.Z}" — bump here + both package.json files
├── docker-compose.yaml # selfhost
├── docker-compose.host.yaml # multi-tenant host mode
├── 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/ # 001008 SQL files, auto-applied on startup
│ └── routes/
│ ├── auth.js # receives io
│ ├── groups.js # receives io
│ ├── messages.js # receives io
│ ├── usergroups.js # receives io
│ ├── schedule.js # receives io
│ ├── users.js
│ ├── settings.js
│ ├── push.js
│ ├── host.js # RosterChirp-Host control plane only
│ ├── about.js
│ └── help.js
└── frontend/
├── package.json # version bump required
├── vite.config.js
├── index.html # viewport: user-scalable=no (pinch handled via JS)
├── public/
│ ├── manifest.json
│ ├── sw.js # service worker / FCM push
│ └── icons/
└── src/
├── App.jsx
├── main.jsx # pinch→font-scale handler, pull-to-refresh blocker, iOS keyboard fix
├── index.css # CSS vars, dark mode, --font-scale, mobile autofill fixes
├── contexts/
│ ├── AuthContext.jsx
│ ├── SocketContext.jsx # force transports: ['websocket']
│ └── ToastContext.jsx
├── pages/
│ ├── Chat.jsx # main shell, page routing, all socket wiring
│ ├── Login.jsx
│ ├── ChangePassword.jsx
│ ├── UserManagerPage.jsx
│ └── GroupManagerPage.jsx
└── components/
├── Sidebar.jsx # groupMessagesMode prop
├── ChatWindow.jsx
├── MessageInput.jsx # onTextChange prop, fixed font size (no --font-scale)
├── Message.jsx # fonts scaled via --font-scale
├── NavDrawer.jsx
├── SchedulePage.jsx # ~1600 lines, desktop+mobile views
├── MobileEventForm.jsx
├── Avatar.jsx # consistent colour algorithm — must match Sidebar + ChatWindow
├── PasswordInput.jsx
├── GroupInfoModal.jsx
├── ProfileModal.jsx # appearance tab: font-scale slider (saved), pinch is session-only
├── SettingsModal.jsx
├── BrandingModal.jsx
├── HostPanel.jsx
├── NewChatModal.jsx
├── UserFooter.jsx
├── GlobalBar.jsx
├── ImageLightbox.jsx
├── UserProfilePopup.jsx
├── AddChildAliasModal.jsx
├── ScheduleManagerModal.jsx
├── ColourPickerSheet.jsx
└── SupportModal.jsx
```
### Dead code (safe to delete)
- `frontend/src/pages/HostAdmin.jsx`
- `frontend/src/components/UserManagerModal.jsx`
- `frontend/src/components/GroupManagerModal.jsx`
- `frontend/src/components/MobileGroupManager.jsx`
---
## Part 4 — Database Architecture
### Connection pool (`db.js`)
```javascript
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
database: process.env.DB_NAME || 'rosterchirp',
user: process.env.DB_USER || 'rosterchirp',
password: process.env.DB_PASSWORD || '',
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 5000,
});
```
### Query helpers
```javascript
query(schema, sql, params) // SET search_path then SELECT
queryOne(schema, sql, params) // returns first row or null
queryResult(schema, sql, params) // returns full result object
exec(schema, sql, params) // INSERT/UPDATE/DELETE
withTransaction(schema, async (client) => { ... })
```
`SET search_path TO {schema}` is called before every query. `assertSafeSchema(name)` validates all schema names against `/^[a-z_][a-z0-9_]*$/`.
### Migrations
Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` are applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
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)
### Seeding order
`seedSettings → seedEventTypes → seedAdmin → seedUserGroups`
All seed functions use `ON CONFLICT DO NOTHING`.
### Core tables (PostgreSQL — schema-qualified at query time)
```sql
users (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
email TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
is_default_admin BOOLEAN DEFAULT FALSE,
must_change_password BOOLEAN DEFAULT TRUE,
avatar TEXT,
about_me TEXT,
display_name TEXT,
hide_admin_tag BOOLEAN DEFAULT FALSE,
allow_dm INTEGER DEFAULT 1,
last_online TIMESTAMPTZ,
help_dismissed BOOLEAN DEFAULT FALSE,
date_of_birth DATE,
phone TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
type TEXT DEFAULT 'public',
owner_id INTEGER REFERENCES users(id),
is_default BOOLEAN DEFAULT FALSE,
is_readonly BOOLEAN DEFAULT FALSE,
is_direct BOOLEAN DEFAULT FALSE,
is_managed BOOLEAN DEFAULT FALSE, -- managed private groups (Group Messages mode)
direct_peer1_id INTEGER,
direct_peer2_id INTEGER,
track_availability BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
group_members (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(group_id, user_id)
)
messages (
id SERIAL PRIMARY KEY,
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id),
content TEXT,
type TEXT DEFAULT 'text', -- 'text' | 'system'
image_url TEXT,
reply_to_id INTEGER REFERENCES messages(id),
is_deleted BOOLEAN DEFAULT FALSE,
is_readonly BOOLEAN DEFAULT FALSE,
link_preview JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
)
reactions (
id SERIAL PRIMARY KEY,
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(message_id, user_id, emoji)
)
notifications (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
type TEXT NOT NULL,
message_id INTEGER,
group_id INTEGER,
from_user_id INTEGER,
is_read BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
)
active_sessions (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT NOT NULL DEFAULT 'desktop', -- 'mobile' | 'desktop'
token TEXT NOT NULL,
ua TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (user_id, device)
)
-- One session per device type per user. New login on same device displaces old session.
-- Displaced socket receives 'session:displaced' event.
push_subscriptions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
device TEXT DEFAULT 'desktop',
fcm_token TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, device)
)
settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ DEFAULT NOW()
)
-- Feature flag keys: feature_branding ('true'/'false'), feature_group_manager,
-- feature_schedule_manager, app_type ('RosterChirp-Chat'/'RosterChirp-Brand'/'RosterChirp-Team')
user_group_names (
user_id INTEGER NOT NULL,
group_id INTEGER NOT NULL,
name TEXT NOT NULL,
PRIMARY KEY (user_id, group_id)
)
user_groups (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
colour TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
)
user_group_members (
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
PRIMARY KEY (user_group_id, user_id)
)
group_user_groups (
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
PRIMARY KEY (group_id, user_group_id)
)
events (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
location TEXT,
start_at TIMESTAMPTZ NOT NULL,
end_at TIMESTAMPTZ NOT NULL,
all_day BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT TRUE,
created_by INTEGER REFERENCES users(id),
event_type_id INTEGER REFERENCES event_types(id),
recurrence_rule JSONB,
track_availability BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
)
event_types (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
colour TEXT NOT NULL DEFAULT '#1a73e8',
created_at TIMESTAMPTZ DEFAULT NOW()
)
event_availability (
id SERIAL PRIMARY KEY,
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status TEXT NOT NULL, -- 'going' | 'maybe' | 'not_going'
note TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(event_id, user_id)
)
tenants (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
schema_name TEXT UNIQUE NOT NULL,
display_name TEXT,
custom_domain TEXT,
status TEXT DEFAULT 'active',
plan TEXT DEFAULT 'team',
created_at TIMESTAMPTZ DEFAULT NOW()
)
```
---
## Part 5 — Multi-Tenant Architecture (Host Mode)
### Tenant resolution
`tenantMiddleware` in `index.js` sets `req.schema` from the HTTP `Host` header before any route runs:
```javascript
// Subdomain tenants: {slug}.{HOST_DOMAIN} → schema 'tenant_{slug}'
// Custom domains: looked up in tenants table custom_domain column
// Host admin: HOST_DOMAIN itself → schema 'public'
const tenantDomainCache = new Map(); // in-process cache, cleared on tenant update
```
### Socket room naming (tenant-isolated)
All socket rooms are prefixed with the tenant schema:
```javascript
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
```
### Online user tracking
```javascript
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
// Key includes schema to prevent cross-tenant ID collisions
```
---
## Part 6 — Auth & Session System
- JWT stored in **HTTP-only cookie** (`token`) AND `localStorage` (for PWA/mobile fallback)
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
- `teamManagerMiddleware` — checks if user is a team manager (role-based feature access)
- **Per-device sessions**: `active_sessions` PK is `(user_id, device)` — logging in on mobile doesn't kick out desktop
- Device: `mobile` if `/Mobile|Android|iPhone/i.test(ua)`, else `desktop`
- `must_change_password = true` redirects to `/change-password` after login
- `ADMPW_RESET=true` env var resets default admin password on container start
---
## Part 7 — Real-time Architecture (Socket.io)
### Connection
- Socket auth: JWT in `socket.handshake.auth.token`
- On connect: user joins `R(schema, 'group', id)` for all their groups, and `R(schema, 'user', userId)` for direct notifications, and `R(schema, 'schema', 'all')` for tenant-wide broadcasts
### Routes that receive `io`
```javascript
// All of these are called as: require('./routes/foo')(io)
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
```
### Key socket events (server → client)
| Event | When |
|---|---|
| `message:new` | new message in a group |
| `message:deleted` | soft delete |
| `reaction:updated` | reaction toggled |
| `typing:start` / `typing:stop` | typing indicator |
| `notification:new` | mention or private message |
| `group:updated` | group settings changed |
| `group:removed` | user removed from group |
| `user:online` / `user:offline` | presence change |
| `users:online` | full online user list (on request) |
| `session:displaced` | same device logged in elsewhere |
| `schedule:event-created/updated/deleted` | schedule changes |
### Reconnect strategy (SocketContext.jsx)
```javascript
const socket = io({ transports: ['websocket'] }); // websocket only — no polling
// reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000
// visibilitychange → visible: call socket.connect() if disconnected
```
---
## Part 8 — FCM Push Notifications
### Architecture
```
Frontend (browser/PWA)
└─ Chat.jsx
├─ GET /api/push/firebase-config → fetches SDK config
├─ Initialises Firebase JS SDK + getMessaging()
├─ getToken(messaging, { vapidKey }) → FCM token
└─ POST /api/push/subscribe → registers in push_subscriptions
Backend (push.js)
├─ sendPushToUser(schema, userId, payload) → called from messages.js (primary)
│ and index.js socket handler (fallback)
└─ Firebase Admin SDK → Google FCM servers → device
```
### Message payload
```javascript
{
token: sub.fcm_token,
notification: { title, body },
data: { url: '/', groupId: '42' },
android: { priority: 'high', notification: { sound: 'default' } },
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
}
```
### Push trigger logic (messages.js)
- Frontend sends messages via `POST /api/messages/group/:groupId` (REST), not socket
- **Push must be fired from messages.js**, not just socket handler
- Private group: push to all `group_members` except sender
- Public group: push to all `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`
- Image messages: body `'📷 Image'`
### Stale token cleanup
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for:
`messaging/registration-token-not-registered`, `messaging/invalid-registration-token`, `messaging/invalid-argument`
### Required env vars
```
FIREBASE_API_KEY=
FIREBASE_PROJECT_ID=
FIREBASE_APP_ID=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_VAPID_KEY= # Web Push certificate public key (Cloud Messaging tab)
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
```
---
## Part 9 — API Routes
All routes require `authMiddleware` except login/health.
### Auth (`/api/auth`)
- `POST /login`, `POST /logout`, `POST /change-password`, `GET /me`
### Users (`/api/users`)
- `GET /` — admin: full user list
- `POST /` — admin: create user
- `PATCH /:id` — admin: update role/status/password
- `PATCH /me/profile` — own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)
- `POST /me/avatar` — multipart: resize to 200×200 webp
- `GET /check-display-name?name=`
### Groups (`/api/groups`)
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
- `POST /` — create group or DM
- `PATCH /:id`, `DELETE /:id`
- `POST /:id/members`, `DELETE /:id/members/:userId`, `GET /:id/members`
- `POST /:id/custom-name`, `DELETE /:id/custom-name`
### Messages (`/api/messages`)
- `GET /?groupId=&before=&limit=` — 50 per page, cursor-based
- `POST /group/:groupId` — send message (REST path, fires push)
- `POST /image` — image upload
### User Groups (`/api/usergroups`)
- CRUD for user groups (team roster groupings), member management
### Schedule (`/api/schedule`)
- CRUD for events, event types, availability tracking
- `GET /events` — with date range, keyword filter, type filter, availability filter
- `POST /events/:id/availability` — set own availability
- `GET /events/:id/availability` — get all availability for an event
### Settings (`/api/settings`)
- `GET /`, `PATCH /`, `POST /logo`, `POST /icon/:key`
### Push (`/api/push`)
- `GET /firebase-config` — returns FCM SDK config
- `POST /subscribe` — save FCM token
- `GET /debug` — admin: list tokens + firebase status
- `POST /test` — send test push to own device
### Host (`/api/host`) — host mode only
- Tenant provisioning, plan management, host admin panel
### About (`/api/about`), Help (`/api/help`)
---
## Part 10 — Frontend Architecture
### Page navigation (Chat.jsx)
`page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'`
**Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)`.
### Group Messages vs Messages (Sidebar)
- `groupMessagesMode={false}` → public groups + non-managed private groups
- `groupMessagesMode={true}` → only `is_managed` private groups
### Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
- `MessageInput` fires `onTextChange(val)` on every keystroke and after send
- `ChatWindow` converts to boolean: `onHasTextChange?.(!!val.trim())`
- `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if switching with unsaved text
- `MessageInput` resets all state on `group?.id` change via `useEffect`
### Font scale system
- CSS var `--font-scale` on `<html>` element (default `1`, range `0.8``2.0`)
- **Message fonts** use `calc(Xrem * var(--font-scale))` — they scale
- **MessageInput font** is fixed (`0.875rem`) — it does NOT scale
- **Slider** (ProfileModal appearance tab) is the saved setting — persisted to `localStorage`
- **Pinch zoom** (main.jsx touch handler) is session-only — updates `--font-scale` but does NOT write to localStorage
- On startup, `--font-scale` is initialised from the saved localStorage value
### Avatar colour algorithm
Must be **identical** across `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`:
```javascript
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length];
```
### Notification rules (group member changes, usergroups.js)
- 1 user added/removed → named system message: `"{Name} has joined/been removed from the conversation."`
- 2+ users added/removed → generic: `"N new members have joined/been removed from the conversation."`
### User deletion (v0.11.11+)
Email → `deleted_{id}@deleted`, name → `'Deleted User'`, all messages `is_deleted=TRUE`, DMs `is_readonly=TRUE`, sessions/subscriptions/availability purged.
---
## Part 11 — Schedule / Events
- All date/time stored as `TIMESTAMPTZ`
- `buildISO(date, time)` — builds timezone-aware ISO string from date + HH:MM input
- `toTimeIn(iso)` — extracts exact HH:MM (no rounding) for edit forms
- `roundUpToHalfHour()` — default start time for new events
- New events cannot have a start date/time in the past
- Recurring events: `expandRecurringEvent` returns occurrences within requested range only
- Keyword filter: unquoted = `\bterm` (prefix match), quoted = `\bterm\b` (exact word)
- Type filter does NOT shift date window to today (unlike keyword/availability filters)
- Clearing keyword also resets `filterFromDate`
Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of the time utilities (`roundUpToHalfHour`, `parseTypedTime`, `fmt12`, `toTimeIn`, `buildISO`).
---
## Part 12 — CSS Design System
### Variables (`:root` — light mode)
```css
--primary: #1a73e8;
--primary-dark: #1557b0;
--primary-light: #e8f0fe;
--surface: #ffffff;
--surface-variant: #f8f9fa;
--background: #f1f3f4;
--border: #e0e0e0;
--text-primary: #202124;
--text-secondary: #5f6368;
--text-tertiary: #9aa0a6;
--error: #d93025;
--success: #188038;
--bubble-out: #1a73e8;
--bubble-in: #f1f3f4;
--radius: 8px;
--font: 'Google Sans', 'Roboto', sans-serif;
--font-scale: 1; /* adjusted by pinch or slider */
```
### Dark mode (`[data-theme="dark"]`)
```css
--primary: #4d8fd4;
--primary-light: #1a2d4a;
--surface: #1e1e2e;
--surface-variant: #252535;
--background: #13131f;
--border: #2e2e45;
--text-primary: #e2e2f0;
--text-secondary: #9898b8;
--text-tertiary: #606080; /* exactly 6 hex digits — a common typo is 7 */
--bubble-out: #4d8fd4;
--bubble-in: #252535;
```
### Mobile input fixes
```css
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
@media (max-width: 768px) {
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
}
/* Autofill styling */
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
-webkit-text-fill-color: var(--text-primary) !important;
}
```
### Layout
- Desktop: sidebar (320px fixed) + chat area (flex-1)
- Mobile (≤768px): sidebar and chat stack — one visible at a time
- `--visual-viewport-height` and `--visual-viewport-offset` CSS vars exposed by main.jsx for iOS keyboard handling
- `.keyboard-open` class toggled on `<html>` when iOS keyboard is visible
---
## Part 13 — Docker & Deployment
### Dockerfile (multi-stage)
```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY frontend/package*.json ./frontend/
RUN cd frontend && npm install
COPY frontend/ ./frontend/
RUN cd frontend && npm run build
FROM node:20-alpine
WORKDIR /app
COPY backend/package*.json ./
RUN npm install --omit=dev
COPY backend/ ./
COPY --from=builder /app/frontend/dist ./public
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
EXPOSE 3000
CMD ["node", "src/index.js"]
```
### Version bump — all three locations
```
backend/package.json "version": "X.Y.Z"
frontend/package.json "version": "X.Y.Z"
build.sh VERSION="${1:-X.Y.Z}"
```
### Environment variables (key ones)
```
APP_TYPE=selfhost|host
HOST_DOMAIN= # host mode only
HOST_ADMIN_KEY= # host mode only
JWT_SECRET=
DB_HOST=db # set to 'pgbouncer' after Phase 1 scaling
DB_NAME=rosterchirp
DB_USER=rosterchirp
DB_PASSWORD= # avoid ! (shell interpolation issue in 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
FIREBASE_API_KEY=
FIREBASE_PROJECT_ID=
FIREBASE_APP_ID=
FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_VAPID_KEY=
FIREBASE_SERVICE_ACCOUNT= # stringified JSON, no newlines
VAPID_PUBLIC= # legacy, auto-generated, no longer used for push delivery
VAPID_PRIVATE=
```
---
## Part 14 — Scale Architecture (Planned)
### Phase 1 — PgBouncer (zero code changes)
Add PgBouncer service to `docker-compose.host.yaml`. Point `DB_HOST=pgbouncer`. Increase Node pool `max` to 100. Use `POOL_MODE=transaction`. Eliminates the 20-connection bottleneck.
### Phase 2 — Redis (horizontal scaling)
Required for multiple Node instances:
1. `@socket.io/redis-adapter` — cross-instance socket fan-out
2. Replace `onlineUsers` Map with Redis `SADD`/`SREM` presence keys (`presence:{schema}:{userId}`)
3. Replace `tenantDomainCache` Map with Redis hash + TTL
4. Move uploads to Cloudflare R2 (S3-compatible) — `@aws-sdk/client-s3`
5. Force WebSocket transport only (eliminates polling sticky-session concern)
**Note on Phase 2:** `SET search_path` per query is safe with PgBouncer in transaction mode. Do NOT use `LISTEN/NOTIFY` or session-level state through PgBouncer.
---
## Part 15 — Known Gotchas & Decisions
| Gotcha | Solution |
|---|---|
| Multi-tenant schema isolation | Every query must go through `query(schema, ...)` — never raw `pool.query` |
| `assertSafeSchema()` | Always validate schema names before use — injection risk |
| Socket room names include schema | `R(schema, 'group', id)` not bare `group:{id}` — cross-tenant leakage otherwise |
| `onlineUsers` key is `${schema}:${userId}` | Two tenants can share the same integer user ID |
| FCM push fired from messages.js REST route | Frontend uses REST POST, not socket, for sending messages |
| Pinch zoom is session-only | Remove `localStorage.setItem` from touchend — slider is the saved setting |
| MessageInput font is fixed | Do not apply `--font-scale` to `.msg-input` font-size |
| iOS keyboard layout | Use `--visual-viewport-height` CSS var, not `100vh`, for the chat layout height |
| Avatar colour algorithm | Must be identical in Avatar.jsx, Sidebar.jsx, and ChatWindow.jsx |
| `is_managed` groups | Managed private groups appear in Group Messages view, not regular Messages view |
| Migrations are SQL files | Not try/catch ALTER TABLE — numbered SQL files in `migrations/` applied in order |
| DB_PASSWORD must not contain `!` | Shell interpolation breaks docker-compose env parsing |
| Routes accept `io` as parameter | `module.exports = (io) => router` — not default export |
| `session:displaced` socket event | Sent to the old socket when a new login displaces a session on the same device type |
| `help.md` is NOT in the volume path | Must be at `backend/src/data/help.md` — not in `/app/data/` which is volume-mounted |
| Dark mode `--text-tertiary` | Exactly 6 hex digits: `#606080` not `#6060808` |
| Web Share API for mobile file download | Use `navigator.share({ files: [...] })` on mobile; fall back to `a.click()` download on desktop |
---
## Part 16 — Features Checklist
### Messaging
- [x] Text messages with URL auto-linking and @mentions
- [x] Image upload + lightbox
- [x] Link preview cards (og: meta, server-side fetch)
- [x] Reply-to with quoted preview
- [x] Emoji reactions (quick bar + full picker)
- [x] Message soft-delete
- [x] Typing indicator
- [x] Date separators, consecutive-message collapsing
- [x] System messages
- [x] Emoji-only messages render larger
- [x] Infinite scroll / cursor-based pagination (50 per page)
### Groups & DMs
- [x] Public channels, private groups, read-only channels
- [x] Managed private groups (Group Messages view)
- [x] User-to-user direct messages
- [x] Per-user custom group display name
- [x] User groups (team roster groupings) with colour coding
- [x] Group availability tracking (events)
### Users & Profiles
- [x] Display name, avatar, about me, date of birth, phone
- [x] Hide admin tag option
- [x] Allow/block DMs toggle
- [x] Child/alias user accounts (`AddChildAliasModal`)
- [x] Bulk user import via CSV
### Admin
- [x] Settings modal: app name, logo, PWA icons
- [x] Branding modal (Brand+ plan)
- [x] User manager (full page): create, edit, suspend, reset password, bulk import
- [x] Group manager (full page): create groups, manage members, assign user groups
- [x] Schedule manager modal: event types with custom colours
- [x] Admin can delete any message
### Schedule
- [x] Event creation (one-time + recurring)
- [x] Event types with colour coding
- [x] Availability tracking (Going / Maybe / Not Going)
- [x] Download availability list (Web Share API on mobile, download link on desktop)
- [x] Keyword filter (prefix and exact-word modes)
- [x] Type filter, date range filter
- [x] Desktop and mobile views (separate implementations)
### PWA / Notifications
- [x] Installable PWA (manifest, service worker, icons)
- [x] FCM push notifications (Android working; iOS in progress)
- [x] App badge on home screen icon
- [x] Page title unread count `(N) App Name`
- [x] Per-conversation notification grouping
### UX
- [x] Light / dark mode (CSS vars, saved localStorage)
- [x] Font scale slider (saved setting) + pinch zoom (session only)
- [x] Mobile-responsive layout
- [x] Pull-to-refresh blocked in PWA standalone mode
- [x] iOS keyboard layout fix (`--visual-viewport-height`)
- [x] Getting Started help modal
- [x] About modal, Support modal
- [x] User profile popup (click any avatar)
- [x] NavDrawer (hamburger menu)
---
## Part 17 — One-Shot Prompt (Copy-Paste to Start)
```
Build a self-hosted team chat PWA called "RosterChirp". Single Docker container.
Supports selfhost (single tenant) and host (multi-tenant via Postgres schema per tenant) modes.
STACK: Node 20 + Express + Socket.io + PostgreSQL 16 (pg npm package) + JWT
(HTTP-only cookie + localStorage) + bcryptjs + React 18 + Vite.
Push via Firebase Cloud Messaging (firebase-admin backend, firebase frontend SDK).
Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind.
MULTI-TENANT: tenantMiddleware sets req.schema from Host header. assertSafeSchema()
validates all schema names. Socket rooms prefixed: `${schema}:${type}:${id}`.
onlineUsers Map key is `${schema}:${userId}` to prevent cross-tenant ID collisions.
Every DB query calls SET search_path TO {schema} first.
MIGRATIONS: Numbered SQL files in backend/src/models/migrations/ (001, 002, ...).
Auto-applied on startup via runMigrations(schema). Never edit applied migrations.
ROUTES accept io as parameter: module.exports = (io) => router
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
KEY FEATURES:
- Public/private/readonly channels, managed private groups (Group Messages view),
user-to-user DMs, @mentions, emoji reactions, reply-to, image upload, link previews,
soft-delete, typing indicator, unread badges, page title (N) count
- User groups (team roster groupings) with colour coding
- Schedule: events, event types, availability tracking, recurring events
- Font scale: --font-scale CSS var on <html>. Message fonts scale with it. MessageInput
font is FIXED (no --font-scale). Slider in ProfileModal = saved setting (localStorage).
Pinch zoom = session only (touchend must NOT write to localStorage).
- FCM push: fired from messages.js REST route (not socket handler). sendPushToUser helper.
Stale token cleanup on FCM error codes.
- Avatar colour: AVATAR_COLORS array, charCodeAt(0) % length. Must be identical in
Avatar.jsx, Sidebar.jsx, ChatWindow.jsx.
- User deletion: email scrubbed, messages nulled, DMs set readonly, sessions purged.
- Web Share API for mobile file downloads; a.click() fallback for desktop.
GOTCHAS:
- DB_PASSWORD must not contain '!' (shell interpolation in docker-compose)
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
- help.md at backend/src/data/help.md (NOT /app/data — volume-mounted, shadows files)
- Session displaced: socket receives 'session:displaced' when new login takes device slot
- iOS keyboard: use --visual-viewport-height CSS var (not 100vh) for chat layout height
- Routes that emit socket events receive io as first argument, not default export
```

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-backend",
"version": "0.12.53",
"version": "0.13.1",
"description": "RosterChirp backend server",
"main": "src/index.js",
"scripts": {

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.12.53}"
VERSION="${1:-0.13.1}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.12.53",
"version": "0.13.1",
"private": true,
"scripts": {
"dev": "vite",

View File

@@ -151,7 +151,7 @@
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: 20px;
font-size: calc(0.875rem * var(--font-scale));
font-size: 0.875rem;
line-height: 1.4;
font-family: var(--font);
color: var(--text-primary);

View File

@@ -447,7 +447,7 @@ export default function ProfileModal({ onClose }) {
</span>
</div>
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
Pinch to zoom in the chat window also adjusts this setting.
Pinch to zoom adjusts font size for this session only.
</span>
</div>
<button

View File

@@ -924,14 +924,23 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
lines.push('');
}
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const safeName = (event.title || 'event').replace(/[^a-z0-9]+/gi, '_').toLowerCase();
a.href = url;
a.download = `availability_${safeName}.txt`;
a.click();
URL.revokeObjectURL(url);
const fileName = `availability_${safeName}.txt`;
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
// On mobile use the native share sheet (lets the user choose Save to Files, etc.)
// On desktop fall back to a standard download link.
const file = new File([blob], fileName, { type: 'text/plain' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
navigator.share({ files: [file], title: fileName }).catch(() => {});
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
};
return ReactDOM.createPortal(

View File

@@ -97,7 +97,8 @@ if ('serviceWorker' in navigator) {
document.addEventListener('touchend', function (e) {
if (e.touches.length < 2 && pinchStartDist !== null) {
pinchStartDist = null;
localStorage.setItem(LS_KEY, currentScale);
// Pinch zoom is session-only — do NOT persist to localStorage.
// The saved (slider) scale is only written by ProfileModal.
}
}, { passive: true });
})();