From 819d60d693b48b66b3511b8486067c3bbe667284 Mon Sep 17 00:00:00 2001
From: Ricky Stretch
Date: Sun, 22 Mar 2026 20:15:57 -0400
Subject: [PATCH] v0.12.0 codes for FCM and rebranded jama to RosterChirp
---
.env | 4 +-
.env.example | 31 ++--
CLAUDE.md | 60 +++----
Caddyfile.example | 22 +--
Dockerfile | 4 +-
FUTURE_FEATURES.md | 31 ++++
KNOWN_LIMITATIONS.md | 8 +-
backend/package.json | 4 +-
backend/src/index.js | 8 +-
backend/src/models/db.js | 22 +--
.../src/models/migrations/007_fcm_push.sql | 5 +
backend/src/models/migrations/008_rebrand.sql | 4 +
backend/src/routes/about.js | 6 +-
backend/src/routes/host.js | 10 +-
backend/src/routes/push.js | 151 +++++++++---------
backend/src/routes/settings.js | 14 +-
backend/src/utils/linkPreview.js | 2 +-
build.sh | 14 +-
docker-compose.host.yaml | 40 ++---
docker-compose.yaml | 30 ++--
frontend/index.html | 6 +-
frontend/package.json | 5 +-
frontend/public/manifest.json | 4 +-
frontend/public/sw.js | 89 ++++++-----
frontend/src/App.jsx | 2 +-
frontend/src/components/AboutModal.jsx | 4 +-
frontend/src/components/BrandingModal.jsx | 8 +-
frontend/src/components/ChatWindow.jsx | 8 +-
frontend/src/components/GlobalBar.jsx | 10 +-
frontend/src/components/HostPanel.jsx | 16 +-
frontend/src/components/SettingsModal.jsx | 36 ++---
frontend/src/components/Sidebar.jsx | 10 +-
frontend/src/components/UserFooter.jsx | 4 +-
frontend/src/contexts/AuthContext.jsx | 4 +-
frontend/src/contexts/SocketContext.jsx | 2 +-
frontend/src/main.jsx | 2 +-
frontend/src/pages/Chat.jsx | 66 ++++----
frontend/src/pages/HostAdmin.jsx | 22 +--
frontend/src/pages/Login.jsx | 4 +-
frontend/src/utils/api.js | 17 +-
40 files changed, 426 insertions(+), 363 deletions(-)
create mode 100644 FUTURE_FEATURES.md
create mode 100644 backend/src/models/migrations/007_fcm_push.sql
create mode 100644 backend/src/models/migrations/008_rebrand.sql
diff --git a/.env b/.env
index 6ce1c14..85a6991 100644
--- a/.env
+++ b/.env
@@ -27,4 +27,6 @@ HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
#** Optional
PORT=3144
-TZ=America/Toronto
\ No newline at end of file
+TZ=America/Toronto
+
+{"type": "service_account","project_id": "rosterchirp-push", "private_key_id": "577d2e29044634a9a7efba8cc3c5b67cd98f1f61", "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCma+cTelvpLARK\n0B+Ok4901OJU3TEpnJHMc7N5mEg231Nn4XnXSuM4QPOSSJLGRTSHJrZ17kAJkShf\nP2V8mVWujtwhK/a2W1Jl43VkgqNnAunsqG0T12hCpPuRYkVZoDtPmJ4pYzD8TkV9\n1xmrHGP4JovSld5uDFWTQ91ZSsEqi1lS3A35Tqhl3jO81GPzRaR8CyY7/cyoplZ3\n49njHV9llh68pFeYYbfNDbZAli9tDDmP4rBQud8Q+Ix5syQ2zbmAErQFzSl8hiCO\n3FhsZYGBxj2cDXTJXfwqiLoVBL9WbTj5NMVnneDSZJ15ILjQA6zyqKvCsXYB4YrM\n3Vs1D3jnAgMBAAECggEAMh2JSwjMV8XNDxhogF94UlbvR14KuXywPTDUabgNexS6\ngaxZLBedoCmTD8iyBmn9vPtP8+iIuTjQvwoQzjpAnp3ftU+Pbm/Guu8JwXhDq7gp\naH55xoFWIMedCDVfK/PAGKKdclov/LK3Y4Ncc/ZLNoWpEoPWJS6qsHu90u9bhytN\n+TQ//K4ODvxzp8dwrVyEoalaTSubxctvyN43L2EkqJBWOfm5GOnfbfB6UENTVYii\nd98lsc/LFumepGyHWrXOGodjVqWqaW54po2KMGXUiYfdzg7vgcVpBIdG1gbKAmax\n1Ypst7l4VKosJM1cI5zykdRj8JuGlX4YM5MRLhGtLQKBgQDafa2tFU4OViWwMn4E\nowdpczZKjHCy/50NSS/UU2WyTCJo8cO6vjhkCGVINazAWABXIcLeuWHYOIhWyfRj\n9v7dShH2VA2mip7vFP1XEjiK98tFOV4FbS1Qg4m5zVvsECOSNZ21ozHEgZ9q0yEK\nDoXX3HOr3OV6az6GUhB45CzeWwKBgQDC/dtnSwJFpFyR1QAZX5lPeAYXlosO1jhO\nhCqPwKemOnH32j/UVGmE2t93ALo1sQD2YV2CsuHysQWEnH/mdC9iR9HF0g9nCrgc\nqbOD2MppnISYEp2DktcfyZMNujxMYYZ6e4rxXCrjkdk4z2xg5PsGYAJZQ+FA7XdN\nBViQmz1tZQKBgDLPsXkkEEADRsaAJ5BafZnHYmPZ30exbEuvroDZWDgrvoDbYKJo\nJGMXFL7DRMaCcKnSvyfewuNu2j4cv0oUIddCp4S6rWYCrM16+yOpqB6hW9NgcP4g\nEr67qGbeXDc81ZjmASRBrIw/fNxx9ygIkpXNvdTFDVT35dWE9jG3FrwrAoGADICO\nUr8idCinrsoDaZ0RjWDasyR54gemMJKU0AbAOQ5CRGv/77NB2LzX2x920P56W1G+\n1yR1DESBYBFQugv1Bc4pCw/+4NJ1H5FZ6zg5MjBQ6Bc5djgyBt27ygOI3jTalHvb\nWsJYFaNCVDwobMYBulTpkaOii7EuFwgit5Lci2kCgYEAimaTURYyX6MblMlLzcKd\nmtjvfldF8xFh1xKofbiQwfMOiKn0G2xuVWyIvv4U23ZV+Yzu18gr9lOT0JsAsn3G\nx91hUPyWPozoe/MHSecDGIMUUiHdPbIvOO4w3Mv/HCfKuIvruQaaMJ2gvj72zn6C\nCMJVQgxfBULCw4ByDd63pnw=\n-----END PRIVATE KEY-----\n", "client_email": "firebase-adminsdk-fbsvc@rosterchirp-push.iam.gserviceaccount.com", "client_id": "103819905443146316089", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40rosterchirp-push.iam.gserviceaccount.com", "universe_domain": "googleapis.com"}
diff --git a/.env.example b/.env.example
index 1a3032f..a20bb72 100644
--- a/.env.example
+++ b/.env.example
@@ -3,28 +3,41 @@ DB_PASSWORD=change_me_strong_password
JWT_SECRET=change_me_super_secret_jwt_key
# ── App identity ──────────────────────────────────────────────────────────────
-PROJECT_NAME=jama
-APP_NAME=jama
+PROJECT_NAME=rosterchirp
+APP_NAME=rosterchirp
DEFCHAT_NAME=General Chat
ADMIN_NAME=Admin User
-ADMIN_EMAIL=admin@jama.local
+ADMIN_EMAIL=admin@rosterchirp.local
ADMIN_PASS=Admin@1234
ADMPW_RESET=false
# ── Database ──────────────────────────────────────────────────────────────────
-DB_NAME=jama
-DB_USER=jama
+DB_NAME=rosterchirp
+DB_USER=rosterchirp
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
# ── Tenancy mode ──────────────────────────────────────────────────────────────
-# selfhost = single tenant (JAMA-CHAT / JAMA-BRAND / JAMA-TEAM)
-# host = multi-tenant (JAMA-HOST only)
+# selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
+# host = multi-tenant (RosterChirp-Host only)
APP_TYPE=selfhost
-# ── JAMA-HOST only (ignored in selfhost mode) ─────────────────────────────────
-# HOST_DOMAIN=jamachat.com
+# ── RosterChirp-Host only (ignored in selfhost mode) ─────────────────────────────────
+# HOST_DOMAIN=rosterchirp.com
# HOST_ADMIN_KEY=change_me_host_admin_secret
# ── Optional ──────────────────────────────────────────────────────────────────
PORT=3000
TZ=UTC
+
+# ── Firebase Cloud Messaging (FCM) — Android background push ──────────────────
+# Required for push notifications to work on Android when the app is backgrounded.
+# Get these from: Firebase Console → Project Settings → General → Your web app
+# FIREBASE_API_KEY=
+# FIREBASE_PROJECT_ID=
+# FIREBASE_MESSAGING_SENDER_ID=
+# FIREBASE_APP_ID=
+# Get VAPID key from: Firebase Console → Project Settings → Cloud Messaging → Web Push certificates
+# FIREBASE_VAPID_KEY=
+# Get service account JSON from: Firebase Console → Project Settings → Service accounts → Generate new private key
+# Paste the entire JSON content as a single-line string:
+# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"..."}
diff --git a/CLAUDE.md b/CLAUDE.md
index 6a5bf80..a63b189 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,10 +1,10 @@
-# JAMA — Claude Code Development Context
+# RosterChirp — Claude Code Development Context
-## What is JAMA?
+## What is RosterChirp?
-**jama** (just another messaging app) is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
+**RosterChirp** is a self-hosted, closed-source, full-stack Progressive Web App for team messaging. It supports both single-tenant (selfhost) and multi-tenant (host) deployments.
-**Current version:** 0.11.25
+**Current version:** 0.11.26
---
@@ -23,7 +23,7 @@
## Repository Layout
```
-jama/
+rosterchirp/
├── CLAUDE.md ← this file
├── KNOWN_LIMITATIONS.md
├── Dockerfile
@@ -51,7 +51,7 @@ jama/
│ │ ├── users.js
│ │ ├── settings.js
│ │ ├── push.js
-│ │ ├── host.js ← JAMA-HOST control plane only
+│ │ ├── host.js ← RosterChirp-Host control plane only
│ │ ├── about.js
│ │ └── help.js
│ └── utils/
@@ -106,7 +106,7 @@ jama/
## Version Bump — Files to Update
-When bumping the version (e.g. 0.11.25 → 0.11.26), update **all three**:
+When bumping the version (e.g. 0.11.26 → 0.11.27), update **all three**:
```
backend/package.json "version": "X.Y.Z"
@@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}"
One-liner:
```bash
-OLD=0.11.25; NEW=0.11.26
+OLD=0.11.26; NEW=0.11.27
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
```
@@ -127,14 +127,14 @@ The `.env.example` has no version field. There is no fourth location.
## Output ZIP
-When packaging for delivery: `jama.zip` at `/mnt/user-data/outputs/jama.zip`
+When packaging for delivery: `rosterchirp.zip` at `/mnt/user-data/outputs/rosterchirp.zip`
Always exclude:
```bash
-zip -qr jama.zip jama \
- --exclude "jama/README.md" \
- --exclude "jama/data/help.md" \
- --exclude "jama/backend/src/data/help.md"
+zip -qr rosterchirp.zip rosterchirp \
+ --exclude "rosterchirp/README.md" \
+ --exclude "rosterchirp/data/help.md" \
+ --exclude "rosterchirp/backend/src/data/help.md"
```
---
@@ -146,7 +146,7 @@ zip -qr jama.zip jama \
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
| `host` | Multi-tenant — one schema per tenant. Requires `HOST_DOMAIN` and `HOST_ADMIN_KEY`. |
-JAMA-HOST tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`.
+RosterChirp-Host tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control panel lives at `https://{HOST_DOMAIN}/host`.
---
@@ -156,7 +156,7 @@ JAMA-HOST tenants are provisioned at `{slug}.{HOST_DOMAIN}`. The host control pa
- **Schema resolution:** `tenantMiddleware` sets `req.schema` from the HTTP `Host` header before any route runs. `assertSafeSchema()` validates all schema names against `[a-z_][a-z0-9_]*`.
- **Migrations:** Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
- **Seeding:** `seedSettings → seedEventTypes → seedAdmin → seedUserGroups` on startup. All use `ON CONFLICT DO NOTHING`.
-- **Current migrations:** 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users)
+- **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)
---
@@ -203,9 +203,9 @@ Stored in `settings` table per schema:
| `feature_branding` | `'true'`/`'false'` | Brand+ |
| `feature_group_manager` | `'true'`/`'false'` | Team |
| `feature_schedule_manager` | `'true'`/`'false'` | Team |
-| `app_type` | `'JAMA-Chat'`/`'JAMA-Brand'`/`'JAMA-Team'` | — |
+| `app_type` | `'RosterChirp-Chat'`/`'RosterChirp-Brand'`/`'RosterChirp-Team'` | — |
-JAMA-HOST always forces `JAMA-Team` on the public schema at startup.
+RosterChirp-Host always forces `RosterChirp-Team` on the public schema at startup.
---
@@ -303,13 +303,7 @@ Single-user add/remove via `groups.js` (GroupInfoModal) always uses the named me
## Outstanding / Deferred Work
### Android Background Push (KNOWN_LIMITATIONS.md)
-**Status:** Deferred. Web Push with VAPID doesn't survive Android Doze mode.
-**Fix plan:** Integrate Firebase Cloud Messaging (FCM).
-1. Create Firebase project (free tier)
-2. Add Firebase config to `.env` and `sw.js`
-3. Replace `web-push` subscription flow with Firebase SDK
-4. Switch backend dispatch from `web-push` to `firebase-admin`
-5. WebSocket reconnect-on-focus (frontend only, no Firebase needed)
+**Status:** Implemented (v0.11.26+). Replaced web-push/VAPID with Firebase Cloud Messaging (FCM). Requires Firebase project setup — see .env.example for required env vars and sw.js for the SW config block.
### WebSocket Reconnect on Focus
**Status:** Deferred. Socket drops when Android PWA is backgrounded.
@@ -326,19 +320,25 @@ HOST_DOMAIN= # host mode only
HOST_ADMIN_KEY= # host mode only
JWT_SECRET=
DB_HOST=db
-DB_NAME=jama
-DB_USER=jama
+DB_NAME=rosterchirp
+DB_USER=rosterchirp
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose)
ADMIN_EMAIL=
ADMIN_NAME=
ADMIN_PASS=
ADMPW_RESET=true|false
-APP_NAME=jama
+APP_NAME=rosterchirp
USER_PASS= # default password for bulk-created users
DEFCHAT_NAME=General Chat
-JAMA_VERSION= # injected by build.sh into Docker image
+ROSTERCHIRP_VERSION= # injected by build.sh into Docker image
VAPID_PUBLIC= # auto-generated on first start if not set
VAPID_PRIVATE= # auto-generated on first start if not set
+FIREBASE_API_KEY= # FCM web app config
+FIREBASE_PROJECT_ID= # FCM web app config
+FIREBASE_MESSAGING_SENDER_ID= # FCM web app config
+FIREBASE_APP_ID= # FCM web app config
+FIREBASE_VAPID_KEY= # FCM Web Push certificate public key
+FIREBASE_SERVICE_ACCOUNT= # FCM service account JSON (stringified, backend only)
```
---
@@ -347,7 +347,7 @@ VAPID_PRIVATE= # auto-generated on first start if not set
```bash
# Production: Ubuntu 22.04, Docker Compose v2
-# Directory: /home/rick/jama/
+# Directory: /home/rick/rosterchirp/
./build.sh # builds Docker image
docker compose up -d # starts all services
@@ -359,4 +359,4 @@ Build sequence: `build.sh` → Docker build → `npm run build` (Vite) → `dock
## Session History
-Previous development was conducted via Claude.ai web interface (sessions summarised in this document). Development continues in Claude Code from v0.11.25.
+Development continues in Claude Code from v0.11.26 (rebranded from jama to RosterChirp).
diff --git a/Caddyfile.example b/Caddyfile.example
index 5d5bd55..3aa1824 100644
--- a/Caddyfile.example
+++ b/Caddyfile.example
@@ -1,4 +1,4 @@
-# Caddyfile.example — JAMA-HOST reverse proxy
+# Caddyfile.example — RosterChirp-Host reverse proxy
#
# Caddy handles SSL automatically via Let's Encrypt.
# Wildcard certs require a DNS challenge provider.
@@ -12,23 +12,23 @@
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
#
# 3. Add a wildcard DNS record in your DNS provider:
-# *.jamachat.com → your server IP
-# jamachat.com → your server IP
+# *.rosterchirp.com → your server IP
+# rosterchirp.com → your server IP
#
# Usage:
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
# Reload: caddy reload
# ── Wildcard subdomain ────────────────────────────────────────────────────────
-# Handles team1.jamachat.com, teamB.jamachat.com, etc.
-# Replace jamachat.com with your actual HOST_DOMAIN.
+# Handles team1.rosterchirp.com, teamB.rosterchirp.com, etc.
+# Replace rosterchirp.com with your actual HOST_DOMAIN.
-*.jamachat.com {
+*.rosterchirp.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
- # Forward all requests to the jama app container
+ # Forward all requests to the rosterchirp app container
reverse_proxy localhost:3000
# Security headers
@@ -42,13 +42,13 @@
# Logs (optional)
log {
- output file /var/log/caddy/jama-access.log
+ output file /var/log/caddy/rosterchirp-access.log
format json
}
}
# ── Base domain (host admin panel) ───────────────────────────────────────────
-jamachat.com {
+rosterchirp.com {
reverse_proxy localhost:3000
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
@@ -60,7 +60,7 @@ jamachat.com {
# ── Custom tenant domains ─────────────────────────────────────────────────────
# When a tenant sets up a custom domain (e.g. chat.theircompany.com):
#
-# 1. They add a DNS CNAME: chat.theircompany.com → jamachat.com
+# 1. They add a DNS CNAME: chat.theircompany.com → rosterchirp.com
#
# 2. You add a block here and reload Caddy.
# Caddy will automatically obtain and renew the SSL cert.
@@ -80,7 +80,7 @@ jamachat.com {
# }
# }
#
-# *.jamachat.com, jamachat.com {
+# *.rosterchirp.com, rosterchirp.com {
# tls { on_demand }
# reverse_proxy localhost:3000
# }
diff --git a/Dockerfile b/Dockerfile
index 8505e2b..585ecb7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -12,12 +12,12 @@ FROM node:20-alpine
ARG VERSION=dev
ARG BUILD_DATE=unknown
-LABEL org.opencontainers.image.title="jama" \
+LABEL org.opencontainers.image.title="rosterchirp" \
org.opencontainers.image.description="Self-hosted team chat PWA" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.created="${BUILD_DATE}"
-ENV JAMA_VERSION=${VERSION}
+ENV ROSTERCHIRP_VERSION=${VERSION}
# No native build tools needed — pg uses pure JS by default
WORKDIR /app
diff --git a/FUTURE_FEATURES.md b/FUTURE_FEATURES.md
new file mode 100644
index 0000000..e5930f5
--- /dev/null
+++ b/FUTURE_FEATURES.md
@@ -0,0 +1,31 @@
+# RosterChirp — Future Feature Requests
+
+---
+
+## Markdown Message Rendering
+
+**Request:** Render markdown-formatted text in the message window.
+
+**Scope recommendation:** Start with inline-only markdown (bold, italic, inline code, strikethrough, links). Full block-level markdown (headers, lists, tables, code blocks) is a follow-on.
+
+### Why inline-only first
+- Zero risk of misformatting existing plain-text messages
+- Full markdown risks rendering existing content oddly (e.g. a message starting with `1.` or `#` rendering as a list/header)
+
+### Implementation notes
+- Integration point is `formatMsgContent()` in `Message.jsx` — already returns HTML for `dangerouslySetInnerHTML`; a markdown parser slots straight in
+- Add `marked` npm package (~13KB) for parsing
+- Add `DOMPurify` for XSS sanitization — **required**, markdown allows raw HTML passthrough and content comes from other users
+- `marked` config: `{ breaks: true }` to preserve single-newline-as-`
` behaviour, `{ mangle: false, headerIds: false }` to suppress heading anchors
+- Apply markdown parse first, then @mention substitution (so `**@[Name]**` renders correctly)
+- Remove the existing URL regex linkifier in `formatMsgContent` — markdown handles links natively
+- Strip markdown from reply previews (currently shows raw `**text**`)
+- `handleCopy` copies `msg.content` (raw markdown source) — correct behaviour, no change needed
+- Emoji-only detection runs on raw content before rendering — no change needed
+- Compose box stays plain textarea for v1; no preview toolbar required
+
+### Effort estimate
+| Scope | Estimate |
+|---|---|
+| Inline-only (bold, italic, code, strikethrough) | ~1.5 hours |
+| Full markdown (+ block elements, CSS for bubbles) | ~4–5 hours |
diff --git a/KNOWN_LIMITATIONS.md b/KNOWN_LIMITATIONS.md
index ec719aa..433f036 100644
--- a/KNOWN_LIMITATIONS.md
+++ b/KNOWN_LIMITATIONS.md
@@ -1,4 +1,4 @@
-# JAMA — Known Limitations
+# RosterChirp — Known Limitations
## Android Background Push Notifications
@@ -7,13 +7,13 @@
**Does not affect:** Desktop browsers, iOS PWA (iOS 16.4+)
### Symptom
-Push notifications are not delivered when the jama PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground.
+Push notifications are not delivered when the RosterChirp PWA or Chrome browser loses focus on Android. The app also disconnects from the WebSocket (real-time chat) when backgrounded. Notifications only arrive while the app is open and in the foreground.
### Root Cause
Android's battery optimization system (Doze mode + App Standby) aggressively kills background network connections for browsers. This affects two things:
1. **WebSocket** — the socket.io connection is dropped when Chrome/PWA loses focus, stopping real-time updates until the user returns to the app.
-2. **Web Push** — jama uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket.
+2. **Web Push** — RosterChirp uses the standard Web Push API with VAPID keys. Android throttles or blocks push delivery at the system level even when battery settings are set to "No restrictions", because that setting only controls the app's own background activity — it does not exempt the browser's push service socket.
Setting Chrome or the PWA to "No battery restrictions" in Android settings does **not** resolve this.
@@ -28,4 +28,4 @@ Firebase Cloud Messaging (FCM) maintains a privileged persistent connection that
5. Address WebSocket reconnect-on-focus separately (frontend only, no Firebase needed)
### Workaround for Users
-None at the app level. Users who need reliable Android notifications should keep the jama PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings).
+None at the app level. Users who need reliable Android notifications should keep the RosterChirp PWA pinned/open, or check their Android vendor's specific battery exemption settings (Samsung DeX, MIUI, etc. have additional per-app exemption controls beyond the standard Android settings).
diff --git a/backend/package.json b/backend/package.json
index 6c01144..d2b0668 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -1,5 +1,5 @@
{
- "name": "jama-backend",
+ "name": "rosterchirp-backend",
"version": "0.11.25",
"description": "TeamChat backend server",
"main": "src/index.js",
@@ -12,13 +12,13 @@
"cookie-parser": "^1.4.6",
"cors": "^2.8.5",
"express": "^4.18.2",
+ "firebase-admin": "^12.0.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"nanoid": "^3.3.7",
"node-fetch": "^2.7.0",
"sharp": "^0.33.2",
"socket.io": "^4.6.1",
- "web-push": "^3.6.7",
"csv-parse": "^5.5.6",
"pg": "^8.11.3"
},
diff --git a/backend/src/index.js b/backend/src/index.js
index 9dd5f77..e587519 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -41,10 +41,10 @@ app.use('/api/about', require('./routes/about'));
app.use('/api/help', require('./routes/help'));
app.use('/api/push', pushRouter);
-// JAMA-HOST control plane — only registered when APP_TYPE=host
+// RosterChirp-Host control plane — only registered when APP_TYPE=host
if (APP_TYPE === 'host') {
app.use('/api/host', require('./routes/host'));
- console.log('[Server] JAMA-HOST control plane enabled at /api/host');
+ console.log('[Server] RosterChirp-Host control plane enabled at /api/host');
}
// ── Link preview proxy ────────────────────────────────────────────────────────
@@ -67,7 +67,7 @@ app.get('/manifest.json', async (req, res) => {
const s = {};
for (const r of rows) s[r.key] = r.value;
- const appName = s.app_name || process.env.APP_NAME || 'jama';
+ const appName = s.app_name || process.env.APP_NAME || 'rosterchirp';
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
const icon512 = s.pwa_icon_512 || '/icons/icon-512.png';
@@ -396,7 +396,7 @@ initDb().then(async () => {
console.warn('[Server] Could not load tenant cache:', e.message);
}
}
- server.listen(PORT, () => console.log(`[Server] jama listening on port ${PORT}`));
+ server.listen(PORT, () => console.log(`[Server] RosterChirp listening on port ${PORT}`));
}).catch(err => {
console.error('[Server] DB init failed:', err);
process.exit(1);
diff --git a/backend/src/models/db.js b/backend/src/models/db.js
index d4c8375..b1226fd 100644
--- a/backend/src/models/db.js
+++ b/backend/src/models/db.js
@@ -1,5 +1,5 @@
/**
- * db.js — Postgres database layer for jama
+ * db.js — Postgres database layer for rosterchirp
*
* APP_TYPE environment variable controls tenancy:
* selfhost (default) → single schema 'public', one Postgres database
@@ -32,8 +32,8 @@ if (APP_TYPE !== 'host') APP_TYPE = 'selfhost'; // only two valid values
const pool = new Pool({
host: process.env.DB_HOST || 'db',
port: parseInt(process.env.DB_PORT || '5432'),
- database: process.env.DB_NAME || 'jama',
- user: process.env.DB_USER || 'jama',
+ database: process.env.DB_NAME || 'rosterchirp',
+ user: process.env.DB_USER || 'rosterchirp',
password: process.env.DB_PASSWORD || '',
max: 20,
idleTimeoutMillis: 30000,
@@ -52,12 +52,12 @@ function resolveSchema(req) {
if (APP_TYPE === 'selfhost') return 'public';
const host = (req.headers.host || '').toLowerCase().split(':')[0];
- const baseDomain = (process.env.HOST_DOMAIN || 'jamachat.com').toLowerCase();
+ const baseDomain = (process.env.HOST_DOMAIN || 'rosterchirp.com').toLowerCase();
// Internal requests (Docker health checks, localhost) → public schema
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
- // Subdomain: team1.jamachat.com → tenant_team1
+ // Subdomain: team1.rosterchirp.com → tenant_team1
if (host.endsWith(`.${baseDomain}`)) {
const slug = host.slice(0, -(baseDomain.length + 1));
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
@@ -198,7 +198,7 @@ async function runMigrations(schema) {
async function seedSettings(schema) {
const defaults = [
- ['app_name', process.env.APP_NAME || 'jama'],
+ ['app_name', process.env.APP_NAME || 'rosterchirp'],
['logo_url', ''],
['pw_reset_active', process.env.ADMPW_RESET === 'true' ? 'true' : 'false'],
['icon_newchat', ''],
@@ -213,7 +213,7 @@ async function seedSettings(schema) {
['feature_branding', 'false'],
['feature_group_manager', 'false'],
['feature_schedule_manager', 'false'],
- ['app_type', 'JAMA-Chat'],
+ ['app_type', 'RosterChirp-Chat'],
['team_group_managers', ''],
['team_schedule_managers', ''],
['team_tool_managers', ''],
@@ -269,7 +269,7 @@ async function seedUserGroups(schema) {
async function seedAdmin(schema) {
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
- const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@jama.local';
+ const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local';
const adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
const pwReset = process.env.ADMPW_RESET === 'true';
@@ -350,11 +350,11 @@ async function initDb() {
await seedAdmin('public');
await seedUserGroups('public');
- // Host mode: the public schema is the host's own workspace — always full JAMA-Team plan.
+ // Host mode: the public schema is the host's own workspace — always full RosterChirp-Team plan.
// ON CONFLICT DO UPDATE ensures existing installs get corrected on restart too.
if (APP_TYPE === 'host') {
const hostPlan = [
- ['app_type', 'JAMA-Team'],
+ ['app_type', 'RosterChirp-Team'],
['feature_branding', 'true'],
['feature_group_manager', 'true'],
['feature_schedule_manager', 'true'],
@@ -365,7 +365,7 @@ async function initDb() {
[key, value]
);
}
- console.log('[DB] Host mode: public schema upgraded to JAMA-Team plan');
+ console.log('[DB] Host mode: public schema upgraded to RosterChirp-Team plan');
}
console.log('[DB] Initialisation complete');
diff --git a/backend/src/models/migrations/007_fcm_push.sql b/backend/src/models/migrations/007_fcm_push.sql
new file mode 100644
index 0000000..c627152
--- /dev/null
+++ b/backend/src/models/migrations/007_fcm_push.sql
@@ -0,0 +1,5 @@
+-- Migration 007: FCM push — add fcm_token column, relax NOT NULL on legacy web-push columns
+ALTER TABLE push_subscriptions ADD COLUMN IF NOT EXISTS fcm_token TEXT;
+ALTER TABLE push_subscriptions ALTER COLUMN endpoint DROP NOT NULL;
+ALTER TABLE push_subscriptions ALTER COLUMN p256dh DROP NOT NULL;
+ALTER TABLE push_subscriptions ALTER COLUMN auth DROP NOT NULL;
diff --git a/backend/src/models/migrations/008_rebrand.sql b/backend/src/models/migrations/008_rebrand.sql
new file mode 100644
index 0000000..ae7db3c
--- /dev/null
+++ b/backend/src/models/migrations/008_rebrand.sql
@@ -0,0 +1,4 @@
+-- Migration 008: Rebrand — update app_type values from JAMA-* to RosterChirp-*
+UPDATE settings SET value = 'RosterChirp-Chat' WHERE key = 'app_type' AND value = 'JAMA-Chat';
+UPDATE settings SET value = 'RosterChirp-Brand' WHERE key = 'app_type' AND value = 'JAMA-Brand';
+UPDATE settings SET value = 'RosterChirp-Team' WHERE key = 'app_type' AND value = 'JAMA-Team';
diff --git a/backend/src/routes/about.js b/backend/src/routes/about.js
index a41e4f3..6cac17d 100644
--- a/backend/src/routes/about.js
+++ b/backend/src/routes/about.js
@@ -28,10 +28,10 @@ router.get('/', (req, res) => {
const about = {
...DEFAULTS,
...overrides,
- version: process.env.JAMA_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
+ version: process.env.ROSTERCHIRP_VERSION || process.env.TEAMCHAT_VERSION || 'dev',
// Always expose original app identity — not overrideable via about.json or settings
- default_app_name: 'jama',
- default_logo: '/icons/jama.png',
+ default_app_name: 'rosterchirp',
+ default_logo: '/icons/rosterchirp.png',
};
// Never expose docker_image — removed from UI
diff --git a/backend/src/routes/host.js b/backend/src/routes/host.js
index 0c2f34f..cddf623 100644
--- a/backend/src/routes/host.js
+++ b/backend/src/routes/host.js
@@ -1,5 +1,5 @@
/**
- * routes/host.js — JAMA-HOST control plane
+ * routes/host.js — RosterChirp-Host control plane
*
* All routes require the HOST_ADMIN_KEY header.
* These routes operate on the 'public' schema (tenant registry).
@@ -141,7 +141,7 @@ router.post('/tenants', async (req, res) => {
process.env.ADMIN_PASS = origPass;
// 5. Set app_type based on plan
- const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
+ const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
await exec(schemaName, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
if (plan === 'brand' || plan === 'team') {
await exec(schemaName, "UPDATE settings SET value='true' WHERE key='feature_branding'");
@@ -161,7 +161,7 @@ router.post('/tenants', async (req, res) => {
// 7. Reload domain cache
await reloadTenantCache();
- const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
+ const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
const tenant = tr.rows[0];
tenant.url = `https://${slug}.${baseDomain}`;
@@ -220,7 +220,7 @@ router.patch('/tenants/:slug', async (req, res) => {
await exec(s, "UPDATE settings SET value=CASE WHEN $1 IN ('brand','team') THEN 'true' ELSE 'false' END WHERE key='feature_branding'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_group_manager'", [plan]);
await exec(s, "UPDATE settings SET value=CASE WHEN $1 = 'team' THEN 'true' ELSE 'false' END WHERE key='feature_schedule_manager'", [plan]);
- const planAppType = { chat: 'JAMA-Chat', brand: 'JAMA-Brand', team: 'JAMA-Team' }[plan] || 'JAMA-Chat';
+ const planAppType = { chat: 'RosterChirp-Chat', brand: 'RosterChirp-Brand', team: 'RosterChirp-Team' }[plan] || 'RosterChirp-Chat';
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
}
@@ -310,7 +310,7 @@ router.get('/status', async (req, res) => {
try {
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants');
const active = await queryOne('public', "SELECT COUNT(*) AS count FROM tenants WHERE status='active'");
- const baseDomain = process.env.HOST_DOMAIN || 'jamachat.com';
+ const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
res.json({
ok: true,
appType: process.env.APP_TYPE || 'selfhost',
diff --git a/backend/src/routes/push.js b/backend/src/routes/push.js
index 8483770..b90443e 100644
--- a/backend/src/routes/push.js
+++ b/backend/src/routes/push.js
@@ -1,50 +1,62 @@
-const express = require('express');
-const webpush = require('web-push');
-const router = express.Router();
-const { query, queryOne, queryResult, exec } = require('../models/db');
+const express = require('express');
+const router = express.Router();
+const { query, queryOne, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth');
-// VAPID keys are stored in settings; lazily initialised on first request
-let vapidPublicKey = null;
+// ── Firebase Admin ─────────────────────────────────────────────────────────────
+let firebaseAdmin = null;
+let firebaseApp = null;
-async function getVapidKeys(schema) {
- const pub = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_public'");
- const priv = await queryOne(schema, "SELECT value FROM settings WHERE key = 'vapid_private'");
- if (!pub?.value || !priv?.value) {
- const keys = webpush.generateVAPIDKeys();
- await exec(schema,
- "INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
- [keys.publicKey]
- );
- await exec(schema,
- "INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
- [keys.privateKey]
- );
- console.log('[Push] Generated new VAPID keys');
- return keys;
- }
- return { publicKey: pub.value, privateKey: priv.value };
-}
-
-async function initWebPush(schema) {
- const keys = await getVapidKeys(schema);
- webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
- return keys.publicKey;
-}
-
-// Called from index.js socket push notifications — schema comes from caller
-async function sendPushToUser(schema, userId, payload) {
+function getMessaging() {
+ if (firebaseApp) return firebaseAdmin.messaging(firebaseApp);
+ const json = process.env.FIREBASE_SERVICE_ACCOUNT;
+ if (!json) return null;
try {
- if (!vapidPublicKey) vapidPublicKey = await initWebPush(schema);
- const subs = await query(schema, 'SELECT * FROM push_subscriptions WHERE user_id = $1', [userId]);
+ firebaseAdmin = require('firebase-admin');
+ const svc = JSON.parse(json);
+ firebaseApp = firebaseAdmin.initializeApp({
+ credential: firebaseAdmin.credential.cert(svc),
+ });
+ console.log('[Push] Firebase Admin initialised');
+ return firebaseAdmin.messaging(firebaseApp);
+ } catch (e) {
+ console.error('[Push] Firebase Admin init failed:', e.message);
+ return null;
+ }
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+// Called from index.js socket push notifications
+async function sendPushToUser(schema, userId, payload) {
+ const messaging = getMessaging();
+ if (!messaging) return;
+ try {
+ const subs = await query(schema,
+ 'SELECT * FROM push_subscriptions WHERE user_id = $1 AND fcm_token IS NOT NULL',
+ [userId]
+ );
for (const sub of subs) {
try {
- await webpush.sendNotification(
- { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } },
- JSON.stringify(payload)
- );
+ await messaging.send({
+ token: sub.fcm_token,
+ data: {
+ title: payload.title || 'New Message',
+ body: payload.body || '',
+ url: payload.url || '/',
+ groupId: payload.groupId ? String(payload.groupId) : '',
+ },
+ android: { priority: 'high' },
+ webpush: { headers: { Urgency: 'high' } },
+ });
} catch (err) {
- if (err.statusCode === 410 || err.statusCode === 404) {
+ // Remove stale tokens
+ const stale = [
+ 'messaging/registration-token-not-registered',
+ 'messaging/invalid-registration-token',
+ 'messaging/invalid-argument',
+ ];
+ if (stale.includes(err.code)) {
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
}
}
@@ -54,56 +66,47 @@ async function sendPushToUser(schema, userId, payload) {
}
}
-router.get('/vapid-public', async (req, res) => {
- try {
- if (!vapidPublicKey) vapidPublicKey = await initWebPush(req.schema);
- res.json({ publicKey: vapidPublicKey });
- } catch (e) { res.status(500).json({ error: e.message }); }
+// ── Routes ─────────────────────────────────────────────────────────────────────
+
+// Public — frontend fetches this to initialise the Firebase JS SDK
+router.get('/firebase-config', (req, res) => {
+ const apiKey = process.env.FIREBASE_API_KEY;
+ const projectId = process.env.FIREBASE_PROJECT_ID;
+ const messagingSenderId = process.env.FIREBASE_MESSAGING_SENDER_ID;
+ const appId = process.env.FIREBASE_APP_ID;
+ const vapidKey = process.env.FIREBASE_VAPID_KEY;
+
+ if (!apiKey || !projectId || !messagingSenderId || !appId) {
+ return res.status(503).json({ error: 'FCM not configured' });
+ }
+ res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey });
});
+// Register / refresh an FCM token for the logged-in user
router.post('/subscribe', authMiddleware, async (req, res) => {
- const { endpoint, keys } = req.body;
- if (!endpoint || !keys?.p256dh || !keys?.auth)
- return res.status(400).json({ error: 'Invalid subscription' });
+ const { fcmToken } = req.body;
+ if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
try {
const device = req.device || 'desktop';
await exec(req.schema,
- 'DELETE FROM push_subscriptions WHERE endpoint = $1 OR (user_id = $2 AND device = $3)',
- [endpoint, req.user.id, device]
+ 'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
+ [req.user.id, device]
);
await exec(req.schema,
- 'INSERT INTO push_subscriptions (user_id, device, endpoint, p256dh, auth) VALUES ($1,$2,$3,$4,$5)',
- [req.user.id, device, endpoint, keys.p256dh, keys.auth]
+ 'INSERT INTO push_subscriptions (user_id, device, fcm_token) VALUES ($1, $2, $3)',
+ [req.user.id, device, fcmToken]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
});
-router.post('/generate-vapid', authMiddleware, async (req, res) => {
- if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admins only' });
- try {
- const keys = webpush.generateVAPIDKeys();
- await exec(req.schema,
- "INSERT INTO settings (key,value) VALUES ('vapid_public',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
- [keys.publicKey]
- );
- await exec(req.schema,
- "INSERT INTO settings (key,value) VALUES ('vapid_private',$1) ON CONFLICT(key) DO UPDATE SET value=$1",
- [keys.privateKey]
- );
- webpush.setVapidDetails('mailto:admin@jama.local', keys.publicKey, keys.privateKey);
- vapidPublicKey = keys.publicKey;
- res.json({ publicKey: keys.publicKey });
- } catch (e) { res.status(500).json({ error: e.message }); }
-});
-
+// Remove the FCM token for the logged-in user / device
router.post('/unsubscribe', authMiddleware, async (req, res) => {
- const { endpoint } = req.body;
- if (!endpoint) return res.status(400).json({ error: 'Endpoint required' });
try {
+ const device = req.device || 'desktop';
await exec(req.schema,
- 'DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2',
- [req.user.id, endpoint]
+ 'DELETE FROM push_subscriptions WHERE user_id = $1 AND device = $2',
+ [req.user.id, device]
);
res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); }
diff --git a/backend/src/routes/settings.js b/backend/src/routes/settings.js
index 20bb7f5..1343717 100644
--- a/backend/src/routes/settings.js
+++ b/backend/src/routes/settings.js
@@ -37,7 +37,7 @@ router.get('/', async (req, res) => {
for (const r of rows) obj[r.key] = r.value;
const admin = await queryOne(req.schema, 'SELECT email FROM users WHERE is_default_admin = TRUE');
if (admin) obj.admin_email = admin.email;
- obj.app_version = process.env.JAMA_VERSION || 'dev';
+ obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234';
// Tell the frontend whether this request came from the HOST_DOMAIN.
// Used to show/hide the Control Panel menu item — only visible on the host's own domain.
@@ -105,7 +105,7 @@ router.patch('/colors', authMiddleware, adminMiddleware, async (req, res) => {
router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
try {
- const originalName = process.env.APP_NAME || 'jama';
+ const originalName = process.env.APP_NAME || 'rosterchirp';
await exec(req.schema, "UPDATE settings SET value=$1, updated_at=NOW() WHERE key='app_name'", [originalName]);
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key='logo_url'");
await exec(req.schema, "UPDATE settings SET value='', updated_at=NOW() WHERE key IN ('icon_newchat','icon_groupinfo','pwa_icon_192','pwa_icon_512','color_title','color_title_dark','color_avatar_public','color_avatar_dm')");
@@ -114,9 +114,9 @@ router.post('/reset', authMiddleware, adminMiddleware, async (req, res) => {
});
const VALID_CODES = {
- 'JAMA-TEAM-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
- 'JAMA-BRAND-2024': { appType:'JAMA-Brand', branding:true, groupManager:false, scheduleManager:false },
- 'JAMA-FULL-2024': { appType:'JAMA-Team', branding:true, groupManager:true, scheduleManager:true },
+ 'ROSTERCHIRP-TEAM-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
+ 'ROSTERCHIRP-BRAND-2024': { appType:'RosterChirp-Brand', branding:true, groupManager:false, scheduleManager:false },
+ 'ROSTERCHIRP-FULL-2024': { appType:'RosterChirp-Team', branding:true, groupManager:true, scheduleManager:true },
};
router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
@@ -124,11 +124,11 @@ router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
try {
if (!code?.trim()) {
await setSetting(req.schema, 'registration_code', '');
- await setSetting(req.schema, 'app_type', 'JAMA-Chat');
+ await setSetting(req.schema, 'app_type', 'RosterChirp-Chat');
await setSetting(req.schema, 'feature_branding', 'false');
await setSetting(req.schema, 'feature_group_manager', 'false');
await setSetting(req.schema, 'feature_schedule_manager', 'false');
- return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'JAMA-Chat'} });
+ return res.json({ success:true, features:{branding:false,groupManager:false,scheduleManager:false,appType:'RosterChirp-Chat'} });
}
const match = VALID_CODES[code.trim().toUpperCase()];
if (!match) return res.status(400).json({ error: 'Invalid registration code' });
diff --git a/backend/src/utils/linkPreview.js b/backend/src/utils/linkPreview.js
index a644e23..f91501e 100644
--- a/backend/src/utils/linkPreview.js
+++ b/backend/src/utils/linkPreview.js
@@ -7,7 +7,7 @@ async function getLinkPreview(url) {
const res = await fetch(url, {
signal: controller.signal,
- headers: { 'User-Agent': 'JamaBot/1.0' }
+ headers: { 'User-Agent': 'RosterChirpBot/1.0' }
});
clearTimeout(timeout);
diff --git a/build.sh b/build.sh
index 16f598d..f491328 100644
--- a/build.sh
+++ b/build.sh
@@ -1,10 +1,10 @@
#!/usr/bin/env bash
# ─────────────────────────────────────────────────────────────
-# jama — Docker build & release script
+# rosterchirp — Docker build & release script
#
# Usage:
-# ./build.sh # builds jama:latest
-# ./build.sh 1.2.0 # builds jama:1.2.0 AND jama:latest
+# ./build.sh # builds rosterchirp:latest
+# ./build.sh 1.2.0 # builds rosterchirp:1.2.0 AND rosterchirp:latest
# ./build.sh 1.2.0 push # builds, tags, and pushes to registry
#
# To push to a registry, set REGISTRY env var:
@@ -13,10 +13,10 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
-VERSION="${1:-0.11.26}"
+VERSION="${1:-0.12.0}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
-IMAGE_NAME="jama"
+IMAGE_NAME="rosterchirp"
# If a registry is set, prefix image name
if [[ -n "$REGISTRY" ]]; then
@@ -26,7 +26,7 @@ else
fi
echo "╔══════════════════════════════════════╗"
-echo "║ jama Docker Builder ║"
+echo "║ rosterchirp Docker Builder ║"
echo "╠══════════════════════════════════════╣"
echo "║ Image : ${FULL_IMAGE}"
echo "║ Version : ${VERSION}"
@@ -67,7 +67,7 @@ fi
echo ""
echo "─────────────────────────────────────────"
echo "To deploy this version, set in your .env:"
-echo " JAMA_VERSION=${VERSION}"
+echo " ROSTERCHIRP_VERSION=${VERSION}"
echo ""
echo "Then run:"
echo " docker compose up -d"
diff --git a/docker-compose.host.yaml b/docker-compose.host.yaml
index 71d2b33..48be51b 100644
--- a/docker-compose.host.yaml
+++ b/docker-compose.host.yaml
@@ -1,6 +1,6 @@
-# docker-compose.host.yaml — JAMA-HOST multi-tenant deployment
+# docker-compose.host.yaml — RosterChirp-Host multi-tenant deployment
#
-# Use this instead of docker-compose.yaml when running JAMA-HOST.
+# Use this instead of docker-compose.yaml when running RosterChirp-Host.
# Adds Caddy as the reverse proxy for automatic wildcard SSL.
#
# Usage:
@@ -8,14 +8,14 @@
#
# Required .env additions for host mode:
# APP_TYPE=host
-# HOST_DOMAIN=jamachat.com
+# HOST_DOMAIN=rosterchirp.com
# HOST_ADMIN_KEY=your_secret_host_admin_key
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
services:
- jama:
- image: jama:${JAMA_VERSION:-latest}
- container_name: ${PROJECT_NAME:-jama}
+ rosterchirp:
+ image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
+ container_name: ${PROJECT_NAME:-rosterchirp}
restart: unless-stopped
# No direct port exposure — traffic comes through Caddy
expose:
@@ -25,21 +25,21 @@ services:
- TZ=${TZ:-UTC}
- APP_TYPE=host
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
+ - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
- - APP_NAME=${APP_NAME:-jama}
+ - APP_NAME=${APP_NAME:-rosterchirp}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
- DB_HOST=db
- DB_PORT=5432
- - DB_NAME=${DB_NAME:-jama}
- - DB_USER=${DB_USER:-jama}
+ - DB_NAME=${DB_NAME:-rosterchirp}
+ - DB_USER=${DB_USER:-rosterchirp}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- HOST_DOMAIN=${HOST_DOMAIN:?HOST_DOMAIN is required in host mode}
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
volumes:
- - jama_uploads:/app/uploads
+ - rosterchirp_uploads:/app/uploads
depends_on:
db:
condition: service_healthy
@@ -51,16 +51,16 @@ services:
db:
image: postgres:16-alpine
- container_name: ${PROJECT_NAME:-jama}_db
+ container_name: ${PROJECT_NAME:-rosterchirp}_db
restart: unless-stopped
environment:
- - POSTGRES_DB=${DB_NAME:-jama}
- - POSTGRES_USER=${DB_USER:-jama}
+ - POSTGRES_DB=${DB_NAME:-rosterchirp}
+ - POSTGRES_USER=${DB_USER:-rosterchirp}
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- - jama_db:/var/lib/postgresql/data
+ - rosterchirp_db:/var/lib/postgresql/data
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
interval: 5s
timeout: 5s
retries: 10
@@ -70,7 +70,7 @@ services:
# Pre-built images: https://github.com/abiosoft/caddy-docker
# Or build your own: xcaddy build --with github.com/caddy-dns/cloudflare
image: caddy:2-alpine
- container_name: ${PROJECT_NAME:-jama}_caddy
+ container_name: ${PROJECT_NAME:-rosterchirp}_caddy
restart: unless-stopped
ports:
- "80:80"
@@ -84,12 +84,12 @@ services:
- caddy_config:/config
- /var/log/caddy:/var/log/caddy
depends_on:
- - jama
+ - rosterchirp
volumes:
- jama_db:
+ rosterchirp_db:
driver: local
- jama_uploads:
+ rosterchirp_uploads:
driver: local
caddy_data:
driver: local
diff --git a/docker-compose.yaml b/docker-compose.yaml
index e6f6b9d..591353c 100644
--- a/docker-compose.yaml
+++ b/docker-compose.yaml
@@ -1,7 +1,7 @@
services:
- jama:
- image: jama:${JAMA_VERSION:-latest}
- container_name: ${PROJECT_NAME:-jama}
+ rosterchirp:
+ image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
+ container_name: ${PROJECT_NAME:-rosterchirp}
restart: unless-stopped
ports:
- "${PORT:-3000}:3000"
@@ -10,21 +10,21 @@ services:
- TZ=${TZ:-UTC}
- APP_TYPE=${APP_TYPE:-selfhost}
- ADMIN_NAME=${ADMIN_NAME:-Admin User}
- - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@jama.local}
+ - ADMIN_EMAIL=${ADMIN_EMAIL:-admin@rosterchirp.local}
- ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme_super_secret_jwt_key_2024}
- - APP_NAME=${APP_NAME:-jama}
+ - APP_NAME=${APP_NAME:-rosterchirp}
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
- DB_HOST=db
- DB_PORT=5432
- - DB_NAME=${DB_NAME:-jama}
- - DB_USER=${DB_USER:-jama}
+ - DB_NAME=${DB_NAME:-rosterchirp}
+ - DB_USER=${DB_USER:-rosterchirp}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
- HOST_DOMAIN=${HOST_DOMAIN:-}
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
volumes:
- - jama_uploads:/app/uploads
+ - rosterchirp_uploads:/app/uploads
depends_on:
db:
condition: service_healthy
@@ -36,22 +36,22 @@ services:
db:
image: postgres:16-alpine
- container_name: ${PROJECT_NAME:-jama}_db
+ container_name: ${PROJECT_NAME:-rosterchirp}_db
restart: unless-stopped
environment:
- - POSTGRES_DB=${DB_NAME:-jama}
- - POSTGRES_USER=${DB_USER:-jama}
+ - POSTGRES_DB=${DB_NAME:-rosterchirp}
+ - POSTGRES_USER=${DB_USER:-rosterchirp}
- POSTGRES_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- - jama_db:/var/lib/postgresql/data
+ - rosterchirp_db:/var/lib/postgresql/data
healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-jama} -d ${DB_NAME:-jama}"]
+ test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp} -d ${DB_NAME:-rosterchirp}"]
interval: 5s
timeout: 5s
retries: 10
volumes:
- jama_db:
+ rosterchirp_db:
driver: local
- jama_uploads:
+ rosterchirp_uploads:
driver: local
diff --git a/frontend/index.html b/frontend/index.html
index 2666525..4da8faf 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -2,13 +2,13 @@
-
+
-
+
- jama
+ RosterChirp
diff --git a/frontend/package.json b/frontend/package.json
index 133a81e..f5b8a33 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,5 +1,5 @@
{
- "name": "jama-frontend",
+ "name": "rosterchirp-frontend",
"version": "0.11.25",
"private": true,
"scripts": {
@@ -17,7 +17,8 @@
"@emoji-mart/react": "^1.1.1",
"papaparse": "^5.4.1",
"date-fns": "^3.3.1",
- "marked": "^12.0.0"
+ "marked": "^12.0.0",
+ "firebase": "^10.14.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.1",
diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json
index 0b70959..8a7ebe1 100644
--- a/frontend/public/manifest.json
+++ b/frontend/public/manifest.json
@@ -1,6 +1,6 @@
{
- "name": "jama",
- "short_name": "jama",
+ "name": "RosterChirp",
+ "short_name": "RosterChirp",
"description": "Modern team messaging application",
"start_url": "/",
"scope": "/",
diff --git a/frontend/public/sw.js b/frontend/public/sw.js
index 68ced58..9fa3714 100644
--- a/frontend/public/sw.js
+++ b/frontend/public/sw.js
@@ -1,4 +1,27 @@
-const CACHE_NAME = 'jama-v1';
+// ── Firebase Messaging (background push for Android PWA) ──────────────────────
+// Fill in the values below from Firebase Console → Project Settings → General → Your apps
+// Leave apiKey as '__FIREBASE_API_KEY__' if not using FCM (push will be disabled).
+importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js');
+importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js');
+
+const FIREBASE_CONFIG = {
+ apiKey: "AIzaSyDx191unzXFT4WA1OvkdbrIY_c57kgruAU",
+ authDomain: "rosterchirp-push.firebaseapp.com",
+ projectId: "rosterchirp-push",
+ storageBucket: "rosterchirp-push.firebasestorage.app",
+ messagingSenderId: "126479377334",
+ appId: "1:126479377334:web:280abdd135cf7e0c50d717"
+};
+
+// Only initialise Firebase if the config has been filled in
+let messaging = null;
+if (FIREBASE_CONFIG.apiKey !== '__FIREBASE_API_KEY__') {
+ firebase.initializeApp(FIREBASE_CONFIG);
+ messaging = firebase.messaging();
+}
+
+// ── Cache ─────────────────────────────────────────────────────────────────────
+const CACHE_NAME = 'rosterchirp-v1';
const STATIC_ASSETS = ['/'];
self.addEventListener('install', (event) => {
@@ -27,53 +50,35 @@ self.addEventListener('fetch', (event) => {
);
});
-// Track badge count in SW scope
+// ── Badge counter ─────────────────────────────────────────────────────────────
let badgeCount = 0;
-self.addEventListener('push', (event) => {
- if (!event.data) return;
-
- let data = {};
- try { data = event.data.json(); } catch (e) { return; }
-
+function showRosterChirpNotification(data) {
badgeCount++;
+ if (self.navigator?.setAppBadge) self.navigator.setAppBadge(badgeCount).catch(() => {});
- // Update app badge
- if (self.navigator && self.navigator.setAppBadge) {
- self.navigator.setAppBadge(badgeCount).catch(() => {});
- }
-
- // Check if app is currently visible — if so, skip the notification
- const showNotification = clients.matchAll({
- type: 'window',
- includeUncontrolled: true,
- }).then((clientList) => {
- const appVisible = clientList.some(
- (c) => c.visibilityState === 'visible'
- );
- // Still show if app is open but hidden (minimized), skip only if truly visible
- if (appVisible) return;
-
- return self.registration.showNotification(data.title || 'New Message', {
- body: data.body || '',
- icon: '/icons/icon-192.png',
- badge: '/icons/icon-192-maskable.png',
- data: { url: data.url || '/' },
- // Use unique tag per group so notifications group by conversation
- tag: data.groupId ? `jama-group-${data.groupId}` : 'jama-message',
- renotify: true,
- });
+ return self.registration.showNotification(data.title || 'New Message', {
+ body: data.body || '',
+ icon: '/icons/icon-192.png',
+ badge: '/icons/icon-192-maskable.png',
+ data: { url: data.url || '/' },
+ tag: data.groupId ? `rosterchirp-group-${data.groupId}` : 'rosterchirp-message',
+ renotify: true,
});
+}
- event.waitUntil(showNotification);
-});
+// ── FCM background messages ───────────────────────────────────────────────────
+if (messaging) {
+ messaging.onBackgroundMessage((payload) => {
+ return showRosterChirpNotification(payload.data || {});
+ });
+}
+// ── Notification click ────────────────────────────────────────────────────────
self.addEventListener('notificationclick', (event) => {
event.notification.close();
badgeCount = 0;
- if (self.navigator && self.navigator.clearAppBadge) {
- self.navigator.clearAppBadge().catch(() => {});
- }
+ if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
const url = event.notification.data?.url || '/';
@@ -88,17 +93,15 @@ self.addEventListener('notificationclick', (event) => {
);
});
-// Clear badge when app signals it
+// ── Badge control messages from main thread ───────────────────────────────────
self.addEventListener('message', (event) => {
if (event.data?.type === 'CLEAR_BADGE') {
badgeCount = 0;
- if (self.navigator && self.navigator.clearAppBadge) {
- self.navigator.clearAppBadge().catch(() => {});
- }
+ if (self.navigator?.clearAppBadge) self.navigator.clearAppBadge().catch(() => {});
}
if (event.data?.type === 'SET_BADGE') {
badgeCount = event.data.count || 0;
- if (self.navigator && self.navigator.setAppBadge) {
+ if (self.navigator?.setAppBadge) {
if (badgeCount > 0) {
self.navigator.setAppBadge(badgeCount).catch(() => {});
} else {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index a74f156..19a6276 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -27,7 +27,7 @@ function AuthRoute({ children }) {
}
function RestoreTheme() {
- const saved = localStorage.getItem('jama-theme') || 'light';
+ const saved = localStorage.getItem('rosterchirp-theme') || 'light';
document.documentElement.setAttribute('data-theme', saved);
return null;
}
diff --git a/frontend/src/components/AboutModal.jsx b/frontend/src/components/AboutModal.jsx
index 4657151..6f3e297 100644
--- a/frontend/src/components/AboutModal.jsx
+++ b/frontend/src/components/AboutModal.jsx
@@ -32,8 +32,8 @@ export default function AboutModal({ onClose }) {
}, []);
// Always use the original app identity — not the user-customised settings name/logo
- const appName = about?.default_app_name || 'jama';
- const logoSrc = about?.default_logo || '/icons/jama.png';
+ const appName = about?.default_app_name || 'rosterchirp';
+ const logoSrc = about?.default_logo || '/icons/rosterchirp.png';
const version = about?.version || '';
const a = about || {};
diff --git a/frontend/src/components/BrandingModal.jsx b/frontend/src/components/BrandingModal.jsx
index bb90bec..a326bc1 100644
--- a/frontend/src/components/BrandingModal.jsx
+++ b/frontend/src/components/BrandingModal.jsx
@@ -321,7 +321,7 @@ export default function BrandingModal({ onClose }) {
useEffect(() => {
api.getSettings().then(({ settings }) => {
setSettings(settings);
- setAppName(settings.app_name || 'jama');
+ setAppName(settings.app_name || 'rosterchirp');
setColourTitle(settings.color_title || DEFAULT_TITLE_COLOR);
setColourTitleDark(settings.color_title_dark || DEFAULT_TITLE_DARK_COLOR);
setColourPublic(settings.color_avatar_public || DEFAULT_PUBLIC_COLOR);
@@ -329,7 +329,7 @@ export default function BrandingModal({ onClose }) {
}).catch(() => {});
}, []);
- const notifySidebarRefresh = () => window.dispatchEvent(new Event('jama:settings-changed'));
+ const notifySidebarRefresh = () => window.dispatchEvent(new Event('rosterchirp:settings-changed'));
const handleSaveName = async () => {
if (!appName.trim()) return;
@@ -391,7 +391,7 @@ export default function BrandingModal({ onClose }) {
await api.resetSettings();
const { settings: fresh } = await api.getSettings();
setSettings(fresh);
- setAppName(fresh.app_name || 'jama');
+ setAppName(fresh.app_name || 'rosterchirp');
setColourTitle(DEFAULT_TITLE_COLOR);
setColourTitleDark(DEFAULT_TITLE_DARK_COLOR);
setColourPublic(DEFAULT_PUBLIC_COLOR);
@@ -433,7 +433,7 @@ export default function BrandingModal({ onClose }) {
border: '1px solid var(--border)', overflow: 'hidden', display: 'flex',
alignItems: 'center', justifyContent: 'center', flexShrink: 0
}}>
-
+
diff --git a/frontend/src/components/HostPanel.jsx b/frontend/src/components/HostPanel.jsx
index ce8a27c..bf1f5a6 100644
--- a/frontend/src/components/HostPanel.jsx
+++ b/frontend/src/components/HostPanel.jsx
@@ -1,7 +1,7 @@
/**
- * HostPanel.jsx — JAMA-HOST Control Panel
+ * HostPanel.jsx — RosterChirp-Host Control Panel
*
- * Renders inside the main JAMA right-panel area (not a separate page/route).
+ * Renders inside the main RosterChirp right-panel area (not a separate page/route).
* Protected by:
* 1. Only shown when is_host_domain === true (server-computed from HOST_DOMAIN)
* 2. Only accessible to admin role users
@@ -15,9 +15,9 @@ import UserFooter from './UserFooter.jsx';
// ── Constants ─────────────────────────────────────────────────────────────────
const PLANS = [
- { value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
- { value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
- { value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
+ { value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' },
+ { value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' },
+ { value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' },
];
const PLAN_COLOURS = {
@@ -307,7 +307,7 @@ function KeyEntry({ onSubmit }) {
setChecking(true); setError('');
try {
const res = await fetch('/api/host/status', { headers: { 'X-Host-Admin-Key': key.trim() } });
- if (res.ok) { sessionStorage.setItem('jama-host-key', key.trim()); onSubmit(key.trim()); }
+ if (res.ok) { sessionStorage.setItem('rosterchirp-host-key', key.trim()); onSubmit(key.trim()); }
else setError('Invalid admin key');
} catch { setError('Connection error'); }
finally { setChecking(false); }
@@ -336,7 +336,7 @@ function KeyEntry({ onSubmit }) {
export default function HostPanel({ onProfile, onHelp, onAbout }) {
const { user } = useAuth();
- const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
+ const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || '');
const [status, setStatus] = useState(null);
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(false);
@@ -363,7 +363,7 @@ export default function HostPanel({ onProfile, onHelp, onAbout }) {
toast(e.message, 'error');
// Key is invalid — clear it so the prompt shows again
if (e.message.includes('401') || e.message.includes('Invalid') || e.message.includes('401')) {
- sessionStorage.removeItem('jama-host-key');
+ sessionStorage.removeItem('rosterchirp-host-key');
setAdminKey('');
}
} finally { setLoading(false); }
diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx
index ef15021..f9b696f 100644
--- a/frontend/src/components/SettingsModal.jsx
+++ b/frontend/src/components/SettingsModal.jsx
@@ -5,9 +5,9 @@ import { useToast } from '../contexts/ToastContext.jsx';
// ── Helpers ───────────────────────────────────────────────────────────────────
const APP_TYPES = {
- 'JAMA-Chat': { label: 'JAMA-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' },
- 'JAMA-Brand': { label: 'JAMA-Brand', desc: 'Chat and Branding.' },
- 'JAMA-Team': { label: 'JAMA-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
+ 'RosterChirp-Chat': { label: 'RosterChirp-Chat', desc: 'Chat only. No Branding, Group Manager or Schedule Manager.' },
+ 'RosterChirp-Brand': { label: 'RosterChirp-Brand', desc: 'Chat and Branding.' },
+ 'RosterChirp-Team': { label: 'RosterChirp-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
};
// ── Team Management Tab ───────────────────────────────────────────────────────
@@ -34,7 +34,7 @@ function TeamManagementTab() {
try {
await api.updateTeamSettings({ toolManagers });
toast('Team settings saved', 'success');
- window.dispatchEvent(new Event('jama:settings-changed'));
+ window.dispatchEvent(new Event('rosterchirp:settings-changed'));
} catch (e) { toast(e.message, 'error'); }
finally { setSaving(false); }
};
@@ -82,7 +82,7 @@ function RegistrationTab({ onFeaturesChanged }) {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
}, []);
- const appType = settings.app_type || 'JAMA-Chat';
+ const appType = settings.app_type || 'RosterChirp-Chat';
const activeCode = settings.registration_code || '';
const adminEmail = settings.admin_email || '—';
@@ -104,7 +104,7 @@ function RegistrationTab({ onFeaturesChanged }) {
const fresh = await api.getSettings();
setSettings(fresh.settings);
toast('Registration applied successfully.', 'success');
- window.dispatchEvent(new Event('jama:settings-changed'));
+ window.dispatchEvent(new Event('rosterchirp:settings-changed'));
onFeaturesChanged && onFeaturesChanged(f);
} catch (e) { toast(e.message || 'Invalid registration code', 'error'); }
finally { setRegLoading(false); }
@@ -116,12 +116,12 @@ function RegistrationTab({ onFeaturesChanged }) {
const fresh = await api.getSettings();
setSettings(fresh.settings);
toast('Registration cleared.', 'success');
- window.dispatchEvent(new Event('jama:settings-changed'));
+ window.dispatchEvent(new Event('rosterchirp:settings-changed'));
onFeaturesChanged && onFeaturesChanged(f);
} catch (e) { toast(e.message, 'error'); }
};
- const typeInfo = APP_TYPES[appType] || APP_TYPES['JAMA-Chat'];
+ const typeInfo = APP_TYPES[appType] || APP_TYPES['RosterChirp-Chat'];
const siteUrl = window.location.origin;
return (
@@ -132,7 +132,7 @@ function RegistrationTab({ onFeaturesChanged }) {
Registration {activeCode ? 'is' : 'required:'}
- JAMA {activeCode ? 'is' : 'will be'} registered to:
+ RosterChirp {activeCode ? 'is' : 'will be'} registered to:
Type: {typeInfo.label}
URL: {siteUrl}
@@ -185,9 +185,9 @@ function RegistrationTab({ onFeaturesChanged }) {
)}
- Registration codes unlock application features. Contact your JAMA provider for a code.
- JAMA-Brand — unlocks Branding.
- JAMA-Team — unlocks Branding, Group Manager and Schedule Manager.
+ Registration codes unlock application features. Contact your RosterChirp provider for a code.
+ RosterChirp-Brand — unlocks Branding.
+ RosterChirp-Team — unlocks Branding, Group Manager and Schedule Manager.
);
@@ -270,18 +270,18 @@ function WebPushTab() {
// ── Main modal ────────────────────────────────────────────────────────────────
export default function SettingsModal({ onClose, onFeaturesChanged }) {
const [tab, setTab] = useState('registration');
- const [appType, setAppType] = useState('JAMA-Chat');
+ const [appType, setAppType] = useState('RosterChirp-Chat');
useEffect(() => {
api.getSettings().then(({ settings }) => {
- setAppType(settings.app_type || 'JAMA-Chat');
+ setAppType(settings.app_type || 'RosterChirp-Chat');
}).catch(() => {});
- const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'JAMA-Chat')).catch(() => {});
- window.addEventListener('jama:settings-changed', handler);
- return () => window.removeEventListener('jama:settings-changed', handler);
+ const handler = () => api.getSettings().then(({ settings }) => setAppType(settings.app_type || 'RosterChirp-Chat')).catch(() => {});
+ window.addEventListener('rosterchirp:settings-changed', handler);
+ return () => window.removeEventListener('rosterchirp:settings-changed', handler);
}, []);
- const isTeam = appType === 'JAMA-Team';
+ const isTeam = appType === 'RosterChirp-Team';
const tabs = [
isTeam && { id: 'team', label: 'Team Management' },
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
index fd88aea..553d692 100644
--- a/frontend/src/components/Sidebar.jsx
+++ b/frontend/src/components/Sidebar.jsx
@@ -14,20 +14,20 @@ function nameToColor(name) {
}
function useAppSettings() {
- const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
+ const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
const fetchSettings = () => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
};
useEffect(() => {
fetchSettings();
- window.addEventListener('jama:settings-changed', fetchSettings);
- return () => window.removeEventListener('jama:settings-changed', fetchSettings);
+ window.addEventListener('rosterchirp:settings-changed', fetchSettings);
+ return () => window.removeEventListener('rosterchirp:settings-changed', fetchSettings);
}, []);
useEffect(() => {
- const name = settings.app_name || 'jama';
+ const name = settings.app_name || 'rosterchirp';
const prefix = document.title.match(/^(\(\d+\)\s*)/)?.[1] || '';
document.title = prefix + name;
- const faviconUrl = settings.logo_url || '/icons/jama.png';
+ const faviconUrl = settings.logo_url || '/icons/rosterchirp.png';
let link = document.querySelector("link[rel~='icon']");
if (!link) { link = document.createElement('link'); link.rel = 'icon'; document.head.appendChild(link); }
link.href = faviconUrl;
diff --git a/frontend/src/components/UserFooter.jsx b/frontend/src/components/UserFooter.jsx
index f620fb4..a376996 100644
--- a/frontend/src/components/UserFooter.jsx
+++ b/frontend/src/components/UserFooter.jsx
@@ -3,10 +3,10 @@ import { useAuth } from '../contexts/AuthContext.jsx';
import Avatar from './Avatar.jsx';
function useTheme() {
- const [dark, setDark] = useState(() => localStorage.getItem('jama-theme') === 'dark');
+ const [dark, setDark] = useState(() => localStorage.getItem('rosterchirp-theme') === 'dark');
useEffect(() => {
document.documentElement.setAttribute('data-theme', dark ? 'dark' : 'light');
- localStorage.setItem('jama-theme', dark ? 'dark' : 'light');
+ localStorage.setItem('rosterchirp-theme', dark ? 'dark' : 'light');
}, [dark]);
return [dark, setDark];
}
diff --git a/frontend/src/contexts/AuthContext.jsx b/frontend/src/contexts/AuthContext.jsx
index 561a668..d9789d8 100644
--- a/frontend/src/contexts/AuthContext.jsx
+++ b/frontend/src/contexts/AuthContext.jsx
@@ -52,8 +52,8 @@ export function AuthProvider({ children }) {
setUser(null);
setMustChangePassword(false);
};
- window.addEventListener('jama:session-displaced', handler);
- return () => window.removeEventListener('jama:session-displaced', handler);
+ window.addEventListener('rosterchirp:session-displaced', handler);
+ return () => window.removeEventListener('rosterchirp:session-displaced', handler);
}, []);
const updateUser = (updates) => setUser(prev => ({ ...prev, ...updates }));
diff --git a/frontend/src/contexts/SocketContext.jsx b/frontend/src/contexts/SocketContext.jsx
index a68fe71..3f24a5c 100644
--- a/frontend/src/contexts/SocketContext.jsx
+++ b/frontend/src/contexts/SocketContext.jsx
@@ -44,7 +44,7 @@ export function SocketProvider({ children }) {
// Session displaced: another login on the same device type has kicked this session
socket.on('session:displaced', () => {
- window.dispatchEvent(new CustomEvent('jama:session-displaced'));
+ window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
});
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
index 266566e..fbe12ad 100644
--- a/frontend/src/main.jsx
+++ b/frontend/src/main.jsx
@@ -26,7 +26,7 @@ if ('serviceWorker' in navigator) {
//
// 2. PULL-TO-REFRESH → blocked in PWA standalone mode only.
(function () {
- const LS_KEY = 'jama_font_scale';
+ const LS_KEY = 'rosterchirp_font_scale';
const MIN_SCALE = 0.8;
const MAX_SCALE = 2.0;
diff --git a/frontend/src/pages/Chat.jsx b/frontend/src/pages/Chat.jsx
index 05bda75..a6b7942 100644
--- a/frontend/src/pages/Chat.jsx
+++ b/frontend/src/pages/Chat.jsx
@@ -42,7 +42,7 @@ export default function Chat() {
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
const [drawerOpen, setDrawerOpen] = useState(false);
- const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'JAMA-Chat', teamToolManagers: [], isHostDomain: false });
+ const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false });
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true);
@@ -81,7 +81,7 @@ export default function Chat() {
branding: settings.feature_branding === 'true',
groupManager: settings.feature_group_manager === 'true',
scheduleManager: settings.feature_schedule_manager === 'true',
- appType: settings.app_type || 'JAMA-Chat',
+ appType: settings.app_type || 'RosterChirp-Chat',
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'),
isHostDomain: settings.is_host_domain === 'true',
}));
@@ -93,53 +93,59 @@ export default function Chat() {
useEffect(() => {
loadFeatures();
- window.addEventListener('jama:settings-changed', loadFeatures);
- return () => window.removeEventListener('jama:settings-changed', loadFeatures);
+ window.addEventListener('rosterchirp:settings-changed', loadFeatures);
+ return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]);
- // Register / refresh push subscription
+ // Register / refresh FCM push subscription
useEffect(() => {
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) return;
+ if (!('serviceWorker' in navigator)) return;
const registerPush = async () => {
try {
- const permission = Notification.permission;
- if (permission === 'denied') return;
+ if (Notification.permission === 'denied') return;
+
+ // Fetch Firebase config from backend (returns 503 if FCM not configured)
+ const configRes = await fetch('/api/push/firebase-config');
+ if (!configRes.ok) return;
+ const { apiKey, projectId, messagingSenderId, appId, vapidKey } = await configRes.json();
+
+ // Dynamically import the Firebase SDK (tree-shaken, only loaded when needed)
+ const { initializeApp, getApps } = await import('firebase/app');
+ const { getMessaging, getToken } = await import('firebase/messaging');
+
+ const firebaseApp = getApps().length
+ ? getApps()[0]
+ : initializeApp({ apiKey, projectId, messagingSenderId, appId });
+ const firebaseMessaging = getMessaging(firebaseApp);
const reg = await navigator.serviceWorker.ready;
- const { publicKey } = await fetch('/api/push/vapid-public').then(r => r.json());
- const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
- let sub = await reg.pushManager.getSubscription();
-
- if (!sub) {
- // First time or subscription was lost — request permission then subscribe
- const granted = permission === 'granted'
- ? 'granted'
- : await Notification.requestPermission();
+ if (Notification.permission !== 'granted') {
+ const granted = await Notification.requestPermission();
if (granted !== 'granted') return;
- sub = await reg.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: urlBase64ToUint8Array(publicKey),
- });
}
- // Always re-register subscription with the server (keeps it fresh on mobile)
+ const fcmToken = await getToken(firebaseMessaging, {
+ vapidKey,
+ serviceWorkerRegistration: reg,
+ });
+ if (!fcmToken) return;
+
+ const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
- body: JSON.stringify(sub.toJSON()),
+ body: JSON.stringify({ fcmToken }),
});
- console.log('[Push] Subscription registered');
+ console.log('[Push] FCM subscription registered');
} catch (e) {
- console.warn('[Push] Subscription failed:', e.message);
+ console.warn('[Push] FCM subscription failed:', e.message);
}
};
registerPush();
- // Bug A fix: re-register push subscription when app returns to foreground
- // Mobile browsers can drop push subscriptions when the app is backgrounded
const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush();
};
@@ -263,7 +269,7 @@ export default function Chat() {
// so we force logout unconditionally — the new session will reconnect cleanly)
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
- window.dispatchEvent(new CustomEvent('jama:session-displaced'));
+ window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
};
// Online presence
@@ -320,7 +326,7 @@ export default function Chat() {
if (isMobile) {
setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app
- window.history.pushState({ jamaChatOpen: true }, '');
+ window.history.pushState({ rosterchirpChatOpen: true }, '');
}
// Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id));
@@ -334,7 +340,7 @@ export default function Chat() {
setShowSidebar(true);
setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted
- window.history.pushState({ jamaChatOpen: true }, '');
+ window.history.pushState({ rosterchirpChatOpen: true }, '');
}
};
window.addEventListener('popstate', handlePopState);
diff --git a/frontend/src/pages/HostAdmin.jsx b/frontend/src/pages/HostAdmin.jsx
index 9bc3744..acbd34f 100644
--- a/frontend/src/pages/HostAdmin.jsx
+++ b/frontend/src/pages/HostAdmin.jsx
@@ -3,9 +3,9 @@ import { useState, useEffect, useCallback } from 'react';
// ── Constants ─────────────────────────────────────────────────────────────────
const PLANS = [
- { value: 'chat', label: 'JAMA-Chat', desc: 'Chat only' },
- { value: 'brand', label: 'JAMA-Brand', desc: 'Chat + Branding' },
- { value: 'team', label: 'JAMA-Team', desc: 'Chat + Branding + Groups + Schedule' },
+ { value: 'chat', label: 'RosterChirp-Chat', desc: 'Chat only' },
+ { value: 'brand', label: 'RosterChirp-Brand', desc: 'Chat + Branding' },
+ { value: 'team', label: 'RosterChirp-Team', desc: 'Chat + Branding + Groups + Schedule' },
];
const PLAN_BADGE = {
@@ -393,7 +393,7 @@ function KeyEntry({ onSubmit }) {
headers: { 'X-Host-Admin-Key': key.trim() },
});
if (res.ok) {
- sessionStorage.setItem('jama-host-key', key.trim());
+ sessionStorage.setItem('rosterchirp-host-key', key.trim());
onSubmit(key.trim());
} else {
setError('Invalid admin key');
@@ -406,7 +406,7 @@ function KeyEntry({ onSubmit }) {
🏠
-
JAMA-HOST
+
RosterChirp-Host
Host Administration Panel
{error &&
{error}
}
@@ -428,7 +428,7 @@ function KeyEntry({ onSubmit }) {
// ── Main host admin panel ─────────────────────────────────────────────────────
export default function HostAdmin() {
- const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('jama-host-key') || '');
+ const [adminKey, setAdminKey] = useState(() => sessionStorage.getItem('rosterchirp-host-key') || '');
const [status, setStatus] = useState(null);
const [tenants, setTenants] = useState([]);
const [loading, setLoading] = useState(false);
@@ -454,7 +454,7 @@ export default function HostAdmin() {
} catch (e) {
toast(e.message, 'error');
if (e.message.includes('Invalid') || e.message.includes('401')) {
- sessionStorage.removeItem('jama-host-key');
+ sessionStorage.removeItem('rosterchirp-host-key');
setAdminKey('');
}
} finally { setLoading(false); }
@@ -479,7 +479,7 @@ export default function HostAdmin() {
!search || t.name.toLowerCase().includes(search.toLowerCase()) ||
t.slug.toLowerCase().includes(search.toLowerCase())
);
- const baseDomain = status?.baseDomain || 'jamachat.com';
+ const baseDomain = status?.baseDomain || 'rosterchirp.com';
return (
@@ -489,7 +489,7 @@ export default function HostAdmin() {
justifyContent: 'space-between', height: 56 }}>
🏠
- JAMA-HOST
+ RosterChirp-Host
/ {baseDomain}
@@ -498,7 +498,7 @@ export default function HostAdmin() {
{status.tenants.active} active · {status.tenants.total} total
)}
- { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}>
+ { sessionStorage.removeItem('rosterchirp-host-key'); setAdminKey(''); }}>
Sign Out
@@ -581,7 +581,7 @@ export default function HostAdmin() {
{/* Footer */}
- JAMA-HOST Control Plane · {baseDomain}
+ RosterChirp-Host Control Plane · {baseDomain}
diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx
index 143d5bb..59b04ae 100644
--- a/frontend/src/pages/Login.jsx
+++ b/frontend/src/pages/Login.jsx
@@ -67,7 +67,7 @@ export default function Login() {
}
};
- const appName = settings.app_name || 'jama';
+ const appName = settings.app_name || 'rosterchirp';
const logoUrl = settings.logo_url;
return (
@@ -77,7 +77,7 @@ export default function Login() {
{logoUrl ? (

) : (
-

+

)}
{appName}
Sign in to continue
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index a66caf9..68d5b6d 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -36,7 +36,7 @@ async function req(method, path, body, opts = {}) {
if (res.status === 401 && data.error?.includes('Session expired')) {
localStorage.removeItem('tc_token');
sessionStorage.removeItem('tc_token');
- window.dispatchEvent(new CustomEvent('jama:session-displaced'));
+ window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
}
throw new Error(data.error || 'Request failed');
}
@@ -123,7 +123,7 @@ export const api = {
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
importPreview: (file) => {
const fd = new FormData(); fd.append('file', file);
- return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('jama-token') }, body: fd }).then(r => r.json());
+ return fetch('/api/schedule/import/preview', { method: 'POST', headers: { Authorization: 'Bearer ' + localStorage.getItem('tc_token') }, body: fd }).then(r => r.json());
},
importConfirm: (rows) => req('POST', '/schedule/import/confirm', { rows }),
@@ -166,16 +166,11 @@ export const api = {
},
resetSettings: () => req('POST', '/settings/reset'),
- // Push notifications
- getPushKey: () => req('GET', '/push/vapid-public'),
- subscribePush: (sub) => req('POST', '/push/subscribe', sub),
- unsubscribePush: (endpoint) => req('POST', '/push/unsubscribe', { endpoint }),
+ // Push notifications (FCM)
+ getFirebaseConfig: () => req('GET', '/push/firebase-config'),
+ subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
+ unsubscribePush: () => req('POST', '/push/unsubscribe'),
// Link preview
getLinkPreview: (url) => req('GET', `/link-preview?url=${encodeURIComponent(url)}`),
-
-
-
- // VAPID key management (admin only)
- generateVapidKeys: () => req('POST', '/push/generate-vapid'),
};