# RosterChirp — Complete Vibe-Code Build Prompt (v0.13.1)

> **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/         # 001–008 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
```
