33 KiB
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.jsxfrontend/src/components/UserManagerModal.jsxfrontend/src/components/GroupManagerModal.jsxfrontend/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) ANDlocalStorage(for PWA/mobile fallback) authMiddlewareinmiddleware/auth.js— verifies JWT, attachesreq.userteamManagerMiddleware— checks if user is a team manager (role-based feature access)- Per-device sessions:
active_sessionsPK is(user_id, device)— logging in on mobile doesn't kick out desktop - Device:
mobileif/Mobile|Android|iPhone/i.test(ua), elsedesktop must_change_password = trueredirects to/change-passwordafter loginADMPW_RESET=trueenv 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, andR(schema, 'user', userId)for direct notifications, andR(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_membersexcept 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 listPOST /— admin: create userPATCH /:id— admin: update role/status/passwordPATCH /me/profile— own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)POST /me/avatar— multipart: resize to 200×200 webpGET /check-display-name?name=
Groups (/api/groups)
GET /— returns{ publicGroups, privateGroups }with last_message, peer data for DMsPOST /— create group or DMPATCH /:id,DELETE /:idPOST /:id/members,DELETE /:id/members/:userId,GET /:id/membersPOST /:id/custom-name,DELETE /:id/custom-name
Messages (/api/messages)
GET /?groupId=&before=&limit=— 50 per page, cursor-basedPOST /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 filterPOST /events/:id/availability— set own availabilityGET /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 configPOST /subscribe— save FCM tokenGET /debug— admin: list tokens + firebase statusPOST /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 groupsgroupMessagesMode={true}→ onlyis_managedprivate groups
Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
MessageInputfiresonTextChange(val)on every keystroke and after sendChatWindowconverts to boolean:onHasTextChange?.(!!val.trim())Chat.jsxstores aschatHasText;selectGroup()showswindow.confirmif switching with unsaved textMessageInputresets all state ongroup?.idchange viauseEffect
Font scale system
- CSS var
--font-scaleon<html>element (default1, range0.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-scalebut does NOT write to localStorage - On startup,
--font-scaleis 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 inputtoTimeIn(iso)— extracts exact HH:MM (no rounding) for edit formsroundUpToHalfHour()— default start time for new events- New events cannot have a start date/time in the past
- Recurring events:
expandRecurringEventreturns 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-heightand--visual-viewport-offsetCSS vars exposed by main.jsx for iOS keyboard handling.keyboard-openclass 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:
@socket.io/redis-adapter— cross-instance socket fan-out- Replace
onlineUsersMap with RedisSADD/SREMpresence keys (presence:{schema}:{userId}) - Replace
tenantDomainCacheMap with Redis hash + TTL - Move uploads to Cloudflare R2 (S3-compatible) —
@aws-sdk/client-s3 - 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