Files
rosterchirp/Reference/vibecode-prompt.md
2026-04-07 11:29:21 -04:00

33 KiB
Raw Permalink Blame History

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/         # 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)

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

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)

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:

// 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:

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

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

// 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)

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

{
  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.82.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:

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)

--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"])

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

/* 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)

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

  • Text messages with URL auto-linking and @mentions
  • Image upload + lightbox
  • Link preview cards (og: meta, server-side fetch)
  • Reply-to with quoted preview
  • Emoji reactions (quick bar + full picker)
  • Message soft-delete
  • Typing indicator
  • Date separators, consecutive-message collapsing
  • System messages
  • Emoji-only messages render larger
  • Infinite scroll / cursor-based pagination (50 per page)

Groups & DMs

  • Public channels, private groups, read-only channels
  • Managed private groups (Group Messages view)
  • User-to-user direct messages
  • Per-user custom group display name
  • User groups (team roster groupings) with colour coding
  • Group availability tracking (events)

Users & Profiles

  • Display name, avatar, about me, date of birth, phone
  • Hide admin tag option
  • Allow/block DMs toggle
  • Child/alias user accounts (AddChildAliasModal)
  • Bulk user import via CSV

Admin

  • Settings modal: app name, logo, PWA icons
  • Branding modal (Brand+ plan)
  • User manager (full page): create, edit, suspend, reset password, bulk import
  • Group manager (full page): create groups, manage members, assign user groups
  • Schedule manager modal: event types with custom colours
  • Admin can delete any message

Schedule

  • Event creation (one-time + recurring)
  • Event types with colour coding
  • Availability tracking (Going / Maybe / Not Going)
  • Download availability list (Web Share API on mobile, download link on desktop)
  • Keyword filter (prefix and exact-word modes)
  • Type filter, date range filter
  • Desktop and mobile views (separate implementations)

PWA / Notifications

  • Installable PWA (manifest, service worker, icons)
  • FCM push notifications (Android working; iOS in progress)
  • App badge on home screen icon
  • Page title unread count (N) App Name
  • Per-conversation notification grouping

UX

  • Light / dark mode (CSS vars, saved localStorage)
  • Font scale slider (saved setting) + pinch zoom (session only)
  • Mobile-responsive layout
  • Pull-to-refresh blocked in PWA standalone mode
  • iOS keyboard layout fix (--visual-viewport-height)
  • Getting Started help modal
  • About modal, Support modal
  • User profile popup (click any avatar)
  • 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