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

@@ -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
```