881 lines
33 KiB
Markdown
881 lines
33 KiB
Markdown
# RosterChirp — Complete Vibe-Code Build Prompt (v0.12.53)
|
||
|
||
> **How to use this document**
|
||
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document reflects the real production build of RosterChirp as of v0.12.53.
|
||
|
||
---
|
||
|
||
## Part 1 — What to Build (Product Brief)
|
||
|
||
Build a **self-hosted team chat Progressive Web App** called **RosterChirp**.
|
||
|
||
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. It supports two deployment modes:
|
||
|
||
| Mode | Description |
|
||
|---|---|
|
||
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
|
||
| `host` | Multi-tenant — one Postgres schema per tenant, provisioned at `{slug}.{HOST_DOMAIN}`. |
|
||
|
||
### Core philosophy
|
||
- Simple to self-host (one `docker compose up`)
|
||
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
|
||
- Instant real-time messaging via WebSockets (Socket.io)
|
||
- Push notifications via Firebase Cloud Messaging (FCM), works when app is backgrounded
|
||
- Multi-tenant via Postgres schema isolation (not separate databases)
|
||
|
||
---
|
||
|
||
## Part 2 — Tech Stack
|
||
|
||
| Layer | Technology |
|
||
|---|---|
|
||
| Backend runtime | Node.js 20 (Alpine) |
|
||
| Backend framework | Express.js |
|
||
| Real-time | Socket.io (server + client) |
|
||
| Database | PostgreSQL 16 via `pg` npm package |
|
||
| Auth | JWT in HTTP-only cookies + localStorage, `jsonwebtoken`, `bcryptjs` |
|
||
| Push notifications | Firebase Cloud Messaging (FCM) — Firebase Admin SDK (backend) + Firebase JS SDK (frontend) |
|
||
| Image processing | `sharp` (avatar/logo resizing) |
|
||
| Frontend framework | React 18 + Vite (PWA) |
|
||
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
|
||
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
|
||
| Markdown rendering | `marked` (for help modal) |
|
||
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
|
||
| Orchestration | `docker-compose.yaml` (selfhost) + `docker-compose.host.yaml` (multi-tenant) |
|
||
| Reverse proxy | Caddy (SSL termination) |
|
||
|
||
### Key npm packages (backend)
|
||
```
|
||
express, socket.io, pg, bcryptjs, jsonwebtoken,
|
||
cookie-parser, cors, multer, sharp,
|
||
firebase-admin, node-fetch
|
||
```
|
||
|
||
### Key npm packages (frontend)
|
||
```
|
||
react, react-dom, vite, socket.io-client,
|
||
@emoji-mart/react, @emoji-mart/data, marked,
|
||
firebase
|
||
```
|
||
|
||
---
|
||
|
||
## Part 3 — Project File Structure
|
||
|
||
```
|
||
rosterchirp/
|
||
├── CLAUDE.md
|
||
├── Dockerfile
|
||
├── build.sh # VERSION="${1:-X.Y.Z}" — bump here + both package.json files
|
||
├── docker-compose.yaml # selfhost
|
||
├── docker-compose.host.yaml # multi-tenant host mode
|
||
├── Caddyfile.example
|
||
├── .env.example
|
||
├── about.json.example
|
||
├── backend/
|
||
│ ├── package.json # version bump required
|
||
│ └── src/
|
||
│ ├── index.js # Express app, Socket.io, tenant middleware wiring
|
||
│ ├── middleware/
|
||
│ │ └── auth.js # JWT auth, teamManagerMiddleware
|
||
│ ├── models/
|
||
│ │ ├── db.js # Postgres pool, query helpers, migrations, seeding
|
||
│ │ └── migrations/ # 001–008 SQL files, auto-applied on startup
|
||
│ └── routes/
|
||
│ ├── auth.js # receives io
|
||
│ ├── groups.js # receives io
|
||
│ ├── messages.js # receives io
|
||
│ ├── usergroups.js # receives io
|
||
│ ├── schedule.js # receives io
|
||
│ ├── users.js
|
||
│ ├── settings.js
|
||
│ ├── push.js
|
||
│ ├── host.js # RosterChirp-Host control plane only
|
||
│ ├── about.js
|
||
│ └── help.js
|
||
└── frontend/
|
||
├── package.json # version bump required
|
||
├── vite.config.js
|
||
├── index.html # viewport: user-scalable=no (pinch handled via JS)
|
||
├── public/
|
||
│ ├── manifest.json
|
||
│ ├── sw.js # service worker / FCM push
|
||
│ └── icons/
|
||
└── src/
|
||
├── App.jsx
|
||
├── main.jsx # pinch→font-scale handler, pull-to-refresh blocker, iOS keyboard fix
|
||
├── index.css # CSS vars, dark mode, --font-scale, mobile autofill fixes
|
||
├── contexts/
|
||
│ ├── AuthContext.jsx
|
||
│ ├── SocketContext.jsx # force transports: ['websocket']
|
||
│ └── ToastContext.jsx
|
||
├── pages/
|
||
│ ├── Chat.jsx # main shell, page routing, all socket wiring
|
||
│ ├── Login.jsx
|
||
│ ├── ChangePassword.jsx
|
||
│ ├── UserManagerPage.jsx
|
||
│ └── GroupManagerPage.jsx
|
||
└── components/
|
||
├── Sidebar.jsx # groupMessagesMode prop
|
||
├── ChatWindow.jsx
|
||
├── MessageInput.jsx # onTextChange prop, fixed font size (no --font-scale)
|
||
├── Message.jsx # fonts scaled via --font-scale
|
||
├── NavDrawer.jsx
|
||
├── SchedulePage.jsx # ~1600 lines, desktop+mobile views
|
||
├── MobileEventForm.jsx
|
||
├── Avatar.jsx # consistent colour algorithm — must match Sidebar + ChatWindow
|
||
├── PasswordInput.jsx
|
||
├── GroupInfoModal.jsx
|
||
├── ProfileModal.jsx # appearance tab: font-scale slider (saved), pinch is session-only
|
||
├── SettingsModal.jsx
|
||
├── BrandingModal.jsx
|
||
├── HostPanel.jsx
|
||
├── NewChatModal.jsx
|
||
├── UserFooter.jsx
|
||
├── GlobalBar.jsx
|
||
├── ImageLightbox.jsx
|
||
├── UserProfilePopup.jsx
|
||
├── AddChildAliasModal.jsx
|
||
├── ScheduleManagerModal.jsx
|
||
├── ColourPickerSheet.jsx
|
||
└── SupportModal.jsx
|
||
```
|
||
|
||
### Dead code (safe to delete)
|
||
- `frontend/src/pages/HostAdmin.jsx`
|
||
- `frontend/src/components/UserManagerModal.jsx`
|
||
- `frontend/src/components/GroupManagerModal.jsx`
|
||
- `frontend/src/components/MobileGroupManager.jsx`
|
||
|
||
---
|
||
|
||
## Part 4 — Database Architecture
|
||
|
||
### Connection pool (`db.js`)
|
||
```javascript
|
||
const pool = new Pool({
|
||
host: process.env.DB_HOST || 'db',
|
||
port: parseInt(process.env.DB_PORT || '5432'),
|
||
database: process.env.DB_NAME || 'rosterchirp',
|
||
user: process.env.DB_USER || 'rosterchirp',
|
||
password: process.env.DB_PASSWORD || '',
|
||
max: 20,
|
||
idleTimeoutMillis: 30000,
|
||
connectionTimeoutMillis: 5000,
|
||
});
|
||
```
|
||
|
||
### Query helpers
|
||
```javascript
|
||
query(schema, sql, params) // SET search_path then SELECT
|
||
queryOne(schema, sql, params) // returns first row or null
|
||
queryResult(schema, sql, params) // returns full result object
|
||
exec(schema, sql, params) // INSERT/UPDATE/DELETE
|
||
withTransaction(schema, async (client) => { ... })
|
||
```
|
||
|
||
`SET search_path TO {schema}` is called before every query. `assertSafeSchema(name)` validates all schema names against `/^[a-z_][a-z0-9_]*$/`.
|
||
|
||
### Migrations
|
||
Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` are applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
|
||
|
||
Current migrations: 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
|
||
|
||
### Seeding order
|
||
`seedSettings → seedEventTypes → seedAdmin → seedUserGroups`
|
||
|
||
All seed functions use `ON CONFLICT DO NOTHING`.
|
||
|
||
### Core tables (PostgreSQL — schema-qualified at query time)
|
||
|
||
```sql
|
||
users (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
email TEXT UNIQUE NOT NULL,
|
||
password TEXT NOT NULL,
|
||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
|
||
is_default_admin BOOLEAN DEFAULT FALSE,
|
||
must_change_password BOOLEAN DEFAULT TRUE,
|
||
avatar TEXT,
|
||
about_me TEXT,
|
||
display_name TEXT,
|
||
hide_admin_tag BOOLEAN DEFAULT FALSE,
|
||
allow_dm INTEGER DEFAULT 1,
|
||
last_online TIMESTAMPTZ,
|
||
help_dismissed BOOLEAN DEFAULT FALSE,
|
||
date_of_birth DATE,
|
||
phone TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
groups (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
type TEXT DEFAULT 'public',
|
||
owner_id INTEGER REFERENCES users(id),
|
||
is_default BOOLEAN DEFAULT FALSE,
|
||
is_readonly BOOLEAN DEFAULT FALSE,
|
||
is_direct BOOLEAN DEFAULT FALSE,
|
||
is_managed BOOLEAN DEFAULT FALSE, -- managed private groups (Group Messages mode)
|
||
direct_peer1_id INTEGER,
|
||
direct_peer2_id INTEGER,
|
||
track_availability BOOLEAN DEFAULT FALSE,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
group_members (
|
||
id SERIAL PRIMARY KEY,
|
||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE(group_id, user_id)
|
||
)
|
||
|
||
messages (
|
||
id SERIAL PRIMARY KEY,
|
||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||
content TEXT,
|
||
type TEXT DEFAULT 'text', -- 'text' | 'system'
|
||
image_url TEXT,
|
||
reply_to_id INTEGER REFERENCES messages(id),
|
||
is_deleted BOOLEAN DEFAULT FALSE,
|
||
is_readonly BOOLEAN DEFAULT FALSE,
|
||
link_preview JSONB,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
reactions (
|
||
id SERIAL PRIMARY KEY,
|
||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
emoji TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE(message_id, user_id, emoji)
|
||
)
|
||
|
||
notifications (
|
||
id SERIAL PRIMARY KEY,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
type TEXT NOT NULL,
|
||
message_id INTEGER,
|
||
group_id INTEGER,
|
||
from_user_id INTEGER,
|
||
is_read BOOLEAN DEFAULT FALSE,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
active_sessions (
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
device TEXT NOT NULL DEFAULT 'desktop', -- 'mobile' | 'desktop'
|
||
token TEXT NOT NULL,
|
||
ua TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
PRIMARY KEY (user_id, device)
|
||
)
|
||
-- One session per device type per user. New login on same device displaces old session.
|
||
-- Displaced socket receives 'session:displaced' event.
|
||
|
||
push_subscriptions (
|
||
id SERIAL PRIMARY KEY,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
device TEXT DEFAULT 'desktop',
|
||
fcm_token TEXT NOT NULL,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE(user_id, device)
|
||
)
|
||
|
||
settings (
|
||
key TEXT PRIMARY KEY,
|
||
value TEXT NOT NULL,
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
-- Feature flag keys: feature_branding ('true'/'false'), feature_group_manager,
|
||
-- feature_schedule_manager, app_type ('RosterChirp-Chat'/'RosterChirp-Brand'/'RosterChirp-Team')
|
||
|
||
user_group_names (
|
||
user_id INTEGER NOT NULL,
|
||
group_id INTEGER NOT NULL,
|
||
name TEXT NOT NULL,
|
||
PRIMARY KEY (user_id, group_id)
|
||
)
|
||
|
||
user_groups (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
colour TEXT,
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
user_group_members (
|
||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (user_group_id, user_id)
|
||
)
|
||
|
||
group_user_groups (
|
||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (group_id, user_group_id)
|
||
)
|
||
|
||
events (
|
||
id SERIAL PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
description TEXT,
|
||
location TEXT,
|
||
start_at TIMESTAMPTZ NOT NULL,
|
||
end_at TIMESTAMPTZ NOT NULL,
|
||
all_day BOOLEAN DEFAULT FALSE,
|
||
is_public BOOLEAN DEFAULT TRUE,
|
||
created_by INTEGER REFERENCES users(id),
|
||
event_type_id INTEGER REFERENCES event_types(id),
|
||
recurrence_rule JSONB,
|
||
track_availability BOOLEAN DEFAULT FALSE,
|
||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
event_types (
|
||
id SERIAL PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
colour TEXT NOT NULL DEFAULT '#1a73e8',
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
|
||
event_availability (
|
||
id SERIAL PRIMARY KEY,
|
||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
status TEXT NOT NULL, -- 'going' | 'maybe' | 'not_going'
|
||
note TEXT,
|
||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||
UNIQUE(event_id, user_id)
|
||
)
|
||
|
||
tenants (
|
||
id SERIAL PRIMARY KEY,
|
||
slug TEXT UNIQUE NOT NULL,
|
||
schema_name TEXT UNIQUE NOT NULL,
|
||
display_name TEXT,
|
||
custom_domain TEXT,
|
||
status TEXT DEFAULT 'active',
|
||
plan TEXT DEFAULT 'team',
|
||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||
)
|
||
```
|
||
|
||
---
|
||
|
||
## Part 5 — Multi-Tenant Architecture (Host Mode)
|
||
|
||
### Tenant resolution
|
||
`tenantMiddleware` in `index.js` sets `req.schema` from the HTTP `Host` header before any route runs:
|
||
|
||
```javascript
|
||
// Subdomain tenants: {slug}.{HOST_DOMAIN} → schema 'tenant_{slug}'
|
||
// Custom domains: looked up in tenants table custom_domain column
|
||
// Host admin: HOST_DOMAIN itself → schema 'public'
|
||
const tenantDomainCache = new Map(); // in-process cache, cleared on tenant update
|
||
```
|
||
|
||
### Socket room naming (tenant-isolated)
|
||
All socket rooms are prefixed with the tenant schema:
|
||
```javascript
|
||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||
// e.g. R('tenant_acme', 'group', 42) → 'tenant_acme:group:42'
|
||
// Room types: group:{id}, user:{id}, schema:all
|
||
```
|
||
|
||
### Online user tracking
|
||
```javascript
|
||
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||
// Key includes schema to prevent cross-tenant ID collisions
|
||
```
|
||
|
||
---
|
||
|
||
## Part 6 — Auth & Session System
|
||
|
||
- JWT stored in **HTTP-only cookie** (`token`) AND `localStorage` (for PWA/mobile fallback)
|
||
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
|
||
- `teamManagerMiddleware` — checks if user is a team manager (role-based feature access)
|
||
- **Per-device sessions**: `active_sessions` PK is `(user_id, device)` — logging in on mobile doesn't kick out desktop
|
||
- Device: `mobile` if `/Mobile|Android|iPhone/i.test(ua)`, else `desktop`
|
||
- `must_change_password = true` redirects to `/change-password` after login
|
||
- `ADMPW_RESET=true` env var resets default admin password on container start
|
||
|
||
---
|
||
|
||
## Part 7 — Real-time Architecture (Socket.io)
|
||
|
||
### Connection
|
||
- Socket auth: JWT in `socket.handshake.auth.token`
|
||
- On connect: user joins `R(schema, 'group', id)` for all their groups, and `R(schema, 'user', userId)` for direct notifications, and `R(schema, 'schema', 'all')` for tenant-wide broadcasts
|
||
|
||
### Routes that receive `io`
|
||
```javascript
|
||
// All of these are called as: require('./routes/foo')(io)
|
||
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||
```
|
||
|
||
### Key socket events (server → client)
|
||
| Event | When |
|
||
|---|---|
|
||
| `message:new` | new message in a group |
|
||
| `message:deleted` | soft delete |
|
||
| `reaction:updated` | reaction toggled |
|
||
| `typing:start` / `typing:stop` | typing indicator |
|
||
| `notification:new` | mention or private message |
|
||
| `group:updated` | group settings changed |
|
||
| `group:removed` | user removed from group |
|
||
| `user:online` / `user:offline` | presence change |
|
||
| `users:online` | full online user list (on request) |
|
||
| `session:displaced` | same device logged in elsewhere |
|
||
| `schedule:event-created/updated/deleted` | schedule changes |
|
||
|
||
### Reconnect strategy (SocketContext.jsx)
|
||
```javascript
|
||
const socket = io({ transports: ['websocket'] }); // websocket only — no polling
|
||
// reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000
|
||
// visibilitychange → visible: call socket.connect() if disconnected
|
||
```
|
||
|
||
---
|
||
|
||
## Part 8 — FCM Push Notifications
|
||
|
||
### Architecture
|
||
```
|
||
Frontend (browser/PWA)
|
||
└─ Chat.jsx
|
||
├─ GET /api/push/firebase-config → fetches SDK config
|
||
├─ Initialises Firebase JS SDK + getMessaging()
|
||
├─ getToken(messaging, { vapidKey }) → FCM token
|
||
└─ POST /api/push/subscribe → registers in push_subscriptions
|
||
|
||
Backend (push.js)
|
||
├─ sendPushToUser(schema, userId, payload) → called from messages.js (primary)
|
||
│ and index.js socket handler (fallback)
|
||
└─ Firebase Admin SDK → Google FCM servers → device
|
||
```
|
||
|
||
### Message payload
|
||
```javascript
|
||
{
|
||
token: sub.fcm_token,
|
||
notification: { title, body },
|
||
data: { url: '/', groupId: '42' },
|
||
android: { priority: 'high', notification: { sound: 'default' } },
|
||
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
|
||
}
|
||
```
|
||
|
||
### Push trigger logic (messages.js)
|
||
- Frontend sends messages via `POST /api/messages/group/:groupId` (REST), not socket
|
||
- **Push must be fired from messages.js**, not just socket handler
|
||
- Private group: push to all `group_members` except sender
|
||
- Public group: push to all `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`
|
||
- Image messages: body `'📷 Image'`
|
||
|
||
### Stale token cleanup
|
||
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for:
|
||
`messaging/registration-token-not-registered`, `messaging/invalid-registration-token`, `messaging/invalid-argument`
|
||
|
||
### Required env vars
|
||
```
|
||
FIREBASE_API_KEY=
|
||
FIREBASE_PROJECT_ID=
|
||
FIREBASE_APP_ID=
|
||
FIREBASE_MESSAGING_SENDER_ID=
|
||
FIREBASE_VAPID_KEY= # Web Push certificate public key (Cloud Messaging tab)
|
||
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
|
||
```
|
||
|
||
---
|
||
|
||
## Part 9 — API Routes
|
||
|
||
All routes require `authMiddleware` except login/health.
|
||
|
||
### Auth (`/api/auth`)
|
||
- `POST /login`, `POST /logout`, `POST /change-password`, `GET /me`
|
||
|
||
### Users (`/api/users`)
|
||
- `GET /` — admin: full user list
|
||
- `POST /` — admin: create user
|
||
- `PATCH /:id` — admin: update role/status/password
|
||
- `PATCH /me/profile` — own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)
|
||
- `POST /me/avatar` — multipart: resize to 200×200 webp
|
||
- `GET /check-display-name?name=`
|
||
|
||
### Groups (`/api/groups`)
|
||
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
|
||
- `POST /` — create group or DM
|
||
- `PATCH /:id`, `DELETE /:id`
|
||
- `POST /:id/members`, `DELETE /:id/members/:userId`, `GET /:id/members`
|
||
- `POST /:id/custom-name`, `DELETE /:id/custom-name`
|
||
|
||
### Messages (`/api/messages`)
|
||
- `GET /?groupId=&before=&limit=` — 50 per page, cursor-based
|
||
- `POST /group/:groupId` — send message (REST path, fires push)
|
||
- `POST /image` — image upload
|
||
|
||
### User Groups (`/api/usergroups`)
|
||
- CRUD for user groups (team roster groupings), member management
|
||
|
||
### Schedule (`/api/schedule`)
|
||
- CRUD for events, event types, availability tracking
|
||
- `GET /events` — with date range, keyword filter, type filter, availability filter
|
||
- `POST /events/:id/availability` — set own availability
|
||
- `GET /events/:id/availability` — get all availability for an event
|
||
|
||
### Settings (`/api/settings`)
|
||
- `GET /`, `PATCH /`, `POST /logo`, `POST /icon/:key`
|
||
|
||
### Push (`/api/push`)
|
||
- `GET /firebase-config` — returns FCM SDK config
|
||
- `POST /subscribe` — save FCM token
|
||
- `GET /debug` — admin: list tokens + firebase status
|
||
- `POST /test` — send test push to own device
|
||
|
||
### Host (`/api/host`) — host mode only
|
||
- Tenant provisioning, plan management, host admin panel
|
||
|
||
### About (`/api/about`), Help (`/api/help`)
|
||
|
||
---
|
||
|
||
## Part 10 — Frontend Architecture
|
||
|
||
### Page navigation (Chat.jsx)
|
||
`page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'`
|
||
|
||
**Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)`.
|
||
|
||
### Group Messages vs Messages (Sidebar)
|
||
- `groupMessagesMode={false}` → public groups + non-managed private groups
|
||
- `groupMessagesMode={true}` → only `is_managed` private groups
|
||
|
||
### Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
|
||
- `MessageInput` fires `onTextChange(val)` on every keystroke and after send
|
||
- `ChatWindow` converts to boolean: `onHasTextChange?.(!!val.trim())`
|
||
- `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if switching with unsaved text
|
||
- `MessageInput` resets all state on `group?.id` change via `useEffect`
|
||
|
||
### Font scale system
|
||
- CSS var `--font-scale` on `<html>` element (default `1`, range `0.8`–`2.0`)
|
||
- **Message fonts** use `calc(Xrem * var(--font-scale))` — they scale
|
||
- **MessageInput font** is fixed (`0.875rem`) — it does NOT scale
|
||
- **Slider** (ProfileModal appearance tab) is the saved setting — persisted to `localStorage`
|
||
- **Pinch zoom** (main.jsx touch handler) is session-only — updates `--font-scale` but does NOT write to localStorage
|
||
- On startup, `--font-scale` is initialised from the saved localStorage value
|
||
|
||
### Avatar colour algorithm
|
||
Must be **identical** across `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`:
|
||
```javascript
|
||
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||
const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||
```
|
||
|
||
### Notification rules (group member changes, usergroups.js)
|
||
- 1 user added/removed → named system message: `"{Name} has joined/been removed from the conversation."`
|
||
- 2+ users added/removed → generic: `"N new members have joined/been removed from the conversation."`
|
||
|
||
### User deletion (v0.11.11+)
|
||
Email → `deleted_{id}@deleted`, name → `'Deleted User'`, all messages `is_deleted=TRUE`, DMs `is_readonly=TRUE`, sessions/subscriptions/availability purged.
|
||
|
||
---
|
||
|
||
## Part 11 — Schedule / Events
|
||
|
||
- All date/time stored as `TIMESTAMPTZ`
|
||
- `buildISO(date, time)` — builds timezone-aware ISO string from date + HH:MM input
|
||
- `toTimeIn(iso)` — extracts exact HH:MM (no rounding) for edit forms
|
||
- `roundUpToHalfHour()` — default start time for new events
|
||
- New events cannot have a start date/time in the past
|
||
- Recurring events: `expandRecurringEvent` returns occurrences within requested range only
|
||
- Keyword filter: unquoted = `\bterm` (prefix match), quoted = `\bterm\b` (exact word)
|
||
- Type filter does NOT shift date window to today (unlike keyword/availability filters)
|
||
- Clearing keyword also resets `filterFromDate`
|
||
|
||
Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of the time utilities (`roundUpToHalfHour`, `parseTypedTime`, `fmt12`, `toTimeIn`, `buildISO`).
|
||
|
||
---
|
||
|
||
## Part 12 — CSS Design System
|
||
|
||
### Variables (`:root` — light mode)
|
||
```css
|
||
--primary: #1a73e8;
|
||
--primary-dark: #1557b0;
|
||
--primary-light: #e8f0fe;
|
||
--surface: #ffffff;
|
||
--surface-variant: #f8f9fa;
|
||
--background: #f1f3f4;
|
||
--border: #e0e0e0;
|
||
--text-primary: #202124;
|
||
--text-secondary: #5f6368;
|
||
--text-tertiary: #9aa0a6;
|
||
--error: #d93025;
|
||
--success: #188038;
|
||
--bubble-out: #1a73e8;
|
||
--bubble-in: #f1f3f4;
|
||
--radius: 8px;
|
||
--font: 'Google Sans', 'Roboto', sans-serif;
|
||
--font-scale: 1; /* adjusted by pinch or slider */
|
||
```
|
||
|
||
### Dark mode (`[data-theme="dark"]`)
|
||
```css
|
||
--primary: #4d8fd4;
|
||
--primary-light: #1a2d4a;
|
||
--surface: #1e1e2e;
|
||
--surface-variant: #252535;
|
||
--background: #13131f;
|
||
--border: #2e2e45;
|
||
--text-primary: #e2e2f0;
|
||
--text-secondary: #9898b8;
|
||
--text-tertiary: #606080; /* exactly 6 hex digits — a common typo is 7 */
|
||
--bubble-out: #4d8fd4;
|
||
--bubble-in: #252535;
|
||
```
|
||
|
||
### Mobile input fixes
|
||
```css
|
||
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
|
||
@media (max-width: 768px) {
|
||
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
|
||
}
|
||
/* Autofill styling */
|
||
input:-webkit-autofill {
|
||
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
|
||
-webkit-text-fill-color: var(--text-primary) !important;
|
||
}
|
||
```
|
||
|
||
### Layout
|
||
- Desktop: sidebar (320px fixed) + chat area (flex-1)
|
||
- Mobile (≤768px): sidebar and chat stack — one visible at a time
|
||
- `--visual-viewport-height` and `--visual-viewport-offset` CSS vars exposed by main.jsx for iOS keyboard handling
|
||
- `.keyboard-open` class toggled on `<html>` when iOS keyboard is visible
|
||
|
||
---
|
||
|
||
## Part 13 — Docker & Deployment
|
||
|
||
### Dockerfile (multi-stage)
|
||
```dockerfile
|
||
FROM node:20-alpine AS builder
|
||
WORKDIR /app
|
||
COPY frontend/package*.json ./frontend/
|
||
RUN cd frontend && npm install
|
||
COPY frontend/ ./frontend/
|
||
RUN cd frontend && npm run build
|
||
|
||
FROM node:20-alpine
|
||
WORKDIR /app
|
||
COPY backend/package*.json ./
|
||
RUN npm install --omit=dev
|
||
COPY backend/ ./
|
||
COPY --from=builder /app/frontend/dist ./public
|
||
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||
EXPOSE 3000
|
||
CMD ["node", "src/index.js"]
|
||
```
|
||
|
||
### Version bump — all three locations
|
||
```
|
||
backend/package.json "version": "X.Y.Z"
|
||
frontend/package.json "version": "X.Y.Z"
|
||
build.sh VERSION="${1:-X.Y.Z}"
|
||
```
|
||
|
||
### Environment variables (key ones)
|
||
```
|
||
APP_TYPE=selfhost|host
|
||
HOST_DOMAIN= # host mode only
|
||
HOST_ADMIN_KEY= # host mode only
|
||
JWT_SECRET=
|
||
DB_HOST=db # set to 'pgbouncer' after Phase 1 scaling
|
||
DB_NAME=rosterchirp
|
||
DB_USER=rosterchirp
|
||
DB_PASSWORD= # avoid ! (shell interpolation issue in docker-compose)
|
||
ADMIN_EMAIL=
|
||
ADMIN_NAME=
|
||
ADMIN_PASS=
|
||
ADMPW_RESET=true|false
|
||
APP_NAME=rosterchirp
|
||
USER_PASS= # default password for bulk-created users
|
||
DEFCHAT_NAME=General Chat
|
||
FIREBASE_API_KEY=
|
||
FIREBASE_PROJECT_ID=
|
||
FIREBASE_APP_ID=
|
||
FIREBASE_MESSAGING_SENDER_ID=
|
||
FIREBASE_VAPID_KEY=
|
||
FIREBASE_SERVICE_ACCOUNT= # stringified JSON, no newlines
|
||
VAPID_PUBLIC= # legacy, auto-generated, no longer used for push delivery
|
||
VAPID_PRIVATE=
|
||
```
|
||
|
||
---
|
||
|
||
## Part 14 — Scale Architecture (Planned)
|
||
|
||
### Phase 1 — PgBouncer (zero code changes)
|
||
Add PgBouncer service to `docker-compose.host.yaml`. Point `DB_HOST=pgbouncer`. Increase Node pool `max` to 100. Use `POOL_MODE=transaction`. Eliminates the 20-connection bottleneck.
|
||
|
||
### Phase 2 — Redis (horizontal scaling)
|
||
Required for multiple Node instances:
|
||
1. `@socket.io/redis-adapter` — cross-instance socket fan-out
|
||
2. Replace `onlineUsers` Map with Redis `SADD`/`SREM` presence keys (`presence:{schema}:{userId}`)
|
||
3. Replace `tenantDomainCache` Map with Redis hash + TTL
|
||
4. Move uploads to Cloudflare R2 (S3-compatible) — `@aws-sdk/client-s3`
|
||
5. Force WebSocket transport only (eliminates polling sticky-session concern)
|
||
|
||
**Note on Phase 2:** `SET search_path` per query is safe with PgBouncer in transaction mode. Do NOT use `LISTEN/NOTIFY` or session-level state through PgBouncer.
|
||
|
||
---
|
||
|
||
## Part 15 — Known Gotchas & Decisions
|
||
|
||
| Gotcha | Solution |
|
||
|---|---|
|
||
| Multi-tenant schema isolation | Every query must go through `query(schema, ...)` — never raw `pool.query` |
|
||
| `assertSafeSchema()` | Always validate schema names before use — injection risk |
|
||
| Socket room names include schema | `R(schema, 'group', id)` not bare `group:{id}` — cross-tenant leakage otherwise |
|
||
| `onlineUsers` key is `${schema}:${userId}` | Two tenants can share the same integer user ID |
|
||
| FCM push fired from messages.js REST route | Frontend uses REST POST, not socket, for sending messages |
|
||
| Pinch zoom is session-only | Remove `localStorage.setItem` from touchend — slider is the saved setting |
|
||
| MessageInput font is fixed | Do not apply `--font-scale` to `.msg-input` font-size |
|
||
| iOS keyboard layout | Use `--visual-viewport-height` CSS var, not `100vh`, for the chat layout height |
|
||
| Avatar colour algorithm | Must be identical in Avatar.jsx, Sidebar.jsx, and ChatWindow.jsx |
|
||
| `is_managed` groups | Managed private groups appear in Group Messages view, not regular Messages view |
|
||
| Migrations are SQL files | Not try/catch ALTER TABLE — numbered SQL files in `migrations/` applied in order |
|
||
| DB_PASSWORD must not contain `!` | Shell interpolation breaks docker-compose env parsing |
|
||
| Routes accept `io` as parameter | `module.exports = (io) => router` — not default export |
|
||
| `session:displaced` socket event | Sent to the old socket when a new login displaces a session on the same device type |
|
||
| `help.md` is NOT in the volume path | Must be at `backend/src/data/help.md` — not in `/app/data/` which is volume-mounted |
|
||
| Dark mode `--text-tertiary` | Exactly 6 hex digits: `#606080` not `#6060808` |
|
||
| Web Share API for mobile file download | Use `navigator.share({ files: [...] })` on mobile; fall back to `a.click()` download on desktop |
|
||
|
||
---
|
||
|
||
## Part 16 — Features Checklist
|
||
|
||
### Messaging
|
||
- [x] Text messages with URL auto-linking and @mentions
|
||
- [x] Image upload + lightbox
|
||
- [x] Link preview cards (og: meta, server-side fetch)
|
||
- [x] Reply-to with quoted preview
|
||
- [x] Emoji reactions (quick bar + full picker)
|
||
- [x] Message soft-delete
|
||
- [x] Typing indicator
|
||
- [x] Date separators, consecutive-message collapsing
|
||
- [x] System messages
|
||
- [x] Emoji-only messages render larger
|
||
- [x] Infinite scroll / cursor-based pagination (50 per page)
|
||
|
||
### Groups & DMs
|
||
- [x] Public channels, private groups, read-only channels
|
||
- [x] Managed private groups (Group Messages view)
|
||
- [x] User-to-user direct messages
|
||
- [x] Per-user custom group display name
|
||
- [x] User groups (team roster groupings) with colour coding
|
||
- [x] Group availability tracking (events)
|
||
|
||
### Users & Profiles
|
||
- [x] Display name, avatar, about me, date of birth, phone
|
||
- [x] Hide admin tag option
|
||
- [x] Allow/block DMs toggle
|
||
- [x] Child/alias user accounts (`AddChildAliasModal`)
|
||
- [x] Bulk user import via CSV
|
||
|
||
### Admin
|
||
- [x] Settings modal: app name, logo, PWA icons
|
||
- [x] Branding modal (Brand+ plan)
|
||
- [x] User manager (full page): create, edit, suspend, reset password, bulk import
|
||
- [x] Group manager (full page): create groups, manage members, assign user groups
|
||
- [x] Schedule manager modal: event types with custom colours
|
||
- [x] Admin can delete any message
|
||
|
||
### Schedule
|
||
- [x] Event creation (one-time + recurring)
|
||
- [x] Event types with colour coding
|
||
- [x] Availability tracking (Going / Maybe / Not Going)
|
||
- [x] Download availability list (Web Share API on mobile, download link on desktop)
|
||
- [x] Keyword filter (prefix and exact-word modes)
|
||
- [x] Type filter, date range filter
|
||
- [x] Desktop and mobile views (separate implementations)
|
||
|
||
### PWA / Notifications
|
||
- [x] Installable PWA (manifest, service worker, icons)
|
||
- [x] FCM push notifications (Android working; iOS in progress)
|
||
- [x] App badge on home screen icon
|
||
- [x] Page title unread count `(N) App Name`
|
||
- [x] Per-conversation notification grouping
|
||
|
||
### UX
|
||
- [x] Light / dark mode (CSS vars, saved localStorage)
|
||
- [x] Font scale slider (saved setting) + pinch zoom (session only)
|
||
- [x] Mobile-responsive layout
|
||
- [x] Pull-to-refresh blocked in PWA standalone mode
|
||
- [x] iOS keyboard layout fix (`--visual-viewport-height`)
|
||
- [x] Getting Started help modal
|
||
- [x] About modal, Support modal
|
||
- [x] User profile popup (click any avatar)
|
||
- [x] NavDrawer (hamburger menu)
|
||
|
||
---
|
||
|
||
## Part 17 — One-Shot Prompt (Copy-Paste to Start)
|
||
|
||
```
|
||
Build a self-hosted team chat PWA called "RosterChirp". Single Docker container.
|
||
Supports selfhost (single tenant) and host (multi-tenant via Postgres schema per tenant) modes.
|
||
|
||
STACK: Node 20 + Express + Socket.io + PostgreSQL 16 (pg npm package) + JWT
|
||
(HTTP-only cookie + localStorage) + bcryptjs + React 18 + Vite.
|
||
Push via Firebase Cloud Messaging (firebase-admin backend, firebase frontend SDK).
|
||
Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind.
|
||
|
||
MULTI-TENANT: tenantMiddleware sets req.schema from Host header. assertSafeSchema()
|
||
validates all schema names. Socket rooms prefixed: `${schema}:${type}:${id}`.
|
||
onlineUsers Map key is `${schema}:${userId}` to prevent cross-tenant ID collisions.
|
||
Every DB query calls SET search_path TO {schema} first.
|
||
|
||
MIGRATIONS: Numbered SQL files in backend/src/models/migrations/ (001, 002, ...).
|
||
Auto-applied on startup via runMigrations(schema). Never edit applied migrations.
|
||
|
||
ROUTES accept io as parameter: module.exports = (io) => router
|
||
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||
|
||
KEY FEATURES:
|
||
- Public/private/readonly channels, managed private groups (Group Messages view),
|
||
user-to-user DMs, @mentions, emoji reactions, reply-to, image upload, link previews,
|
||
soft-delete, typing indicator, unread badges, page title (N) count
|
||
- User groups (team roster groupings) with colour coding
|
||
- Schedule: events, event types, availability tracking, recurring events
|
||
- Font scale: --font-scale CSS var on <html>. Message fonts scale with it. MessageInput
|
||
font is FIXED (no --font-scale). Slider in ProfileModal = saved setting (localStorage).
|
||
Pinch zoom = session only (touchend must NOT write to localStorage).
|
||
- FCM push: fired from messages.js REST route (not socket handler). sendPushToUser helper.
|
||
Stale token cleanup on FCM error codes.
|
||
- Avatar colour: AVATAR_COLORS array, charCodeAt(0) % length. Must be identical in
|
||
Avatar.jsx, Sidebar.jsx, ChatWindow.jsx.
|
||
- User deletion: email scrubbed, messages nulled, DMs set readonly, sessions purged.
|
||
- Web Share API for mobile file downloads; a.click() fallback for desktop.
|
||
|
||
GOTCHAS:
|
||
- DB_PASSWORD must not contain '!' (shell interpolation in docker-compose)
|
||
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
|
||
- help.md at backend/src/data/help.md (NOT /app/data — volume-mounted, shadows files)
|
||
- Session displaced: socket receives 'session:displaced' when new login takes device slot
|
||
- iOS keyboard: use --visual-viewport-height CSS var (not 100vh) for chat layout height
|
||
- Routes that emit socket events receive io as first argument, not default export
|
||
```
|