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