v0.12.0 codes for FCM and rebranded jama to RosterChirp

This commit is contained in:
2026-03-22 20:15:57 -04:00
parent 21dc788cd3
commit 819d60d693
40 changed files with 426 additions and 363 deletions

2
.env
View File

@@ -28,3 +28,5 @@ HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
#** Optional
PORT=3144
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"}

View File

@@ -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":"..."}

View File

@@ -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).

View File

@@ -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
# }

View File

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

31
FUTURE_FEATURES.md Normal file
View File

@@ -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-`<br>` 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) | ~45 hours |

View File

@@ -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).

View File

@@ -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"
},

View File

@@ -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);

View File

@@ -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');

View File

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

View File

@@ -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';

View File

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

View File

@@ -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',

View File

@@ -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 { 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 }); }

View File

@@ -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' });

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,13 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/icons/jama.png" />
<link rel="icon" type="image/png" href="/icons/rosterchirp.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#1a73e8" />
<meta name="description" content="jama - just another messaging app" />
<meta name="description" content="RosterChirp - team messaging" />
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>jama</title>
<title>RosterChirp</title>
</head>
<body>
<div id="root"></div>

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"name": "jama",
"short_name": "jama",
"name": "RosterChirp",
"short_name": "RosterChirp",
"description": "Modern team messaging application",
"start_url": "/",
"scope": "/",

View File

@@ -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++;
// 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;
if (self.navigator?.setAppBadge) self.navigator.setAppBadge(badgeCount).catch(() => {});
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',
tag: data.groupId ? `rosterchirp-group-${data.groupId}` : 'rosterchirp-message',
renotify: true,
});
}
// ── FCM background messages ───────────────────────────────────────────────────
if (messaging) {
messaging.onBackgroundMessage((payload) => {
return showRosterChirpNotification(payload.data || {});
});
}
event.waitUntil(showNotification);
});
// ── 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 {

View File

@@ -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;
}

View File

@@ -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 || {};

View File

@@ -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
}}>
<img src={settings.logo_url || '/icons/jama.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
<img src={settings.logo_url || '/icons/rosterchirp.png'} alt="logo" style={{ width: '100%', height: '100%', objectFit: 'contain' }} />
</div>
<div>
<label className="btn btn-secondary btn-sm" style={{ cursor: 'pointer', display: 'inline-block' }}>

View File

@@ -48,11 +48,11 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
setIconGroupInfo(settings.icon_groupinfo || '');
setAvatarColors({ public: settings.color_avatar_public || '#1a73e8', dm: settings.color_avatar_dm || '#a142f4' });
}).catch(() => {});
window.addEventListener('jama:settings-updated', handler);
window.addEventListener('jama:settings-changed', handler);
window.addEventListener('rosterchirp:settings-updated', handler);
window.addEventListener('rosterchirp:settings-changed', handler);
return () => {
window.removeEventListener('jama:settings-updated', handler);
window.removeEventListener('jama:settings-changed', handler);
window.removeEventListener('rosterchirp:settings-updated', handler);
window.removeEventListener('rosterchirp:settings-changed', handler);
};
}, []);

View File

@@ -4,24 +4,24 @@ import { api } from '../utils/api.js';
export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
const { connected } = useSocket();
const [settings, setSettings] = useState({ app_name: 'jama', logo_url: '' });
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '' });
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
useEffect(() => {
api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
const handler = () => api.getSettings().then(({ settings }) => setSettings(settings)).catch(() => {});
window.addEventListener('jama:settings-changed', handler);
window.addEventListener('rosterchirp:settings-changed', handler);
const themeObserver = new MutationObserver(() => {
setIsDark(document.documentElement.getAttribute('data-theme') === 'dark');
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
return () => {
window.removeEventListener('jama:settings-changed', handler);
window.removeEventListener('rosterchirp:settings-changed', handler);
themeObserver.disconnect();
};
}, []);
const appName = settings.app_name || 'jama';
const appName = settings.app_name || 'rosterchirp';
const logoUrl = settings.logo_url;
const titleColor = (isDark ? settings.color_title_dark : settings.color_title) || null;
@@ -48,7 +48,7 @@ export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
</svg>
</button>
<div className="global-bar-brand">
<img src={logoUrl || '/icons/jama.png'} alt={appName} className="global-bar-logo" />
<img src={logoUrl || '/icons/rosterchirp.png'} alt={appName} className="global-bar-logo" />
<span className="global-bar-title" style={titleColor ? { color: titleColor } : {}}>{appName}</span>
</div>
</div>

View File

@@ -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); }

View File

@@ -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:'}
</p>
<p style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
JAMA {activeCode ? 'is' : 'will be'} registered to:<br />
RosterChirp {activeCode ? 'is' : 'will be'} registered to:<br />
<strong>Type:</strong> {typeInfo.label}<br />
<strong>URL:</strong> {siteUrl}
</p>
@@ -185,9 +185,9 @@ function RegistrationTab({ onFeaturesChanged }) {
)}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
Registration codes unlock application features. Contact your JAMA provider for a code.<br />
<strong>JAMA-Brand</strong> unlocks Branding.&nbsp;
<strong>JAMA-Team</strong> unlocks Branding, Group Manager and Schedule Manager.
Registration codes unlock application features. Contact your RosterChirp provider for a code.<br />
<strong>RosterChirp-Brand</strong> unlocks Branding.&nbsp;
<strong>RosterChirp-Team</strong> unlocks Branding, Group Manager and Schedule Manager.
</p>
</div>
);
@@ -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' },

View File

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

View File

@@ -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];
}

View File

@@ -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 }));

View File

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

View File

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

View File

@@ -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);

View File

@@ -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 }) {
<div style={{ background: '#fff', borderRadius: 12, padding: 40, width: '100%', maxWidth: 380,
boxShadow: '0 2px 16px rgba(0,0,0,0.12)', textAlign: 'center' }}>
<div style={{ fontSize: 32, marginBottom: 8 }}>🏠</div>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>JAMA-HOST</h1>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: '0 0 4px' }}>RosterChirp-Host</h1>
<p style={{ color: '#5f6368', fontSize: 13, margin: '0 0 24px' }}>Host Administration Panel</p>
{error && <div style={{ padding: '8px 12px', background: '#fce8e6', color: '#d93025',
borderRadius: 6, fontSize: 13, marginBottom: 16 }}>{error}</div>}
@@ -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 (
<div style={{ minHeight: '100vh', background: '#f1f3f4', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif' }}>
@@ -489,7 +489,7 @@ export default function HostAdmin() {
justifyContent: 'space-between', height: 56 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: 20 }}>🏠</span>
<span style={{ fontWeight: 700, fontSize: 16 }}>JAMA-HOST</span>
<span style={{ fontWeight: 700, fontSize: 16 }}>RosterChirp-Host</span>
<span style={{ opacity: 0.7, fontSize: 13 }}>/ {baseDomain}</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
@@ -498,7 +498,7 @@ export default function HostAdmin() {
{status.tenants.active} active · {status.tenants.total} total
</span>
)}
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('jama-host-key'); setAdminKey(''); }}>
<Btn size="sm" variant="secondary" onClick={() => { sessionStorage.removeItem('rosterchirp-host-key'); setAdminKey(''); }}>
Sign Out
</Btn>
</div>
@@ -581,7 +581,7 @@ export default function HostAdmin() {
{/* Footer */}
<div style={{ textAlign: 'center', marginTop: 24, fontSize: 12, color: '#9aa0a6' }}>
JAMA-HOST Control Plane · {baseDomain}
RosterChirp-Host Control Plane · {baseDomain}
</div>
</div>

View File

@@ -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 ? (
<img src={logoUrl} alt={appName} className="logo-img" />
) : (
<img src="/icons/jama.png" alt="jama" className="logo-img" />
<img src="/icons/rosterchirp.png" alt="rosterchirp" className="logo-img" />
)}
<h1>{appName}</h1>
<p>Sign in to continue</p>

View File

@@ -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'),
};