# jama — Complete Vibe-Code Build Prompt (v0.1.0) > **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 was reverse-engineered from the real build history of jama across ~9 sessions. --- ## Part 1 — What to Build (Product Brief) Build a **self-hosted team chat Progressive Web App** called **jama**. 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. There is no cloud dependency — everything (database, uploads, API, frontend) lives in one Docker image. ### Core philosophy - Simple to self-host (one `docker compose up`) - No external services required (no Firebase, no Pusher, no S3) - Works as an installed PWA on Android, iOS, and desktop Chrome/Edge - Instant real-time messaging via WebSockets (Socket.io) - Push notifications via Web Push (VAPID), works when app is backgrounded --- ## Part 2 — Tech Stack | Layer | Technology | |---|---| | Backend runtime | Node.js 20 (Alpine) | | Backend framework | Express.js | | Real-time | Socket.io (server + client) | | Database | SQLite via `better-sqlite3` | | Auth | JWT in HTTP-only cookies + `jsonwebtoken` | | Password hashing | `bcryptjs` | | Push notifications | Web Push (`web-push` npm package, VAPID) | | Image processing | `sharp` (avatar/logo resizing) | | Frontend framework | React 18 + Vite | | Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) | | Frontend fonts | Google Sans + Roboto (via Google Fonts CDN) | | 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` with named volumes | ### Key npm packages (backend) ``` express, socket.io, better-sqlite3, bcryptjs, jsonwebtoken, cookie-parser, cors, multer, sharp, web-push ``` ### Key npm packages (frontend) ``` react, react-dom, vite, socket.io-client, @emoji-mart/react, @emoji-mart/data, marked ``` --- ## Part 3 — Project File Structure ``` jama/ ├── Dockerfile # Multi-stage: Vite build → Node runtime ├── docker-compose.yaml # Single service, two named volumes ├── build.sh # Docker build + optional push script ├── .env.example # All configurable env vars documented ├── about.json.example # Optional About modal customisation ├── data/ # Gitignored — runtime DB lives in Docker volume ├── backend/ │ ├── package.json │ └── src/ │ ├── index.js # Express app, Socket.io server, all socket events │ ├── middleware/ │ │ └── auth.js # JWT cookie auth middleware — exports { authMiddleware } │ ├── models/ │ │ └── db.js # Schema init, migrations, seed functions │ ├── routes/ │ │ ├── auth.js # Login, logout, register, change password │ │ ├── users.js # User CRUD, avatar upload, profile update │ │ ├── groups.js # Group CRUD, members, direct messages │ │ ├── messages.js # Message history, image upload │ │ ├── settings.js # App settings (name, logo, icons) │ │ ├── push.js # VAPID keys, subscription save, send helper │ │ ├── about.js # Serves about.json for About modal │ │ └── help.js # Serves help.md for Getting Started modal │ ├── utils/ │ │ └── linkPreview.js # Fetches og: meta for URL previews │ └── data/ │ └── help.md # Help content (NOT in /app/data volume) └── frontend/ ├── package.json ├── vite.config.js ├── index.html └── src/ ├── main.jsx ├── App.jsx ├── index.css # Global styles, CSS variables, dark mode ├── contexts/ │ ├── AuthContext.jsx # User state, login/logout │ ├── SocketContext.jsx # Socket.io connection, reconnect logic │ └── ToastContext.jsx # Global toast notifications ├── pages/ │ ├── Login.jsx │ ├── Login.css │ ├── Chat.jsx # Main app shell — groups state, socket events │ ├── Chat.css │ └── ChangePassword.jsx ├── components/ │ ├── Sidebar.jsx # Group/DM list, unread badges │ ├── Sidebar.css │ ├── ChatWindow.jsx # Message list, typing indicator, header │ ├── ChatWindow.css │ ├── Message.jsx # Individual message — reactions, reply, delete, link preview │ ├── Message.css │ ├── MessageInput.jsx # Text input, image upload, @mention, emoji │ ├── MessageInput.css │ ├── Avatar.jsx # Circular avatar with initials fallback │ ├── GlobalBar.jsx # Top bar (mobile only) │ ├── GroupInfoModal.jsx # Group details, members, custom name │ ├── ProfileModal.jsx # User profile, display name, avatar, about │ ├── SettingsModal.jsx # Admin: app name, logo, icons, VAPID, users │ ├── UserManagerModal.jsx # Admin: user list, create, suspend, reset pw │ ├── NewChatModal.jsx # Create group or start DM │ ├── HelpModal.jsx # Getting Started modal (renders help.md) │ ├── AboutModal.jsx # About this app modal │ ├── SupportModal.jsx # Contact support (sends message to Support group) │ ├── ImageLightbox.jsx # Full-screen image viewer │ └── UserProfilePopup.jsx # Click avatar → see profile, start DM └── utils/ └── api.js # All fetch calls to /api/*, parseTS helper └── public/ ├── sw.js # Service worker: cache, push, badge ├── manifest.json # Static fallback (dynamic manifest served by Express) ├── favicon.ico └── icons/ # PWA icons: 192, 192-maskable, 512, 512-maskable, jama.png, logo-64.png ``` --- ## Part 4 — Database Schema Use SQLite with WAL mode and foreign keys enabled. ```sql -- Core tables (CREATE TABLE IF NOT EXISTS for all) users ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, -- login/real name email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, -- bcrypt hash role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member' status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended' is_default_admin INTEGER DEFAULT 0, must_change_password INTEGER DEFAULT 1, avatar TEXT, -- relative URL e.g. /uploads/avatars/x.webp about_me TEXT, display_name TEXT, -- user-set public display name (nullable) hide_admin_tag INTEGER DEFAULT 0, last_online TEXT, -- datetime('now') string help_dismissed INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ) groups ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT DEFAULT 'public', -- 'public' | 'private' owner_id INTEGER REFERENCES users(id), is_default INTEGER DEFAULT 0, -- 1 = General Chat (cannot be deleted) is_readonly INTEGER DEFAULT 0, is_direct INTEGER DEFAULT 0, -- 1 = user-to-user DM direct_peer1_id INTEGER, -- DM: first user ID direct_peer2_id INTEGER, -- DM: second user ID created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')) ) group_members ( id INTEGER PRIMARY KEY AUTOINCREMENT, group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, joined_at TEXT DEFAULT (datetime('now')), UNIQUE(group_id, user_id) ) messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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 INTEGER DEFAULT 0, -- soft delete link_preview TEXT, -- JSON string of og: meta created_at TEXT DEFAULT (datetime('now')) ) reactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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 TEXT DEFAULT (datetime('now')), UNIQUE(message_id, user_id, emoji) -- one reaction type per user per message ) notifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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 INTEGER DEFAULT 0, created_at TEXT DEFAULT (datetime('now')) ) active_sessions ( user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, device TEXT NOT NULL DEFAULT 'desktop', -- 'desktop' | 'mobile' token TEXT NOT NULL, ua TEXT, created_at TEXT DEFAULT (datetime('now')), PRIMARY KEY (user_id, device) ) push_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, endpoint TEXT NOT NULL, p256dh TEXT NOT NULL, auth TEXT NOT NULL, device TEXT DEFAULT 'desktop', created_at TEXT DEFAULT (datetime('now')), UNIQUE(user_id, device) ) settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ) -- Default settings keys: app_name, logo_url, pw_reset_active, icon_newchat, -- icon_groupinfo, pwa_icon_192, pwa_icon_512, vapid_public, vapid_private user_group_names ( user_id INTEGER NOT NULL, group_id INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY (user_id, group_id) ) -- Allows each user to set a personal display name for any group/DM ``` ### Migration pattern All schema changes must be additive `ALTER TABLE` statements wrapped in try/catch (column-already-exists errors are ignored). Never drop or recreate tables in migrations — this is a live production DB. ```javascript try { db.exec("ALTER TABLE users ADD COLUMN last_online TEXT"); } catch (e) { /* already exists */ } ``` --- ## Part 5 — Auth & Session System - JWT stored in **HTTP-only cookie** (`token`), 30-day expiry - `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user` - **Per-device sessions**: one session per `(user_id, device)` pair — logging in on mobile doesn't kick out desktop - Device is detected from User-Agent: mobile if `/Mobile|Android|iPhone/i.test(ua)` - `active_sessions` table stores current tokens for invalidation on logout/suspend - `must_change_password = 1` redirects to `/change-password` after login - Admin can reset any user's password; `ADMPW_RESET=true` env var resets default admin password on container start --- ## Part 6 — Real-time Architecture (Socket.io) ### Connection - Socket auth: JWT token in `socket.handshake.auth.token` - On connect: user joins `group:{id}` rooms for all their groups, and `user:{id}` for direct notifications - `onlineUsers` Map: `userId → Set` (multiple tabs/devices) ### Socket events (server → client) | Event | Payload | When | |---|---|---| | `message:new` | full message object with reactions | new message in a group | | `message:deleted` | `{ messageId, groupId }` | message soft-deleted | | `reaction:updated` | `{ messageId, reactions[] }` | reaction toggled | | `typing:start` | `{ userId, groupId, user }` | user started typing | | `typing:stop` | `{ userId, groupId }` | user stopped typing | | `notification:new` | `{ type, groupId, fromUser }` | mention or private message | | `group:updated` | group object | group renamed, settings changed | | `group:removed` | `{ groupId }` | user removed from group | ### Socket events (client → server) | Event | Payload | |---|---| | `message:send` | `{ groupId, content, replyToId, imageUrl }` | | `message:delete` | `{ messageId }` | | `reaction:toggle` | `{ messageId, emoji }` | | `typing:start` | `{ groupId }` | | `typing:stop` | `{ groupId }` | | `group:join-room` | `{ groupId }` | | `group:leave-room` | `{ groupId }` | ### Reconnect strategy (SocketContext.jsx) ```javascript // Aggressive reconnect for mobile PWA (which drops connections when backgrounded) reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000 // Also: on visibilitychange → visible, call socket.connect() if disconnected // Also: on socket 'connect' event, reload groups (catches missed messages) ``` --- ## Part 7 — Push Notifications (Web Push / VAPID) - Generate VAPID keys once (stored in `settings` table as `vapid_public` / `vapid_private`) - Endpoint: `POST /api/push/subscribe` — saves subscription per `(user_id, device)` - Push is sent when the target user has **no active socket connections** (truly offline) - Push payload: `{ title, body, url, groupId }` - `groupId` in payload enables per-conversation notification grouping via `tag` in `showNotification` - Service worker skips notification if app window is currently visible (already open) - Re-register push subscription on every `visibilitychange → visible` event (mobile browsers drop subscriptions when PWA is backgrounded) - `navigator.setAppBadge(count)` updates home screen badge (Chrome/Edge/Android — not iOS) --- ## Part 8 — API Routes All routes require `authMiddleware` except login/register/health. ### Auth (`/api/auth`) - `POST /login` — returns JWT cookie, user object - `POST /logout` — clears cookie, removes active_session - `POST /register` — admin only - `POST /change-password` - `GET /me` — returns current user from JWT ### Users (`/api/users`) - `GET /` — admin only: full user list with last_online - `POST /` — admin: create user - `PATCH /:id` — admin: update role/status/password - `PATCH /me/profile` — update own display_name, about_me, hide_admin_tag - `POST /me/avatar` — multipart: upload + sharp-resize to 200×200 webp - `GET /check-display-name?name=` — returns `{ taken: bool }` ### Groups (`/api/groups`) - `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs - `POST /` — create group or start DM (checks for existing DM between pair) - `PATCH /:id` — rename group - `DELETE /:id` — delete group (admin only, not default group) - `POST /:id/members` — add member - `DELETE /:id/members/:userId` — remove member - `GET /:id/members` — list members - `POST /:id/custom-name` — set per-user custom group name - `DELETE /:id/custom-name` — remove custom name ### Messages (`/api/messages`) - `GET /?groupId=&before=&limit=` — paginated message history (50 per page) - `POST /image` — multipart image upload, returns `{ url }` ### Settings (`/api/settings`) - `GET /` — returns all settings (public: app_name, logo_url; admin: everything) - `PATCH /` — admin: update settings - `POST /logo` — admin: upload logo - `POST /icon/:key` — admin: upload PWA icon ### Push (`/api/push`) - `GET /vapid-public` — returns VAPID public key (unauthenticated) - `POST /subscribe` — save push subscription ### Help (`/api/help`) - `GET /` — returns rendered help.md content - `GET /status` — returns `{ dismissed: bool }` for current user - `POST /dismiss` — set help_dismissed flag --- ## Part 9 — Frontend Architecture ### State management No Redux. Three React contexts: 1. **AuthContext** — `user`, `login()`, `logout()`, `updateUser()` 2. **SocketContext** — `socket` instance, handles connection lifecycle 3. **ToastContext** — `toast(message, type)` for notifications ### Chat.jsx (main shell) - Holds `groups` state: `{ publicGroups[], privateGroups[] }` - Handles all Socket.io group-level events and dispatches to child components - On `message:new`: updates `last_message`, `last_message_at`, `last_message_user_id`, re-sorts private groups newest-first - Tracks `unreadGroups: Map` — increments on new messages in non-active groups (or active group when window is hidden) - Manages `activeGroupId`, `notifications[]`, PWA badge count - Re-registers push on `visibilitychange → visible` - Shows HelpModal if user hasn't dismissed it ### Sidebar.jsx - Renders public groups (fixed order: default first, then alphabetical) - Renders private groups (sorted newest-message first) - DM entries show peer's avatar (if set), `Display Name (real name)` format when peer has a display name - Unread badge counts (blue dot for unread, numbered badge for @mentions) - Page title format: `(N) App Name` where N is total unread count ### ChatWindow.jsx - Renders message list with infinite scroll upward (load older messages on scroll to top) - Typing indicator with bouncing dots animation - Header: for DMs shows peer avatar + `Display Name (real name)` title - Marks messages as read when group becomes active ### Message.jsx features - Date separators between days - Consecutive messages from same user collapse avatar (show once) - Emoji-only messages render larger - Quick reactions bar (👍❤️😂😮😢🙏) + full emoji picker - Reply-to preview with quoted content - Link preview cards (og: meta, fetched server-side to avoid CORS) - Image messages with lightbox on click - @mention highlighting - Delete button (own messages + admin/owner can delete any) - Long-press on mobile = show action menu ### MessageInput.jsx - Auto-expanding textarea - `@` triggers mention picker (searches group members) - Image attach button (uploads immediately, inserts URL into content) - Emoji picker button - `Enter` to send, `Shift+Enter` for newline - Typing events: emit `typing:start` on input, `typing:stop` after 2s idle --- ## Part 10 — CSS Design System ### Variables (`:root` — day 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; /* own message bubble */ --bubble-in: #f1f3f4; /* other's message bubble */ --radius: 8px; --radius-lg: 16px; --radius-xl: 24px; --shadow-sm: 0 1px 3px rgba(0,0,0,0.12); --shadow-md: 0 2px 8px rgba(0,0,0,0.15); --font: 'Google Sans', 'Roboto', sans-serif; ``` ### 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; /* NOTE: exactly 6 hex digits — a common typo is 7 */ --bubble-out: #4d8fd4; --bubble-in: #252535; ``` Dark mode is toggled by setting `document.documentElement.setAttribute('data-theme', 'dark')` and saved to `localStorage`. ### Layout - Desktop: sidebar (320px fixed) + chat area (flex-1) side by side - Mobile (≤768px): sidebar and chat stack — only one visible at a time, back button navigates - Chat area: header + scrollable message list (flex-1, overflow-y: auto) + input fixed at bottom --- ## Part 11 — Docker & Deployment ### Dockerfile (multi-stage) ```dockerfile # Stage 1: Build frontend 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 # Stage 2: Runtime FROM node:20-alpine RUN apk add --no-cache sqlite python3 make g++ WORKDIR /app COPY backend/package*.json ./ RUN npm install --omit=dev RUN apk del python3 make g++ COPY backend/ ./ COPY --from=builder /app/frontend/dist ./public RUN mkdir -p /app/data /app/uploads/avatars /app/uploads/logos /app/uploads/images EXPOSE 3000 CMD ["node", "src/index.js"] ``` **Critical**: `help.md` lives at `backend/src/data/help.md` → copied to `/app/src/data/help.md` in the image. It must NOT be placed in `/app/data/` which is volume-mounted and will hide baked-in files. ### docker-compose.yaml ```yaml version: '3.8' services: jama: image: jama:${JAMA_VERSION:-latest} restart: unless-stopped ports: - "${PORT:-3000}:3000" environment: - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local} - ADMIN_NAME=${ADMIN_NAME:-Admin User} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234} - ADMPW_RESET=${ADMPW_RESET:-false} # set true to reset admin pw on next start - JWT_SECRET=${JWT_SECRET:-changeme} - APP_NAME=${APP_NAME:-jama} - DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat} volumes: - jama_db:/app/data # SQLite + uploads persist here - jama_uploads:/app/uploads volumes: jama_db: jama_uploads: ``` ### Volume gotcha Named Docker volumes shadow any files baked into that directory at image build time. Any file that must survive across rebuilds AND be editable at runtime goes in the volume. Any file that is bundled with the app and should not be overridden by the volume (like `help.md`) must be stored outside the volume mount path. --- ## Part 12 — PWA / Service Worker - `manifest.json` is **dynamically served by Express** (not static) so app name and icons update without a rebuild - Service worker (`sw.js`) handles: - Asset caching for offline support - Web Push notifications - Per-conversation notification grouping by `tag: 'jama-group-{groupId}'` - Skip notification if app window is currently visible (`clients.matchAll`) - App badge sync via `SET_BADGE` message from the app - Clear badge on notification click --- ## Part 13 — Features Checklist ### Messaging - [x] Text messages with URL auto-linking - [x] @mention with inline picker (type `@` to trigger) - [x] Image upload + display with 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 delete (soft delete — shows "deleted" placeholder) - [x] Typing indicator with animated dots - [x] Date separators - [x] System messages (e.g. "User joined") - [x] Emoji-only messages render at larger size - [x] Infinite scroll (load older messages on scroll to top) ### Groups & DMs - [x] Public channels (# icon, auto-join for all users) - [x] Private groups (lock icon, invite-only) - [x] Read-only channels (📢, only admins can post) - [x] User-to-user direct messages - [x] Support group (private, all admins auto-added) - [x] Per-user custom group display name (only visible to that user) - [x] Group message list sorted newest-first (re-sorted on live messages) - [x] DM shows peer's avatar and `Display Name (real name)` format ### Users & Profiles - [x] User display name (separate from login name) - [x] User avatar (upload, resize to 200×200 webp) - [x] About me text - [x] Hide admin tag option - [x] Last online timestamp in admin user list - [x] Suspend/unsuspend users ### Admin - [x] Settings modal: app name, logo, PWA icons, VAPID key generation - [x] User manager: create, edit, suspend, reset password - [x] Admin can delete any message - [x] Custom icons for UI elements (new chat button, group info button) ### PWA / Notifications - [x] Installable PWA (manifest, service worker, icons) - [x] Web Push notifications (VAPID, works when app is closed) - [x] App badge on home screen icon (Chrome/Edge/Android) - [x] Page title unread count `(N) App Name` - [x] Per-conversation notification grouping - [x] Push re-registration on mobile resume (prevents subscription loss) ### UX - [x] Day / dark mode toggle (saved to localStorage) - [x] Getting Started help modal (markdown, per-user dismiss) - [x] About modal (customisable via about.json) - [x] Support modal (sends message to Support group) - [x] User profile popup (click any avatar) - [x] Mobile-responsive layout (sidebar ↔ chat toggle) - [x] `overscroll-behavior-y: none` (prevents pull-to-refresh viewport shift in PWA) --- ## Part 14 — Known Gotchas & Decisions These are things that will catch you out if you don't know them upfront. | Gotcha | Solution | |---|---| | `auth.js` exports `{ authMiddleware }` as an object | Always destructure: `const { authMiddleware } = require('../middleware/auth')` | | Docker named volume shadows baked-in files | Never put runtime-required bundled files in `/app/data/` | | Dark mode `--text-tertiary` hex typo | Value must be exactly 6 hex digits: `#606080` not `#6060808` | | `last_message_user_id` not set on live updates | Must include `last_message_user_id: msg.user_id` in the `setGroups` update — not just content and timestamp | | Mobile push subscriptions drop when PWA is backgrounded | Re-register push on every `visibilitychange → visible` event | | Socket.io reconnect on mobile PWA resume | Add `visibilitychange` handler that calls `socket.connect()` if disconnected | | Minimized window doesn't count unread in active chat | Check `document.visibilityState === 'hidden'` before skipping unread increment | | JSX edits with `sed` break on special characters | Use Python string replacement for all JSX file edits | | Multiple named exports vs default exports | Routes that need to share `io` accept it as a parameter: `module.exports = (io) => router` | | Page title overwritten on settings refresh | Preserve `(N)` prefix: `const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] \|\| ''` | | `navigator.setAppBadge` not in service worker scope | Use `self.navigator.setAppBadge` inside `sw.js` | --- ## Part 15 — Prompt Engineering Notes When using this prompt with an AI coding assistant, these practices produced the best results across the 9 build sessions: ### Do - **Give the full schema upfront** — the AI can design all routes consistently without asking - **Specify the exact npm packages** — prevents the AI choosing different libs mid-build - **Name every socket event** — socket event naming inconsistencies between client/server are silent bugs - **Describe the volume gotcha explicitly** — this caused a real production bug - **Ask for migrations, not table drops** — always say "wrap in try/catch, column-already-exists is OK" - **Specify `module.exports` patterns** — named vs default exports is a common source of `is not a function` crashes - **Request CSS variable names explicitly** — prevents the AI inventing colour values instead of using the system - **For JSX edits, request Python string replacement** — sed with special characters destroys JSX ### Avoid - Asking for partial implementations and filling in later — the AI will make different assumptions - Letting the AI choose the DB (it will pick Postgres — SQLite is intentional here) - Letting the AI choose the state management (it will add Redux/Zustand — contexts are intentional) - Vague feature requests like "add notifications" — specify: socket-based for online users, push for offline, badge for all ### Iteration pattern that worked 1. **Session 1**: Stack + schema + auth + basic messaging 2. **Session 2**: Groups, DMs, reactions, replies 3. **Session 3**: Push notifications, service worker, PWA manifest 4. **Session 4**: Admin panel, user management, settings 5. **Session 5**: Mobile fixes, reconnect logic, offline resilience 6. **Session 6+**: Feature additions and bug fixes Each session started by re-reading the previous session summary so the AI had full context on decisions made. --- ## Part 16 — One-Shot Prompt (Copy-Paste to Start) > Use this if you want to try building jama in a single session. It is dense by design — the AI needs all of it. ``` Build a self-hosted team chat PWA called "jama". Single Docker container. No external services. STACK: Node 20 + Express + Socket.io + SQLite (better-sqlite3) + JWT (HTTP-only cookie) + bcryptjs + React 18 + Vite. Push via web-push (VAPID). Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind. STRUCTURE: - backend/src/index.js — Express app + all Socket.io events - backend/src/middleware/auth.js — exports { authMiddleware } - backend/src/models/db.js — schema, migrations (additive ALTER TABLE in try/catch only, never drop), seed - backend/src/routes/ — auth, users, groups, messages, settings, push, about, help - frontend/src/ — React app with AuthContext, SocketContext, ToastContext - frontend/public/sw.js — service worker for push + badge + cache - Dockerfile — multi-stage (Vite builder → Node runtime) - docker-compose.yaml — single service, volumes: jama_db:/app/data, jama_uploads:/app/uploads DATABASE TABLES: users (name, email, password, role, status, is_default_admin, must_change_password, avatar, about_me, display_name, hide_admin_tag, last_online, help_dismissed), groups (name, type, owner_id, is_default, is_readonly, is_direct, direct_peer1_id, direct_peer2_id), group_members, messages (content, type, image_url, reply_to_id, is_deleted, link_preview), reactions (UNIQUE message+user+emoji), notifications, active_sessions (PRIMARY KEY user_id+device), push_subscriptions (UNIQUE user_id+device), settings, user_group_names (PRIMARY KEY user_id+group_id). AUTH: JWT in HTTP-only cookie. Per-device sessions (desktop/mobile detected from UA). must_change_password redirects to /change-password. ADMPW_RESET env var resets admin. SOCKET EVENTS: message:new, message:deleted, reaction:updated, typing:start/stop, notification:new, group:updated, group:removed. Server tracks onlineUsers Map for push fallback. Reconnect: reconnectionDelay 500ms, re-register push on visibilitychange. FEATURES: Public/private/readonly channels, user-to-user DMs, @mentions, emoji reactions (toggle — same emoji off, different emoji replaces), reply-to, image upload, link previews (server-side og: fetch), message soft-delete, typing indicator (bouncing dots — use CSS var(--text-tertiary) for dot colour), unread badges, page title (N) count, PWA badge via navigator.setAppBadge, Web Push with per-group notification tags, per-user custom group names, user display names, avatars (200x200 webp), day/dark mode (CSS vars, saved localStorage), getting-started help modal (renders help.md via marked), admin settings (app name, logo, PWA icons, VAPID keygen), admin user manager, Support group (auto-created, all admins added). LAYOUT: Desktop: 320px sidebar + flex chat. Mobile ≤768px: sidebar/chat toggle. CSS system: Google Sans font, --primary #1a73e8, dark mode on [data-theme="dark"]. GOTCHAS: - help.md must be at backend/src/data/help.md (NOT /app/data — that path is volume-mounted and will shadow baked-in files) - auth.js must export { authMiddleware } as named export - dark mode --text-tertiary must be exactly 6 hex digits: #606080 - last_message_user_id must be included in real-time setGroups update - Use Python string replacement for JSX file edits, never sed - self.navigator.setAppBadge (not navigator) inside service worker scope ``` ```