Compare commits
125 Commits
ef3935560d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| f7f7eb9d2f | |||
| b527e24705 | |||
| 1af039ab0a | |||
| 03496884d5 | |||
| c87b5f0304 | |||
| b1ceb7cccb | |||
| 3a86385038 | |||
| 70750890e3 | |||
| 8f5310754c | |||
| d79839b438 | |||
| f0a591f474 | |||
| 2874285cc7 | |||
| c9d6a4d9d4 | |||
| dbea35abe2 | |||
| 18c63953cc | |||
| 4df92752bb | |||
| ae47f66ef5 | |||
| e4ac7e248c | |||
| 18e4a92241 | |||
| 97b308e9f0 | |||
| 1d4116d1a3 | |||
| 6de899112b | |||
| 3910063ed3 | |||
| 7031979571 | |||
| a3a878854e | |||
| f942bc45b9 | |||
| 9c263e7e8d | |||
| 350bb25ecd | |||
| d0f10c4d7e | |||
| 1a85d3930e | |||
| c82d113adf | |||
| fe836ae69f | |||
| ec6246bd72 | |||
| e8e941c436 | |||
| 6a2f4438f9 | |||
| ff6743c9b1 | |||
| d03baec163 | |||
| b456143d20 | |||
| 2710a9c111 | |||
| 93689d4486 | |||
| cfb351a251 | |||
| 2dffeb1fde | |||
| 4b4ddf0825 | |||
| 43ff0f450d | |||
| f4dfa6eeca | |||
| 36e1be8f40 | |||
| 3bf01cba1f | |||
| 12c4a154e5 | |||
| 2037bb1caa | |||
| 1ed9d9d95e | |||
| a43d067e61 | |||
| a6ac21aed0 | |||
| 5c5f2b4050 | |||
| 252c0e09cb | |||
| f40bb123d2 | |||
| d07d9e3919 | |||
| 76edd7abd1 | |||
| fb9d4dc956 | |||
| eb3e45d88f | |||
| d7790bb7ef | |||
| a0d7125dd3 | |||
| 459ab27c5b | |||
| abd4574ee3 | |||
| f50f2aaba1 | |||
| 7476ca5cd1 | |||
| f1683e2ff5 | |||
| 407e9ee731 | |||
| 8a21ffddb5 | |||
| 2b2b184f04 | |||
| eea7cb91e7 | |||
| 05f7d48bf1 | |||
| fe55e6481a | |||
| 97f1dace4f | |||
| d6a37d5948 | |||
| 6e5c39607c | |||
| 13e5e3a627 | |||
| 92dbcf2780 | |||
| ba91fce44c | |||
| 2b2e98fa48 | |||
| 0b03f15e4a | |||
| 6af892c9a6 | |||
| 941d216f38 | |||
| 163d71d505 | |||
| 8c4650d1bc | |||
| f48ce589ca | |||
| 225dcd718b | |||
| 7276228a98 | |||
| e77176841c | |||
| 72094d7d15 | |||
| 65e7cc4007 | |||
| 9dd3392e95 | |||
| 780020fc46 | |||
| d0c15287c4 | |||
| 85177a643f | |||
| ce6e03d66b | |||
| b5672eb4a2 | |||
| 7c0c3e1132 | |||
| 117b5cbe4c | |||
| bb5a3b6813 | |||
| 44799f76cc | |||
| 2e3e4100f5 | |||
| dec24eb842 | |||
| bcd9f4a060 | |||
| 477b25dfa0 | |||
| 01f37e60be | |||
| eca93aae28 | |||
| ad67330d20 | |||
| a0183458eb | |||
| 10e3df25f9 | |||
| 048abcfbfd | |||
| f9024a6f3a | |||
| b3cc9727e4 | |||
| cf9b22feb5 | |||
| 3c7d3002f1 | |||
| de15d28d3a | |||
| 15bc1d110e | |||
| 6e179eb1ec | |||
| edc7885a6b | |||
| 14c80f436a | |||
| 2d164958d8 | |||
| 64522764cb | |||
| 2495a2c358 | |||
| bfb67261b2 | |||
| 3d7e75a1e6 | |||
| d2ed487079 |
14
.claude/settings.local.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(/mnt/c/Program Files/nodejs/npm.cmd run build)",
|
||||
"Bash(cmd.exe /c \"npm run build\")",
|
||||
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build 2>&1\")",
|
||||
"Bash(cmd.exe /c \"cd /d d:\\\\_projects\\\\gitea\\\\jama\\\\frontend && npm run build\")",
|
||||
"Bash(powershell.exe -Command \"cd ''d:\\\\_projects\\\\gitea\\\\jama\\\\frontend''; & ''C:\\\\Program Files\\\\nodejs\\\\npm.cmd'' run build 2>&1\")",
|
||||
"Bash(powershell.exe -Command \"Get-Command npm -ErrorAction SilentlyContinue; \\(Get-Command node -ErrorAction SilentlyContinue\\).Source\")",
|
||||
"Bash(npm run:*)",
|
||||
"Bash(*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
32
.env
@@ -1,29 +1,28 @@
|
||||
#** Required
|
||||
DB_PASSWORD=C@nuck2024
|
||||
DB_PASSWORD=r0sterCh!rp2026
|
||||
JWT_SECRET=changemesupersecretjwtkey
|
||||
|
||||
#** App identity
|
||||
PROJECT_NAME=jama
|
||||
PROJECT_NAME=rosterchirp
|
||||
APP_NAME=RosterChirp
|
||||
DEFCHAT_NAME=General Chat
|
||||
ADMIN_NAME=Admin User
|
||||
ADMIN_EMAIL=admin@rosterchirp.local
|
||||
ADMIN_EMAIL=admin@yourdomain.com
|
||||
ADMIN_PASS=Admin@1234
|
||||
ADMPW_RESET=false
|
||||
|
||||
#** Database
|
||||
# DB names intentionally kept as 'jama' — matches the existing live 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 (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
|
||||
# host = multi-tenant (RosterChirp-Host only)
|
||||
APP_TYPE=host
|
||||
APP_TYPE=selfhost
|
||||
|
||||
#** RosterChirp-Host only (ignored in selfhost mode)
|
||||
HOST_DOMAIN=jamahost.stretchy.ca
|
||||
HOST_DOMAIN=yourdomain.com
|
||||
HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
|
||||
|
||||
#** Optional
|
||||
@@ -32,11 +31,16 @@ TZ=America/Toronto
|
||||
|
||||
#** Firebase Cloud Messaging (FCM) — Android background push
|
||||
# Web app config — from Firebase Console → Project Settings → General → Your apps
|
||||
FIREBASE_API_KEY=AIzaSyDx191unzXFT4WA1OvkdbrIY_c57kgruAU
|
||||
FIREBASE_PROJECT_ID=rosterchirp-push
|
||||
FIREBASE_MESSAGING_SENDER_ID=126479377334
|
||||
FIREBASE_APP_ID=1:126479377334:web:280abdd135cf7e0c50d717
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_APP_ID=
|
||||
# VAPID key — from Firebase Console → Project Settings → Cloud Messaging → Web Push certificates
|
||||
FIREBASE_VAPID_KEY=BKUioOWptwKIfQJV9udX5P0VsIxLn3LC-Bj2eAenUNSZ5CoFmls3lQWxu03rcO9XZcXA-aYaGuD-jWNH3fOybN8
|
||||
FIREBASE_VAPID_KEY=
|
||||
# Service account — from Firebase Console → Project Settings → Service accounts → Generate new private key
|
||||
FIREBASE_SERVICE_ACCOUNT={"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"}
|
||||
FIREBASE_SERVICE_ACCOUNT=
|
||||
|
||||
#Required for iOS notifications (create here: https://vapidkeys.com/ with valid email address)
|
||||
VAPID_SUBJECT=mailto:webpush@yourdomain.com
|
||||
VAPID_PUBLIC=
|
||||
VAPID_PRIVATE=
|
||||
22
.env.example
@@ -22,22 +22,32 @@ DB_USER=rosterchirp
|
||||
APP_TYPE=selfhost
|
||||
|
||||
# ── RosterChirp-Host only (ignored in selfhost mode) ─────────────────────────────────
|
||||
# HOST_DOMAIN=rosterchirp.com
|
||||
# APP_DOMAIN=example.com
|
||||
# HOST_SLUG=chathost
|
||||
# HOST_ADMIN_KEY=change_me_host_admin_secret
|
||||
# To access the tenant host it would be http|https://HOST_SLUG.APP_DOMAIN (ie: http|https://chathost.example.com)
|
||||
|
||||
# ── Optional ──────────────────────────────────────────────────────────────────
|
||||
PORT=3000
|
||||
TZ=UTC
|
||||
|
||||
# ── Firebase Cloud Messaging (FCM) — Android background push ──────────────────
|
||||
# ── Firebase Cloud Messaging (FCM) https://firebase.google.com/ — 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
|
||||
# -- 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
|
||||
# -- 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:
|
||||
# -- 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 (include curlybracket to curlybracket):
|
||||
# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"..."}
|
||||
|
||||
# ── iOS (iPhone) background push ──────────────────
|
||||
# Required for push notifications to work on iOS when the app is backgrounded.
|
||||
# -- Get these from: https://vapidkeys.com/
|
||||
# -- The subject requires the "mailto:yourvalid@email.com" without quotes
|
||||
# VAPID_SUBJECT=
|
||||
# VAPID_PUBLIC=
|
||||
# FVAPID_PRIVATE=
|
||||
|
||||
36
.gitignore
vendored
Normal file
@@ -0,0 +1,36 @@
|
||||
# Environment — never commit real credentials
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
frontend/node_modules/
|
||||
backend/node_modules/
|
||||
|
||||
# Build output
|
||||
frontend/dist/
|
||||
|
||||
# Runtime data
|
||||
data/
|
||||
uploads/
|
||||
|
||||
# Docker local volume mounts
|
||||
postgres-data/
|
||||
|
||||
# OS / editor artefacts
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Private reference / scratch docs
|
||||
ReferenceDocs/
|
||||
51
Bulk_User_Import.txt
Normal file
@@ -0,0 +1,51 @@
|
||||
<info_box>
|
||||
CVS Format (title>
|
||||
|
||||
FULL: email,firstname,lastname,password,role,default_usergroup
|
||||
MINIMUM: email,firstname,lastname,,,
|
||||
OPTIONAL: email,firstname,lastname,,,default_usergroup
|
||||
|
||||
We highly recommend using spreadsheet editor and save as a CSV file to ensure maximum accuracy
|
||||
You can include this header row (ie: email,firstname,lastname,password,role,usergroup,minor,guardian-user-email)
|
||||
|
||||
Examples:
|
||||
Parent: example@rosterchirp.com,Barney,Rubble,,member,parents,,
|
||||
Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,,
|
||||
Minor: Player: example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players,minor,example@rosterchirp.com
|
||||
|
||||
CVS Details (accordion title) - collapsed by default, click title to expand accordion
|
||||
<accordion>
|
||||
|
||||
CSV requirements:
|
||||
five commas, exactly, are required per row (rows with more or less will be ignored)
|
||||
email,firstname,lastname are the minimum required fields
|
||||
user can onlt be added to one group during a bulk import.
|
||||
optional fields: these fields can be left blank and the system defaults will be used
|
||||
|
||||
User Groups available: *
|
||||
list $user_groups (single column, multiple rows) that new users can be added to
|
||||
|
||||
Roles available: *
|
||||
member - non-priviledged user (default)
|
||||
manager - priviledged user: add/edit/remove schedules/users/user groups etc
|
||||
admin - priviledged user: manager + edit settings, change branding
|
||||
|
||||
* Only available group values (user group, roles) will be used, group values that do not exist will be ignored
|
||||
|
||||
Option field defaults:
|
||||
password ($setpass)
|
||||
role = member
|
||||
usergroup = <unset>
|
||||
minor = <unset>
|
||||
guardian-user-email = <unset>
|
||||
|
||||
</accordion>
|
||||
</info_box>
|
||||
|
||||
[Select CVS file] button as it currently exists
|
||||
checkbox "Ignore first row (header)"
|
||||
|
||||
**** Do not include the following text in the details above, they are your build instrcutions
|
||||
Build Instructions
|
||||
- validate all CVS requirements, skip rows that do not mean requirements
|
||||
- even if ignore first row is unchecked, check first header row for any values in "FULL" format, if true ignore row
|
||||
657
CLAUDE.md
@@ -4,7 +4,7 @@
|
||||
|
||||
**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.26
|
||||
**Current version:** 0.13.1
|
||||
|
||||
---
|
||||
|
||||
@@ -41,7 +41,7 @@ rosterchirp/
|
||||
│ │ └── auth.js ← JWT auth, teamManagerMiddleware
|
||||
│ ├── models/
|
||||
│ │ ├── db.js ← Postgres pool, query helpers, migrations, seeding
|
||||
│ │ └── migrations/ ← 001–006 SQL files, auto-applied on startup
|
||||
│ │ └── migrations/ ← 001–008 SQL files, auto-applied on startup
|
||||
│ ├── routes/
|
||||
│ │ ├── auth.js
|
||||
│ │ ├── groups.js ← receives io
|
||||
@@ -106,7 +106,7 @@ rosterchirp/
|
||||
|
||||
## Version Bump — Files to Update
|
||||
|
||||
When bumping the version (e.g. 0.11.26 → 0.11.27), update **all three**:
|
||||
When bumping the version (e.g. 0.12.28 → 0.12.29), 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.26; NEW=0.11.27
|
||||
OLD=0.12.28; NEW=0.12.29
|
||||
sed -i "s/\"version\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
|
||||
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh
|
||||
```
|
||||
@@ -184,6 +184,8 @@ const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||||
|
||||
**Critical:** The map key is `${schema}:${userId}` — not bare `userId`. Integer IDs are per-schema, so two tenants can have the same user ID. Without the schema prefix, push notifications and online presence would leak across tenants.
|
||||
|
||||
**Scale note:** This in-process Map is a single-server construct. See Phase 2 (Redis) for the multi-instance replacement.
|
||||
|
||||
---
|
||||
|
||||
## Active Sessions
|
||||
@@ -300,14 +302,639 @@ Single-user add/remove via `groups.js` (GroupInfoModal) always uses the named me
|
||||
|
||||
---
|
||||
|
||||
## FCM Push Notifications
|
||||
|
||||
**Status:** Working on Android (v0.12.26+). iOS in progress.
|
||||
|
||||
### Overview
|
||||
|
||||
Push notifications use Firebase Cloud Messaging (FCM) — not the older web-push/VAPID approach. VAPID env vars are still present (auto-generated on first start) but are no longer used for push delivery.
|
||||
|
||||
### Firebase Project Setup
|
||||
|
||||
1. Create a Firebase project at console.firebase.google.com
|
||||
2. Add a **Web app** to the project → copy the web app config values into `.env`
|
||||
3. In Project Settings → Cloud Messaging → **Web Push certificates** → generate a key pair → copy the public key as `FIREBASE_VAPID_KEY`
|
||||
4. In Project Settings → Service accounts → Generate new private key → download JSON → stringify it (remove all newlines) → set as `FIREBASE_SERVICE_ACCOUNT` in `.env`
|
||||
|
||||
Required `.env` vars:
|
||||
```
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_APP_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_VAPID_KEY= # Web Push certificate public key (from Cloud Messaging tab)
|
||||
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Frontend (browser/PWA)
|
||||
└─ usePushNotifications hook (Chat.jsx or dedicated hook)
|
||||
├─ GET /api/push/firebase-config → fetches SDK config from backend
|
||||
├─ Initialises Firebase JS SDK + getMessaging()
|
||||
├─ getToken(messaging, { vapidKey }) → obtains FCM token
|
||||
└─ POST /api/push/subscribe → registers token in push_subscriptions table
|
||||
|
||||
Backend (push.js)
|
||||
├─ sendPushToUser(schema, userId, payload) — shared helper, called from:
|
||||
│ ├─ messages.js (REST POST route — PRIMARY message path)
|
||||
│ └─ index.js (socket message:send handler — secondary/fallback)
|
||||
└─ Firebase Admin SDK sends the FCM message to Google's servers → device
|
||||
```
|
||||
|
||||
### Database
|
||||
|
||||
Table `push_subscriptions` (migration 007):
|
||||
```sql
|
||||
id, user_id, device ('mobile'|'desktop'), fcm_token, created_at
|
||||
```
|
||||
PK is `(user_id, device)` — one token per device type per user. `/api/push/subscribe` deletes the old row then inserts, so tokens stay fresh.
|
||||
|
||||
### Message Payload Structure
|
||||
|
||||
All real messages use `notification + data`:
|
||||
```js
|
||||
{
|
||||
token: sub.fcm_token,
|
||||
notification: { title, body }, // FCM shows this even if SW fails
|
||||
data: { url: '/', groupId: '42' }, // SW uses for click routing
|
||||
android: { priority: 'high', notification: { sound: 'default' } },
|
||||
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
|
||||
}
|
||||
```
|
||||
|
||||
### Service Worker (sw.js)
|
||||
|
||||
`onBackgroundMessage` fires when the PWA is backgrounded/closed. Shows the notification and stores `groupId` for click routing. When the user taps the notification, the SW's `notificationclick` handler navigates to the app.
|
||||
|
||||
### Push Trigger Logic (messages.js)
|
||||
|
||||
**Critical:** The frontend sends messages via `POST /api/messages/group/:groupId` (REST), not via the socket `message:send` event. Push notifications **must** be fired from `messages.js`, not just from the socket handler in `index.js`.
|
||||
|
||||
- **Private group:** query `group_members`, skip sender, call `sendPushToUser` for each member
|
||||
- **Public group:** query `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`, call `sendPushToUser` for each
|
||||
- Image messages use body `'📷 Image'`
|
||||
- The socket handler in `index.js` has identical logic for any future socket-path senders
|
||||
|
||||
### Debug & Test Endpoints
|
||||
|
||||
```
|
||||
GET /api/push/debug # admin only — lists all FCM tokens for this schema + firebase status
|
||||
POST /api/push/test # sends test push to own device
|
||||
POST /api/push/test?mode=browser # webpush-only test (Chrome handles directly, no SW involved)
|
||||
```
|
||||
|
||||
Use `/debug` to confirm tokens are registered. Use `/test` to verify end-to-end delivery independently of real message flow.
|
||||
|
||||
### Stale Token Cleanup
|
||||
|
||||
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for codes:
|
||||
- `messaging/registration-token-not-registered`
|
||||
- `messaging/invalid-registration-token`
|
||||
- `messaging/invalid-argument`
|
||||
|
||||
---
|
||||
|
||||
## Scale Architecture
|
||||
|
||||
### Context
|
||||
|
||||
RosterChirp-Host is expected to grow to 100,000+ tenants with some tenants having 300+ users — potentially millions of concurrent users total. The current single-process, single-database architecture has well-understood ceilings. This section documents what those ceilings are, what needs to change, and exactly how to implement each phase.
|
||||
|
||||
### How Messages Are Currently Loaded (No Problem Here)
|
||||
|
||||
Messages are **not** pre-loaded into server memory. The backend uses cursor-based pagination:
|
||||
- On conversation open: fetches the most recent **50 messages** via `ORDER BY created_at DESC LIMIT 50`
|
||||
- "Load older messages" button: fetches the next 50 using `before={oldest_message_id}` as a cursor
|
||||
- Each fetch is a fast indexed Postgres query; the Node process returns results and discards them immediately
|
||||
|
||||
The `messages` array grows in the **browser tab** as users scroll back (each "load more" prepends 50 items to React state). At extreme history depth this affects browser memory and scroll performance — a virtual scroll window would fix it — but this is a client-side concern, not a server concern.
|
||||
|
||||
### Current Architecture Ceilings
|
||||
|
||||
| Resource | Current Config | Approximate Ceiling |
|
||||
|---|---|---|
|
||||
| Node.js processes | 1 | ~10,000–30,000 concurrent sockets |
|
||||
| Postgres connections | Pool max 20 | Saturates under concurrent load |
|
||||
| `onlineUsers` Map | In-process JavaScript Map | Lost on restart; not shared across instances |
|
||||
| `tenantDomainCache` | In-process JavaScript Map | Stale on other instances after update |
|
||||
| File storage | `/app/uploads` (container volume) | Not accessible across multiple instances |
|
||||
|
||||
### Scale Targets by Phase
|
||||
|
||||
| Phase | Concurrent Users | Architecture |
|
||||
|---|---|---|
|
||||
| Current | ~5,000–10,000 | Single Node, single Postgres |
|
||||
| Phase 1 (PgBouncer) | ~20,000–40,000 | + connection pooler, no code changes |
|
||||
| Phase 2 (Redis) | ~200,000–500,000 | + Redis, multiple Node instances |
|
||||
| Phase 3 (Read replicas) | ~500,000–1,000,000 | + Postgres streaming replication |
|
||||
| Phase 4 (Sharding) | 1,000,000+ | Multiple Postgres clusters, regional deploy |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — PgBouncer (Implement Now)
|
||||
|
||||
### What It Does
|
||||
|
||||
PgBouncer sits between the Node app and Postgres as a connection pooler. Instead of Node holding up to 20 long-lived Postgres connections, PgBouncer maintains a pool of e.g. 100 server-side Postgres connections and multiplexes thousands of short application requests onto them. Postgres itself stays healthy; query throughput increases significantly under concurrent load.
|
||||
|
||||
**This requires zero code changes.** It is purely an infrastructure addition.
|
||||
|
||||
### Why It Matters Now
|
||||
|
||||
The current pool `max: 20` means at most 20 queries run simultaneously across all tenants. Under load (many tenants posting messages simultaneously) requests queue up waiting for a free connection. PgBouncer resolves this without touching a line of application code.
|
||||
|
||||
### Implementation
|
||||
|
||||
**Step 1: Add PgBouncer service to `docker-compose.host.yaml`**
|
||||
|
||||
```yaml
|
||||
pgbouncer:
|
||||
image: edoburu/pgbouncer:latest
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}_pgbouncer
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${DB_USER:-rosterchirp}:${DB_PASSWORD}@db:5432/${DB_NAME:-rosterchirp}
|
||||
- POOL_MODE=transaction
|
||||
- MAX_CLIENT_CONN=1000
|
||||
- DEFAULT_POOL_SIZE=100
|
||||
- MIN_POOL_SIZE=10
|
||||
- RESERVE_POOL_SIZE=20
|
||||
- RESERVE_POOL_TIMEOUT=5
|
||||
- SERVER_IDLE_TIMEOUT=600
|
||||
- LOG_CONNECTIONS=0
|
||||
- LOG_DISCONNECTIONS=0
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -h localhost -p 5432 -U ${DB_USER:-rosterchirp}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
```
|
||||
|
||||
**Step 2: Point the Node app at PgBouncer instead of Postgres directly**
|
||||
|
||||
In `docker-compose.host.yaml`, change the `jama` service environment:
|
||||
```yaml
|
||||
- DB_HOST=pgbouncer # was: db
|
||||
- DB_PORT=5432
|
||||
```
|
||||
|
||||
The `jama` service `depends_on` should add `pgbouncer`.
|
||||
|
||||
**Step 3: Tune Postgres `max_connections`**
|
||||
|
||||
Add to the `db` service in `docker-compose.host.yaml`:
|
||||
```yaml
|
||||
command: >
|
||||
postgres
|
||||
-c max_connections=200
|
||||
-c shared_buffers=256MB
|
||||
-c effective_cache_size=768MB
|
||||
-c work_mem=4MB
|
||||
-c maintenance_work_mem=64MB
|
||||
-c checkpoint_completion_target=0.9
|
||||
-c wal_buffers=16MB
|
||||
-c random_page_cost=1.1
|
||||
```
|
||||
|
||||
**Step 4: Increase the Node pool size**
|
||||
|
||||
In `backend/src/models/db.js`, increase `max` since PgBouncer multiplexes efficiently:
|
||||
```js
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'rosterchirp',
|
||||
user: process.env.DB_USER || 'rosterchirp',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 100, // was 20 — PgBouncer handles the actual Postgres pool
|
||||
idleTimeoutMillis: 10000, // was 30000 — release faster, PgBouncer manages persistence
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
**Important caveat — transaction mode:** PgBouncer in `POOL_MODE=transaction` releases the server connection after each transaction completes. This means `SET search_path` (which `db.js` runs before every query) is safe only because each `query()` call acquires, uses, and releases its own connection. Do **not** use session-level state or `LISTEN/NOTIFY` through PgBouncer — it won't work in transaction mode.
|
||||
|
||||
**Step 5: Add `PGBOUNCER_` vars to `.env.example`**
|
||||
```
|
||||
PGBOUNCER_MAX_CLIENT_CONN=1000
|
||||
PGBOUNCER_DEFAULT_POOL_SIZE=100
|
||||
```
|
||||
|
||||
**Step 6: Verify**
|
||||
|
||||
After deploying:
|
||||
```bash
|
||||
# Connect to PgBouncer admin console
|
||||
docker compose exec pgbouncer psql -h localhost -p 6432 -U pgbouncer pgbouncer
|
||||
SHOW POOLS; -- shows active/idle/waiting connections
|
||||
SHOW STATS; -- shows requests/sec
|
||||
```
|
||||
|
||||
### Expected Outcome
|
||||
|
||||
With PgBouncer in place, the database connection bottleneck is effectively eliminated for the near term. 1,000 simultaneous tenant requests will queue through PgBouncer's pool of 100 server connections rather than waiting for Node's pool of 20 application-level connections. Throughput roughly 5× at moderate load.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Redis (Horizontal Scaling)
|
||||
|
||||
### What It Does
|
||||
|
||||
Redis enables multiple Node.js instances to share state that currently lives in each process's memory:
|
||||
|
||||
1. **Socket.io Redis Adapter** — allows `io.to(room).emit()` to reach sockets on any instance
|
||||
2. **Shared `onlineUsers`** — replaces the in-process Map with a Redis `SADD`/`SREM`/`SMEMBERS` structure
|
||||
3. **Shared `tenantDomainCache`** — replaces the in-process Map with a Redis hash with TTL
|
||||
|
||||
Without Redis, running two Node instances would mean:
|
||||
- A message emitted on Instance A can't reach a user connected to Instance B
|
||||
- User A on Instance 1 shows as offline to User B on Instance 2
|
||||
- A custom domain update on Instance 1 isn't reflected on Instance 2
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Phase 1 (PgBouncer) should be deployed and stable first. Phase 2 is a significant code change — plan for a maintenance window.
|
||||
|
||||
### npm Packages Required
|
||||
|
||||
```bash
|
||||
npm install @socket.io/redis-adapter ioredis
|
||||
```
|
||||
|
||||
Add to `backend/package.json` dependencies.
|
||||
|
||||
### Step 1: Add Redis to docker-compose.host.yaml
|
||||
|
||||
```yaml
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ${PROJECT_NAME:-rosterchirp}_redis
|
||||
restart: unless-stopped
|
||||
command: >
|
||||
redis-server
|
||||
--maxmemory 512mb
|
||||
--maxmemory-policy allkeys-lru
|
||||
--save ""
|
||||
--appendonly no
|
||||
volumes:
|
||||
- rosterchirp_redis:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
rosterchirp_redis:
|
||||
driver: local
|
||||
```
|
||||
|
||||
Add `REDIS_URL=redis://redis:6379` to the `jama` service environment and to `.env.example`.
|
||||
|
||||
### Step 2: Socket.io Redis Adapter (index.js)
|
||||
|
||||
Replace the current `new Server(server, ...)` block:
|
||||
|
||||
```js
|
||||
const { createAdapter } = require('@socket.io/redis-adapter');
|
||||
const { createClient } = require('ioredis');
|
||||
|
||||
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
|
||||
|
||||
// Two Redis clients required by the adapter (pub + sub)
|
||||
const pubClient = createClient(REDIS_URL);
|
||||
const subClient = pubClient.duplicate();
|
||||
|
||||
await Promise.all([pubClient.connect(), subClient.connect()]);
|
||||
io.adapter(createAdapter(pubClient, subClient));
|
||||
console.log('[Server] Socket.io Redis adapter connected');
|
||||
```
|
||||
|
||||
This must be done **before** `io.on('connection', ...)` registers. With this in place, `io.to(room).emit(...)` fans out via Redis pub/sub to every Node instance — no other route code changes required.
|
||||
|
||||
### Step 3: Replace onlineUsers Map with Redis (index.js)
|
||||
|
||||
Current in-process Map:
|
||||
```js
|
||||
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||||
```
|
||||
|
||||
Replace with Redis operations. Create a dedicated Redis client for presence (separate from the adapter clients):
|
||||
|
||||
```js
|
||||
const presenceClient = createClient(REDIS_URL);
|
||||
await presenceClient.connect();
|
||||
|
||||
// Key structure: presence:{schema}:{userId} → Set of socketIds
|
||||
// TTL of 24h prevents stale keys if a server crashes without cleanup
|
||||
const PRESENCE_TTL = 86400; // seconds
|
||||
|
||||
async function addPresence(schema, userId, socketId) {
|
||||
const key = `presence:${schema}:${userId}`;
|
||||
await presenceClient.sAdd(key, socketId);
|
||||
await presenceClient.expire(key, PRESENCE_TTL);
|
||||
}
|
||||
|
||||
async function removePresence(schema, userId, socketId) {
|
||||
const key = `presence:${schema}:${userId}`;
|
||||
await presenceClient.sRem(key, socketId);
|
||||
// Return remaining count — 0 means user is now offline
|
||||
return presenceClient.sCard(key);
|
||||
}
|
||||
|
||||
async function isOnline(schema, userId) {
|
||||
const key = `presence:${schema}:${userId}`;
|
||||
return (await presenceClient.sCard(key)) > 0;
|
||||
}
|
||||
|
||||
async function getOnlineUserIds(schema) {
|
||||
// Scan keys matching presence:{schema}:* and return user IDs of non-empty sets
|
||||
const pattern = `presence:${schema}:*`;
|
||||
const keys = await presenceClient.keys(pattern);
|
||||
const online = [];
|
||||
for (const key of keys) {
|
||||
if ((await presenceClient.sCard(key)) > 0) {
|
||||
online.push(parseInt(key.split(':')[2]));
|
||||
}
|
||||
}
|
||||
return online;
|
||||
}
|
||||
```
|
||||
|
||||
Then replace all `onlineUsers.has/get/set/delete` calls in the `io.on('connection')` handler with the async Redis equivalents. This requires making the connection handler and its sub-handlers `async` where they aren't already.
|
||||
|
||||
**Disconnect handler becomes:**
|
||||
```js
|
||||
socket.on('disconnect', async () => {
|
||||
const remaining = await removePresence(schema, userId, socket.id);
|
||||
if (remaining === 0) {
|
||||
exec(schema, 'UPDATE users SET last_online=NOW() WHERE id=$1', [userId]).catch(() => {});
|
||||
io.to(R('schema', 'all')).emit('user:offline', { userId });
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**users:online handler becomes:**
|
||||
```js
|
||||
socket.on('users:online', async () => {
|
||||
const userIds = await getOnlineUserIds(schema);
|
||||
socket.emit('users:online', { userIds });
|
||||
});
|
||||
```
|
||||
|
||||
### Step 4: Replace tenantDomainCache with Redis (db.js)
|
||||
|
||||
Current in-process Map:
|
||||
```js
|
||||
const tenantDomainCache = new Map();
|
||||
```
|
||||
|
||||
Replace with a Redis hash with TTL:
|
||||
|
||||
```js
|
||||
let redisClient = null; // set externally after Redis connects
|
||||
|
||||
function setRedisClient(client) { redisClient = client; }
|
||||
|
||||
async function resolveSchema(req) {
|
||||
// ... existing logic up to custom domain lookup ...
|
||||
|
||||
// Custom domain lookup — Redis first, fallback to DB
|
||||
if (redisClient) {
|
||||
const cached = await redisClient.hGet('tenantDomainCache', host);
|
||||
if (cached) return cached;
|
||||
}
|
||||
// DB fallback
|
||||
const tenant = await queryOne('public',
|
||||
'SELECT schema_name FROM tenants WHERE custom_domain=$1 AND status=$2',
|
||||
[host, 'active']
|
||||
);
|
||||
if (tenant) {
|
||||
if (redisClient) await redisClient.hSet('tenantDomainCache', host, tenant.schema_name);
|
||||
return tenant.schema_name;
|
||||
}
|
||||
throw new Error(`Unknown tenant for host: ${host}`);
|
||||
}
|
||||
|
||||
async function refreshTenantCache(tenants) {
|
||||
if (!redisClient) return;
|
||||
// Rebuild the entire hash atomically
|
||||
await redisClient.del('tenantDomainCache');
|
||||
for (const t of tenants) {
|
||||
if (t.custom_domain && t.schema_name) {
|
||||
await redisClient.hSet('tenantDomainCache', t.custom_domain.toLowerCase(), t.schema_name);
|
||||
}
|
||||
}
|
||||
await redisClient.expire('tenantDomainCache', 3600); // 1h TTL as safety net
|
||||
}
|
||||
```
|
||||
|
||||
Export `setRedisClient` and call it from `index.js` after Redis connects, before `initDb()`.
|
||||
|
||||
When a custom domain is updated via the host control panel (`host.js`), call `refreshTenantCache` to invalidate immediately.
|
||||
|
||||
### Step 5: File Storage — Move to Object Storage
|
||||
|
||||
With multiple Node instances, each container has its own `/app/uploads` volume. An avatar uploaded to Instance A isn't accessible from Instance B.
|
||||
|
||||
**Recommended: Cloudflare R2** (S3-compatible, free egress, affordable storage)
|
||||
|
||||
```bash
|
||||
npm install @aws-sdk/client-s3 @aws-sdk/s3-request-presigner
|
||||
```
|
||||
|
||||
Changes to `backend/src/routes/users.js` (avatar upload) and `backend/src/routes/settings.js` (logo/icon upload):
|
||||
|
||||
```js
|
||||
const { S3Client, PutObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
||||
|
||||
const s3 = new S3Client({
|
||||
region: 'auto',
|
||||
endpoint: process.env.R2_ENDPOINT, // https://<account>.r2.cloudflarestorage.com
|
||||
credentials: {
|
||||
accessKeyId: process.env.R2_ACCESS_KEY_ID,
|
||||
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
async function uploadToR2(buffer, key, contentType) {
|
||||
await s3.send(new PutObjectCommand({
|
||||
Bucket: process.env.R2_BUCKET,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}));
|
||||
return `${process.env.R2_PUBLIC_URL}/${key}`; // R2 public bucket URL
|
||||
}
|
||||
```
|
||||
|
||||
All `avatarUrl` and `logoUrl` values stored in the DB become full `https://` URLs rather than `/uploads/...` paths. The frontend already renders them via `<img src={url}>` so no frontend changes are needed.
|
||||
|
||||
Add to `.env.example`:
|
||||
```
|
||||
R2_ENDPOINT=
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
R2_PUBLIC_URL= # e.g. https://assets.yourdomain.com
|
||||
```
|
||||
|
||||
### Step 6: Load Balancing Multiple Node Instances
|
||||
|
||||
With Redis adapter in place, run multiple Node containers behind Caddy:
|
||||
|
||||
In `docker-compose.host.yaml`, add additional app instances:
|
||||
```yaml
|
||||
rosterchirp_1:
|
||||
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||
<<: *rosterchirp-base # use YAML anchors for shared config
|
||||
container_name: rosterchirp_1
|
||||
|
||||
rosterchirp_2:
|
||||
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||
<<: *rosterchirp-base
|
||||
container_name: rosterchirp_2
|
||||
```
|
||||
|
||||
**Caddyfile update:**
|
||||
```
|
||||
{HOST_DOMAIN} {
|
||||
reverse_proxy rosterchirp_1:3000 rosterchirp_2:3000 {
|
||||
lb_policy round_robin
|
||||
health_uri /api/health
|
||||
health_interval 15s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Critical — WebSocket sticky sessions:** Socket.io with the Redis adapter handles cross-instance messaging, but the **initial HTTP upgrade handshake** must land on the same instance as the polling fallback. Caddy's `lb_policy round_robin` handles this correctly for WebSocket connections (once upgraded, the connection stays). For the polling transport, add:
|
||||
|
||||
```
|
||||
header_up X-Real-IP {remote_host}
|
||||
header_up Cookie {http.request.header.Cookie}
|
||||
```
|
||||
|
||||
Or force WebSocket-only transport in the Socket.io client config (eliminates the polling concern entirely):
|
||||
```js
|
||||
// frontend/src/contexts/SocketContext.jsx
|
||||
const socket = io({ transports: ['websocket'] });
|
||||
```
|
||||
|
||||
### Step 7: Verify Redis Phase
|
||||
|
||||
After deploying:
|
||||
```bash
|
||||
# Check adapter is working — should see Redis keys
|
||||
docker compose exec redis redis-cli keys '*'
|
||||
|
||||
# Check presence tracking
|
||||
docker compose exec redis redis-cli keys 'presence:*'
|
||||
|
||||
# Check tenant cache
|
||||
docker compose exec redis redis-cli hgetall tenantDomainCache
|
||||
|
||||
# Monitor real-time Redis traffic during a test message send
|
||||
docker compose exec redis redis-cli monitor
|
||||
```
|
||||
|
||||
### Phase 2 Summary — Files Changed
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `backend/src/index.js` | Redis adapter, presence helpers replacing onlineUsers Map |
|
||||
| `backend/src/models/db.js` | Redis-backed tenantDomainCache, setRedisClient export |
|
||||
| `backend/src/routes/users.js` | R2 upload for avatars |
|
||||
| `backend/src/routes/settings.js` | R2 upload for logos/icons |
|
||||
| `backend/package.json` | Add `@socket.io/redis-adapter`, `ioredis`, `@aws-sdk/client-s3` |
|
||||
| `docker-compose.host.yaml` | Add Redis service, multiple app instances, Caddy lb |
|
||||
| `frontend/src/contexts/SocketContext.jsx` | Force WebSocket transport |
|
||||
| `.env.example` | Add `REDIS_URL`, `R2_*` vars |
|
||||
|
||||
---
|
||||
|
||||
## Mobile Input / Auto-Fill Fixes
|
||||
|
||||
### CSS (`index.css`)
|
||||
|
||||
**Auto-fill styling** — prevents browser yellow/blue autofill background from breaking the theme:
|
||||
```css
|
||||
input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
|
||||
-webkit-text-fill-color: var(--text-primary) !important;
|
||||
transition: background-color 5000s ease-in-out 0s !important;
|
||||
}
|
||||
```
|
||||
|
||||
**Prevent iOS zoom on input focus** — iOS zooms in if font-size < 16px:
|
||||
```css
|
||||
@media (max-width: 768px) {
|
||||
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
|
||||
}
|
||||
```
|
||||
|
||||
### Input Attributes
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `MessageInput.jsx` | `autoComplete="off"`, `autoCorrect="off"`, `autoCapitalize="sentences"`, `spellCheck="true"` on message textarea |
|
||||
| `PasswordInput.jsx` | Default `autoComplete="new-password"`, `autoCorrect="off"`, `autoCapitalize="off"`, `spellCheck="false"` (callers can override via props) |
|
||||
| `Login.jsx` | Email: `autoComplete="email"`, `autoCorrect="off"`, `autoCapitalize="off"`, `spellCheck="false"`; Password: `autoComplete="current-password"` |
|
||||
| `ChangePassword.jsx` | Current password: `autoComplete="current-password"`; new/confirm: inherit `new-password` default |
|
||||
| `UserManagerPage.jsx` | Email: `autoComplete="email"`; First/Last name: `given-name`/`family-name`; Phone: `autoComplete="tel"` |
|
||||
| `GroupManagerPage.jsx` | Fixed duplicate `autoComplete` attributes; search/name inputs use `autoComplete="off"` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Read Replicas (Future)
|
||||
|
||||
When write load on Postgres becomes a bottleneck (typically >100,000 concurrent active users):
|
||||
|
||||
1. Configure Postgres streaming replication — one primary, 1–2 standbys
|
||||
2. In `db.js`, maintain two pools: `primaryPool` (writes) and `replicaPool` (reads)
|
||||
3. Route `query()` to `replicaPool`, `exec()`/`queryResult()` to `primaryPool`
|
||||
4. `withTransaction()` always uses `primaryPool`
|
||||
|
||||
This is entirely within `db.js` — no route changes needed if the abstraction is preserved.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Tenant Sharding (Future)
|
||||
|
||||
When a single Postgres cluster can't handle the write volume (millions of active tenants):
|
||||
|
||||
1. Assign each tenant to a shard (DB cluster) at provisioning time — store in the `tenants` table as `shard_id`
|
||||
2. `resolveSchema()` in `db.js` looks up the tenant's shard and returns both schema name and DB host
|
||||
3. Maintain a pool per shard rather than one global pool
|
||||
4. `host.js` provisioning logic assigns shards using a round-robin or least-loaded strategy
|
||||
|
||||
This is a significant architectural change. Do not implement until clearly needed.
|
||||
|
||||
---
|
||||
|
||||
## Outstanding / Deferred Work
|
||||
|
||||
### Android Background Push (KNOWN_LIMITATIONS.md)
|
||||
**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.
|
||||
### iOS Push Notifications
|
||||
**Status:** In progress. Android working (v0.12.26+). iOS PWA push requires additional handling — investigation ongoing.
|
||||
|
||||
### WebSocket Reconnect on Focus
|
||||
**Status:** Deferred. Socket drops when Android PWA is backgrounded.
|
||||
**Fix:** Frontend-only — listen for `visibilitychange` in `SocketContext.jsx`, reconnect socket when `document.visibilityState === 'visible'`.
|
||||
**Fix:** Frontend-only — listen for `visibilitychange` in `SocketContext.jsx`, reconnect socket when `document.visibilityState === 'visible'`. Note: forcing WebSocket-only transport (Phase 2 Step 6) may affect reconnect behaviour — implement reconnect-on-focus at the same time as the transport change.
|
||||
|
||||
### Message History — Browser Memory
|
||||
**Status:** Future. The `messages` array in `ChatWindow` grows unbounded as a user scrolls back through history. At extreme depth (thousands of messages in one session), this affects browser scroll performance.
|
||||
**Fix:** Virtual scroll window — discard messages scrolled far out of view, re-fetch on demand. This is a non-trivial frontend refactor (react-virtual or similar). Not needed until users regularly have very long scrollback sessions.
|
||||
|
||||
### Orphaned Image Cleanup
|
||||
**Status:** Future. Deleted messages null `image_url` in DB but leave the file on disk (or in R2 after Phase 2). A background job that periodically deletes image files with no corresponding DB row would prevent unbounded storage growth.
|
||||
|
||||
### hasMore Heuristic
|
||||
**Status:** Minor. `hasMore` is set to `true` when `messages.length >= 50`. If a conversation has exactly 50 messages total, this shows a "Load older" button that returns nothing. Fix: return a `total` count from the backend GET messages route, or check `older.length < 50` to detect end of history.
|
||||
|
||||
---
|
||||
|
||||
@@ -319,7 +946,7 @@ APP_TYPE=selfhost|host
|
||||
HOST_DOMAIN= # host mode only
|
||||
HOST_ADMIN_KEY= # host mode only
|
||||
JWT_SECRET=
|
||||
DB_HOST=db
|
||||
DB_HOST=db # set to 'pgbouncer' after Phase 1
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose)
|
||||
@@ -339,6 +966,18 @@ 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)
|
||||
|
||||
# Phase 1 (PgBouncer)
|
||||
PGBOUNCER_MAX_CLIENT_CONN=1000
|
||||
PGBOUNCER_DEFAULT_POOL_SIZE=100
|
||||
|
||||
# Phase 2 (Redis + R2)
|
||||
REDIS_URL=redis://redis:6379
|
||||
R2_ENDPOINT= # https://<account>.r2.cloudflarestorage.com
|
||||
R2_ACCESS_KEY_ID=
|
||||
R2_SECRET_ACCESS_KEY=
|
||||
R2_BUCKET=
|
||||
R2_PUBLIC_URL= # https://assets.yourdomain.com
|
||||
```
|
||||
|
||||
---
|
||||
@@ -359,4 +998,4 @@ Build sequence: `build.sh` → Docker build → `npm run build` (Vite) → `dock
|
||||
|
||||
## Session History
|
||||
|
||||
Development continues in Claude Code from v0.11.26 (rebranded from jama to RosterChirp).
|
||||
Development continues in Claude Code from v0.11.26 (rebranded from jama to RosterChirp). Scale architecture analysis and Phase 1/2 implementation specs added based on planned growth to 100,000+ tenants.
|
||||
|
||||
@@ -12,18 +12,17 @@
|
||||
# CF_API_TOKEN=your_cloudflare_token (or equivalent)
|
||||
#
|
||||
# 3. Add a wildcard DNS record in your DNS provider:
|
||||
# *.rosterchirp.com → your server IP
|
||||
# rosterchirp.com → your server IP
|
||||
# *.example.com → your server IP
|
||||
#
|
||||
# Usage:
|
||||
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
|
||||
# Reload: caddy reload
|
||||
|
||||
# ── Wildcard subdomain ────────────────────────────────────────────────────────
|
||||
# Handles team1.rosterchirp.com, teamB.rosterchirp.com, etc.
|
||||
# Replace rosterchirp.com with your actual HOST_DOMAIN.
|
||||
# Handles mychat.example.com, teamB.example.com, chathost.example.com, etc.
|
||||
# Replace example.com with your actual APP_DOMAIN.
|
||||
|
||||
*.rosterchirp.com {
|
||||
*.example.com {
|
||||
tls {
|
||||
dns cloudflare {env.CF_API_TOKEN}
|
||||
}
|
||||
@@ -47,20 +46,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
# ── Base domain (host admin panel) ───────────────────────────────────────────
|
||||
rosterchirp.com {
|
||||
reverse_proxy localhost:3000
|
||||
header {
|
||||
Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
X-Content-Type-Options nosniff
|
||||
-Server
|
||||
}
|
||||
}
|
||||
|
||||
# ── 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 → rosterchirp.com
|
||||
# 1. They add a DNS CNAME: chat.theircompany.com → your server IP
|
||||
#
|
||||
# 2. You add a block here and reload Caddy.
|
||||
# Caddy will automatically obtain and renew the SSL cert.
|
||||
@@ -80,7 +69,7 @@ rosterchirp.com {
|
||||
# }
|
||||
# }
|
||||
#
|
||||
# *.rosterchirp.com, rosterchirp.com {
|
||||
# *.example.com {
|
||||
# tls { on_demand }
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
|
||||
152
FEATURES.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# RosterChirp — Feature Reference
|
||||
|
||||
> **Current version:** 0.12.42
|
||||
> **Application types:** RosterChirp-Chat · RosterChirp-Brand · RosterChirp-Team
|
||||
|
||||
---
|
||||
|
||||
## All Users
|
||||
|
||||
### Messaging
|
||||
|
||||
- **Public Messages** — Read and post in public group channels open to all members. Channels can be marked read-only by an admin (announcements-style).
|
||||
- **Private Group Messages** — Participate in named private groups with a specific set of members.
|
||||
- **Direct Messages (U2U)** — Start a private one-on-one conversation with any user who has not blocked direct messages.
|
||||
- **Group Messages** — Access managed private group conversations assigned to you through User Groups (requires RosterChirp-Team).
|
||||
- **Message History** — Scroll back through conversation history with paginated loading (50 messages per page).
|
||||
- **Message Reactions** — React to any message with an emoji.
|
||||
- **Image Sharing** — Attach and send images in any conversation.
|
||||
- **Reply Threading** — Reply to a specific message to preserve context.
|
||||
- **@Mentions** — Mention users by name; mentioned users receive a notification badge.
|
||||
- **Link Previews** — URLs pasted into messages automatically generate a title/image preview card.
|
||||
- **Message Deletion** — Authors can delete their own messages; deleted messages are replaced with a tombstone.
|
||||
|
||||
### Schedule (requires RosterChirp-Team)
|
||||
|
||||
- **Calendar View** — Browse events in a full monthly calendar grid (desktop) or a day-list view (mobile).
|
||||
- **Event Details** — Tap any event to view its full details: date/time, location, description, event type, assigned user groups, and recurrence pattern.
|
||||
- **Availability Response** — Respond to events with **Going**, **Maybe**, or **Not Going**, plus an optional short note (up to 20 characters).
|
||||
- **Bulk Availability** — Respond to multiple pending events at once from a single screen.
|
||||
- **Response Summary** — See how many group members have responded Going / Maybe / Not Going on any event you are assigned to.
|
||||
- **Filter & Search** — Filter the calendar by event type, keyword, or your own availability status. Keyword search supports word-boundary matching and exact quoted terms.
|
||||
|
||||
### Profile & Account
|
||||
|
||||
- **Display Name** — Set a public display name shown alongside your username (must be unique).
|
||||
- **Avatar** — Upload a custom profile photo. A consistent colour avatar is generated automatically from your name if no photo is set.
|
||||
- **About Me** — Add a short bio visible on your profile.
|
||||
- **Hide Admin Tag** — Admins can choose to hide the "Admin" role badge on their messages.
|
||||
- **Block Direct Messages** — Opt out of receiving unsolicited direct messages from other users.
|
||||
- **Change Password** — Change your own account password at any time.
|
||||
- **Font Scale** — Adjust the interface text size (80%–200%) stored per-device.
|
||||
|
||||
### Notifications & Presence
|
||||
|
||||
- **Push Notifications** — Receive push notifications for new messages when the app is backgrounded. Supports Android (Firebase Cloud Messaging) and iOS 16.4+ PWA (Web Push / VAPID).
|
||||
- **Notification Permission** — Grant or revoke push notification permission from the Notifications tab in your profile.
|
||||
- **Unread Badges** — Conversations with unread messages display a count badge in the sidebar and on the PWA app icon.
|
||||
- **Online Presence** — A green indicator shows which users are currently active. Last-seen time is displayed for offline users.
|
||||
- **Browser Tab Badge** — The page title and PWA icon badge update with the total unread count across all conversations.
|
||||
|
||||
### App Experience
|
||||
|
||||
- **Progressive Web App (PWA)** — Install RosterChirp to your home screen on Android, iOS, and desktop for a native app feel.
|
||||
- **Dark / Light Theme** — The interface respects your operating system's colour scheme preference automatically.
|
||||
- **Mobile-Optimised Layout** — A dedicated mobile layout with a slide-in sidebar, swipe-back navigation, and mobile-native time/date pickers.
|
||||
- **Keyboard Shortcuts** — Press Enter to send messages; Escape to dismiss modals.
|
||||
|
||||
---
|
||||
|
||||
## Managers (Tool Managers)
|
||||
|
||||
Tool Manager access is granted by an admin to members of one or more designated **User Groups**. Managers have access to the following tools in addition to all user features.
|
||||
|
||||
### User Manager
|
||||
|
||||
- **View All Users** — Browse the full user directory including email, role, phone, status, and last seen time.
|
||||
- **Create Users** — Add individual new user accounts with name, email, role, and phone.
|
||||
- **Bulk Import** — Import multiple users at once from a structured list (CSV-compatible).
|
||||
- **Edit Users** — Update names, email addresses, phone numbers, and minor status for any user.
|
||||
- **Suspend / Activate** — Suspend a user to block login without deleting their account or messages. Reversible at any time.
|
||||
- **Reset Password** — Set a new temporary password for any user.
|
||||
|
||||
### Group Manager (requires RosterChirp-Team)
|
||||
|
||||
- **Create User Groups** — Create named user groups to organise members into teams or departments.
|
||||
- **Manage Members** — Add or remove users from any user group. Member changes trigger a system notification in the group's conversation.
|
||||
- **Multi-Groups** — Create a multi-group conversation that spans multiple user groups simultaneously.
|
||||
- **Assign Schedule Groups** — Link user groups to schedule events to control who is invited and whose availability is tracked.
|
||||
|
||||
### Schedule Manager (requires RosterChirp-Team)
|
||||
|
||||
- **Create Events** — Create new calendar events with title, type, date/time, location, description, visibility (public/private), and assigned user groups.
|
||||
- **Edit & Delete Events** — Modify or remove any event. Recurring events support editing/deleting a single occurrence, all future occurrences, or the entire series.
|
||||
- **Recurring Events** — Schedule repeating events (daily, weekly, bi-weekly, monthly) with optional end date or occurrence count. Supports specific weekday selection for weekly recurrence.
|
||||
- **Event Types** — Create and manage colour-coded event type categories (e.g. Training, Match, Meeting).
|
||||
- **Track Availability** — Enable availability tracking on an event to collect Going / Maybe / Not Going responses from assigned group members.
|
||||
- **View Full Responses** — See the complete list of who has responded and with what answer, including individual notes. The **No Response** count shows how many assigned members have not yet replied.
|
||||
- **Download Availability List** — Export a formatted `.txt` file of all availability responses for an event, organised by section (Going, Maybe, Not Going, No Response) and sorted alphabetically by last name within each section.
|
||||
- **Import Schedule** — Upload and preview a schedule import file, then confirm to bulk-create events.
|
||||
- **Past Event Visibility** — View and manage past events in the calendar; past events are displayed in a greyed style.
|
||||
|
||||
---
|
||||
|
||||
## Admins
|
||||
|
||||
Admins have full access to all user and manager features plus the following administrative controls.
|
||||
|
||||
### User Manager (extended)
|
||||
|
||||
- **Delete Users** — Permanently scrub a user's account: email and name are anonymised, all their messages are marked deleted, and direct message threads become read-only. Frees the email address for re-registration immediately.
|
||||
- **Assign Roles** — Promote or demote users between the **User**, **Manager**, and **Admin** roles.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Message Features** — Enable or disable individual message channel types across the entire instance: Public Messages, Group Messages, Private Group Messages, and Private Messages (U2U). Disabled features are hidden from all menus, sidebars, and modals.
|
||||
- **Registration** — Apply a registration code to unlock the application type (Chat / Brand / Team) and associated features. View the instance serial number and current registration status.
|
||||
|
||||
### Branding (requires RosterChirp-Brand or higher)
|
||||
|
||||
- **App Name** — Set a custom application name that appears in the header, browser tab, and push notifications.
|
||||
- **Logo / Favicon** — Upload a custom logo used as the app header image and PWA icon (192×512 px generated automatically).
|
||||
- **Header Colour** — Set custom header bar colours for light mode and dark mode independently.
|
||||
- **Avatar Colours** — Customise the default avatar colours used for public channel icons and direct message icons.
|
||||
- **Reset Branding** — Restore all branding settings to the default RosterChirp values in one click.
|
||||
|
||||
### Team Configuration (requires RosterChirp-Team)
|
||||
|
||||
- **Tool Manager Groups** — Designate one or more User Groups whose members are granted Tool Manager access (User Manager, Group Manager, Schedule Manager). Admins always have full access regardless of this setting.
|
||||
|
||||
### Control Panel (Host mode only — admin on the host domain)
|
||||
|
||||
- **Tenant Management** — View, create, suspend, and delete tenant instances from a central dashboard.
|
||||
- **Assign Plans** — Set the application type (Chat / Brand / Team) for each tenant.
|
||||
- **Custom Domains** — Assign a custom domain to a tenant in addition to its default subdomain.
|
||||
- **Tenant Details** — View each tenant's slug, plan, status, custom domain, and creation date.
|
||||
|
||||
---
|
||||
|
||||
## Hosting & Tenant Privacy
|
||||
|
||||
RosterChirp supports two deployment modes configured via the `APP_TYPE` environment variable.
|
||||
|
||||
### Self-Hosted (Single Tenant)
|
||||
|
||||
`APP_TYPE=selfhost` — The default mode for teams running their own private instance. All data is stored in a single PostgreSQL schema. There are no subdomains or tenant concepts; the application runs at the root of whatever domain or IP the server is deployed on.
|
||||
|
||||
### RosterChirp-Host (Multi-Tenant)
|
||||
|
||||
`APP_TYPE=host` — Enables multi-tenant hosting from a single server. Each tenant is provisioned with:
|
||||
|
||||
- **A unique slug** — for example, the slug `acme` creates a dedicated instance accessible at `acme.yourdomain.com`. The slug is set at provisioning time and forms the permanent subdomain for that tenant.
|
||||
- **An isolated Postgres schema** — every tenant's data (users, messages, groups, events, settings) lives in its own named schema (`tenant_acme`, etc.) within the same database. No data is shared between tenants.
|
||||
- **An optional custom domain** — a tenant can be mapped to a fully custom domain (e.g. `chat.acme.com`) in addition to its default subdomain. Custom domain lookups are cached for performance.
|
||||
- **Plan-level feature control** — each tenant can be assigned a different application type (Chat / Brand / Team), enabling per-tenant feature gating from the host control panel.
|
||||
|
||||
### Privacy & Isolation Guarantees
|
||||
|
||||
- **Schema isolation** — all database queries are scoped to the tenant's schema. A query in one tenant's context cannot read or write another tenant's tables.
|
||||
- **Socket room isolation** — all real-time socket rooms are prefixed with the tenant schema name (`acme:group:42`). Events emitted in one tenant's rooms cannot reach sockets in another tenant.
|
||||
- **Online presence isolation** — the online user map is keyed by `schema:userId`, preventing user ID collisions between tenants from leaking presence data.
|
||||
- **Session isolation** — JWT tokens are validated against the tenant schema. A valid token for one tenant is not accepted by another.
|
||||
- **Host control plane separation** — the host admin control panel is only accessible on the host's own root domain, protected by a separate `HOST_ADMIN_KEY`, and hidden from all tenant subdomains.
|
||||
297
README.md
@@ -1,7 +1,16 @@
|
||||
# jama 💬
|
||||
### *just another messaging app*
|
||||
<<<<<<< HEAD
|
||||
# RosterChirp
|
||||
|
||||
A modern, self-hosted team messaging Progressive Web App (PWA) built for small to medium teams. jama runs entirely in a single Docker container with no external database dependencies — all data is stored locally using SQLite.
|
||||
A modern, self-hosted team messaging Progressive Web App (PWA) built for small to medium teams. RosterChirp runs via Docker Compose with PostgreSQL and supports both single-tenant (self-hosted) and multi-tenant (hosted) deployments.
|
||||
|
||||
Development was vibe-coded using Claude.ai.
|
||||
|
||||
**Current version:** 0.13.1
|
||||
=======
|
||||
# rosterchirp
|
||||
|
||||
A modern, self-hosted team messaging Progressive Web App (PWA) built for small to medium teams. rosterchirp runs entirely in a single Docker container with no external database dependencies — all data is stored locally using SQLite.
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
---
|
||||
|
||||
@@ -29,6 +38,17 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
|
||||
- **Read-only channels** — Admin-configurable announcement-style channels; only admins can post
|
||||
- **Support group** — A private admin-only group that receives submissions from the login page contact form
|
||||
- **Custom group names** — Each user can set a personal display name for any group, visible only to them
|
||||
- **Group Messages** — Managed private groups (created and controlled by admins via Group Manager) appear in a separate "Private Group Messages" section in the sidebar
|
||||
|
||||
### Schedule
|
||||
- **Team schedule** — Full calendar view for creating and managing team events (Team plan)
|
||||
- **Desktop & mobile views** — Dedicated layout for each; desktop shows a full monthly grid, mobile shows a scrollable event list
|
||||
- **Event types** — Colour-coded event categories (configurable by admins)
|
||||
- **Recurring events** — Create daily, weekly, or custom-interval recurring events; only future occurrences are shown
|
||||
- **Availability** — Users can mark their availability per event
|
||||
- **Keyword filter** — Search events by keyword with word-boundary matching; quoted terms match exactly
|
||||
- **Type filter** — Filter events by event type across the current month (including past events, shown greyed)
|
||||
- **Past event protection** — New events cannot be created with a start date/time in the past
|
||||
|
||||
### Users & Profiles
|
||||
- **Authentication** — Email/password login with optional Remember Me (30-day session)
|
||||
@@ -36,19 +56,29 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
|
||||
- **User profiles** — Custom display name, avatar upload, About Me text
|
||||
- **Profile popup** — Click any user's avatar in chat to view their profile card
|
||||
- **Admin badge** — Admins display a role badge; can be hidden per-user in Profile settings
|
||||
- **Online presence** — Real-time online/offline status tracked per user
|
||||
- **Last seen** — Users' last online timestamp updated on disconnect
|
||||
|
||||
### Notifications
|
||||
- **In-app notifications** — Mention alerts with toast notifications
|
||||
- **Unread indicators** — Private groups with new unread messages are highlighted and bolded in the sidebar
|
||||
- **Web Push notifications** — Badge and push notifications for mentions and new private messages when the app is backgrounded or closed (requires HTTPS)
|
||||
- **Push notifications** — Firebase Cloud Messaging (FCM) push notifications for mentions and new private messages when the app is backgrounded or closed (Android PWA; requires HTTPS and Firebase setup)
|
||||
|
||||
### Admin & Settings
|
||||
- **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles
|
||||
- **Bulk CSV import** — Import multiple users at once from a CSV file
|
||||
- **App branding** — Customize app name and logo via the Settings panel
|
||||
- **Group Manager** — Create and manage private groups and their membership centrally (Team plan)
|
||||
- **App branding** — Customize app name, logo, and icons via the Settings panel (Brand+ plan)
|
||||
- **Reset to defaults** — One-click reset of all branding customizations
|
||||
- **Version display** — Current app version shown in the Settings panel
|
||||
- **Default user password** — Configurable via `USER_PASS` env var; shown live in User Manager
|
||||
- **Feature flags** — Plan-gated features (branding, group manager, schedule manager) controlled via settings
|
||||
|
||||
### User Deletion
|
||||
- Deleting a user scrubs their email, name, and avatar immediately
|
||||
- Their messages are marked deleted (content removed); direct message threads become read-only
|
||||
- Group memberships, sessions, push subscriptions, and notifications are purged
|
||||
- Suspended users retain all data and can be re-activated
|
||||
|
||||
### Help & Onboarding
|
||||
- **Getting Started modal** — Appears automatically on first login; users can dismiss permanently with "Do not show again"
|
||||
@@ -67,18 +97,39 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
|
||||
|
||||
---
|
||||
|
||||
## Deployment Modes
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `selfhost` | Single tenant — one team, one database schema. Default. |
|
||||
| `host` | Multi-tenant — one schema per tenant, provisioned via subdomains. Requires `APP_DOMAIN`, `HOST_SLUG`, and `HOST_ADMIN_KEY`. |
|
||||
|
||||
Set `APP_TYPE=selfhost` or `APP_TYPE=host` in `.env`.
|
||||
|
||||
---
|
||||
|
||||
## Plans & Feature Flags
|
||||
|
||||
| Plan | Features |
|
||||
|---|---|
|
||||
| **RosterChirp-Chat** | Messaging, channels, DMs, profiles, push notifications |
|
||||
| **RosterChirp-Brand** | Everything in Chat + custom branding (logo, app name, icons) |
|
||||
| **RosterChirp-Team** | Everything in Brand + Group Manager + Schedule Manager |
|
||||
|
||||
Feature flags are stored in the database `settings` table and can be toggled by the admin.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend | Node.js, Express, Socket.io |
|
||||
| Database | SQLite (better-sqlite3) |
|
||||
| Database | PostgreSQL 16 (via `pg`) |
|
||||
| Frontend | React 18, Vite |
|
||||
| Markdown rendering | marked |
|
||||
| Emoji picker | emoji-mart |
|
||||
| Push notifications | Firebase Cloud Messaging (FCM) |
|
||||
| Image processing | sharp |
|
||||
| Push notifications | web-push (VAPID) |
|
||||
| Containerization | Docker, Docker Compose |
|
||||
| Containerization | Docker, Docker Compose v2 |
|
||||
| Reverse proxy / SSL | Caddy (recommended) |
|
||||
|
||||
---
|
||||
@@ -86,8 +137,9 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
|
||||
## Requirements
|
||||
|
||||
- **Docker** and **Docker Compose v2**
|
||||
- A domain name with DNS pointed at your server (required for HTTPS and Web Push notifications)
|
||||
- A domain name with DNS pointed at your server (required for HTTPS and push notifications)
|
||||
- Ports **80** and **443** open on your server firewall (if using Caddy for SSL)
|
||||
- (Optional) A Firebase project for push notifications
|
||||
|
||||
---
|
||||
|
||||
@@ -102,7 +154,7 @@ All builds use `build.sh`. No host Node.js installation is required.
|
||||
./build.sh
|
||||
|
||||
# Build and tag as a specific version
|
||||
./build.sh 1.0.0
|
||||
./build.sh 0.13.1
|
||||
```
|
||||
|
||||
---
|
||||
@@ -112,14 +164,18 @@ All builds use `build.sh`. No host Node.js installation is required.
|
||||
### 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://your-gitea/youruser/jama.git
|
||||
cd jama
|
||||
<<<<<<< HEAD
|
||||
git clone https://your-git/youruser/rosterchirp.git
|
||||
=======
|
||||
git clone https://your-gitea/youruser/rosterchirp.git
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
cd rosterchirp
|
||||
```
|
||||
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
./build.sh 1.0.0
|
||||
./build.sh 0.13.1
|
||||
```
|
||||
|
||||
### 3. Configure environment
|
||||
@@ -129,13 +185,13 @@ cp .env.example .env
|
||||
nano .env
|
||||
```
|
||||
|
||||
At minimum, change `ADMIN_EMAIL`, `ADMIN_PASS`, and `JWT_SECRET`.
|
||||
At minimum, set `ADMIN_EMAIL`, `ADMIN_PASS`, `ADMIN_NAME`, `JWT_SECRET`, and `DB_PASSWORD`.
|
||||
|
||||
### 4. Start the container
|
||||
### 4. Start the services
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
docker compose logs -f jama
|
||||
docker compose logs -f rosterchirp
|
||||
```
|
||||
|
||||
### 5. Log in
|
||||
@@ -146,40 +202,79 @@ Open `http://your-server:3000`, log in with your `ADMIN_EMAIL` and `ADMIN_PASS`,
|
||||
|
||||
## HTTPS & SSL
|
||||
|
||||
jama does not manage SSL itself. Use **Caddy** as a reverse proxy.
|
||||
<<<<<<< HEAD
|
||||
RosterChirp does not manage SSL itself. Use **Caddy** as a reverse proxy.
|
||||
=======
|
||||
rosterchirp does not manage SSL itself. Use **Caddy** as a reverse proxy.
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
### Caddyfile
|
||||
|
||||
```
|
||||
chat.yourdomain.com {
|
||||
reverse_proxy jama:3000
|
||||
reverse_proxy rosterchirp:3000
|
||||
}
|
||||
```
|
||||
|
||||
### docker-compose.yaml (with Caddy)
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
jama:
|
||||
image: jama:${JAMA_VERSION:-latest}
|
||||
container_name: jama
|
||||
rosterchirp:
|
||||
<<<<<<< HEAD
|
||||
image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
|
||||
=======
|
||||
image: rosterchirp:${rosterchirp_VERSION:-latest}
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
container_name: rosterchirp
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- 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}
|
||||
- USER_PASS=${USER_PASS:-user@1234}
|
||||
- ADMPW_RESET=${ADMPW_RESET:-false}
|
||||
- JWT_SECRET=${JWT_SECRET:-changeme}
|
||||
- APP_NAME=${APP_NAME:-jama}
|
||||
- JAMA_VERSION=${JAMA_VERSION:-latest}
|
||||
<<<<<<< HEAD
|
||||
- APP_NAME=${APP_NAME:-RosterChirp}
|
||||
- DEFCHAT_NAME=${DEFCHAT_NAME:-General Chat}
|
||||
- DB_HOST=db
|
||||
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||
- DB_USER=${DB_USER:-rosterchirp}
|
||||
- DB_PASSWORD=${DB_PASSWORD}
|
||||
- ROSTERCHIRP_VERSION=${ROSTERCHIRP_VERSION:-latest}
|
||||
volumes:
|
||||
- jama_db:/app/data
|
||||
- jama_uploads:/app/uploads
|
||||
- rosterchirp_uploads:/app/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: rosterchirp_db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- POSTGRES_DB=${DB_NAME:-rosterchirp}
|
||||
- POSTGRES_USER=${DB_USER:-rosterchirp}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- rosterchirp_db:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-rosterchirp}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
=======
|
||||
- APP_NAME=${APP_NAME:-rosterchirp}
|
||||
- rosterchirp_VERSION=${rosterchirp_VERSION:-latest}
|
||||
volumes:
|
||||
- rosterchirp_db:/app/data
|
||||
- rosterchirp_uploads:/app/uploads
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
caddy:
|
||||
image: caddy:alpine
|
||||
@@ -194,11 +289,11 @@ services:
|
||||
- caddy_data:/data
|
||||
- caddy_certs:/config
|
||||
depends_on:
|
||||
- jama
|
||||
- rosterchirp
|
||||
|
||||
volumes:
|
||||
jama_db:
|
||||
jama_uploads:
|
||||
rosterchirp_db:
|
||||
rosterchirp_uploads:
|
||||
caddy_data:
|
||||
caddy_certs:
|
||||
```
|
||||
@@ -209,24 +304,57 @@ volumes:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `JAMA_VERSION` | `latest` | Docker image tag to run |
|
||||
<<<<<<< HEAD
|
||||
| `APP_TYPE` | `selfhost` | Deployment mode: `selfhost` (single tenant) or `host` (multi-tenant) |
|
||||
| `ROSTERCHIRP_VERSION` | `latest` | Docker image tag to run |
|
||||
=======
|
||||
| `rosterchirp_VERSION` | `latest` | Docker image tag to run |
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
| `TZ` | `UTC` | Container timezone (e.g. `America/Toronto`) |
|
||||
| `ADMIN_NAME` | `Admin User` | Display name of the default admin account |
|
||||
| `ADMIN_EMAIL` | `admin@jama.local` | Login email for the default admin account |
|
||||
| `ADMIN_EMAIL` | `admin@rosterchirp.local` | Login email for the default admin account |
|
||||
| `ADMIN_PASS` | `Admin@1234` | Initial password for the default admin account |
|
||||
| `USER_PASS` | `user@1234` | Default temporary password for bulk-imported users when no password is specified in CSV |
|
||||
| `ADMPW_RESET` | `false` | If `true`, resets the **admin** password to `ADMIN_PASS` on every restart. Emergency access recovery only. Shows a warning banner when active. |
|
||||
| `ADMPW_RESET` | `false` | If `true`, resets the admin password to `ADMIN_PASS` on every restart. Emergency recovery only. |
|
||||
| `JWT_SECRET` | *(insecure default)* | Secret used to sign auth tokens. **Must be changed in production.** |
|
||||
<<<<<<< HEAD
|
||||
| `APP_NAME` | `RosterChirp` | Initial application name (can also be changed in Settings UI) |
|
||||
| `DEFCHAT_NAME` | `General Chat` | Name of the default public channel created on first run |
|
||||
| `DB_HOST` | `db` | PostgreSQL hostname |
|
||||
| `DB_NAME` | `rosterchirp` | PostgreSQL database name |
|
||||
| `DB_USER` | `rosterchirp` | PostgreSQL username |
|
||||
| `DB_PASSWORD` | *(required)* | PostgreSQL password. **Avoid `!` — shell interpolation issue with Docker Compose.** |
|
||||
| `APP_DOMAIN` | — | Base domain for multi-tenant host mode (e.g. `example.com`) |
|
||||
| `HOST_SLUG` | — | Subdomain slug for the host control panel (e.g. `chathost` → `chathost.example.com`) |
|
||||
| `HOST_ADMIN_KEY` | — | Secret key for the host control plane API |
|
||||
=======
|
||||
| `PORT` | `3000` | Host port to bind (without Caddy) |
|
||||
| `APP_NAME` | `jama` | Initial application name (can also be changed in Settings UI) |
|
||||
| `APP_NAME` | `rosterchirp` | Initial application name (can also be changed in Settings UI) |
|
||||
| `DEFCHAT_NAME` | `General Chat` | Name of the default public group created on first run |
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
> `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the **first run**. Once the database exists they are ignored — unless `ADMPW_RESET=true`.
|
||||
### Firebase Push Notification Variables (optional)
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `FIREBASE_API_KEY` | Firebase web app API key |
|
||||
| `FIREBASE_PROJECT_ID` | Firebase project ID |
|
||||
| `FIREBASE_MESSAGING_SENDER_ID` | Firebase messaging sender ID |
|
||||
| `FIREBASE_APP_ID` | Firebase web app ID |
|
||||
| `FIREBASE_VAPID_KEY` | Web Push certificate public key (from Firebase Cloud Messaging tab) |
|
||||
| `FIREBASE_SERVICE_ACCOUNT` | Full service account JSON, stringified (remove all newlines) |
|
||||
|
||||
> `ADMIN_EMAIL` and `ADMIN_PASS` are only used on the **first run**. Once the database is seeded they are ignored — unless `ADMPW_RESET=true`.
|
||||
|
||||
### Example `.env`
|
||||
|
||||
```env
|
||||
JAMA_VERSION=1.0.0
|
||||
<<<<<<< HEAD
|
||||
ROSTERCHIRP_VERSION=0.13.1
|
||||
APP_TYPE=selfhost
|
||||
=======
|
||||
rosterchirp_VERSION=1.0.0
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
TZ=America/Toronto
|
||||
|
||||
ADMIN_NAME=Your Name
|
||||
@@ -238,9 +366,17 @@ ADMPW_RESET=false
|
||||
|
||||
JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars
|
||||
|
||||
<<<<<<< HEAD
|
||||
APP_NAME=RosterChirp
|
||||
=======
|
||||
PORT=3000
|
||||
APP_NAME=jama
|
||||
APP_NAME=rosterchirp
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
DEFCHAT_NAME=General Chat
|
||||
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
DB_PASSWORD=a-strong-db-password
|
||||
```
|
||||
|
||||
---
|
||||
@@ -266,7 +402,7 @@ Accessible from the bottom-left menu (admin only).
|
||||
| Reset password | User is forced to set a new password on next login |
|
||||
| Suspend | Blocks login; messages are preserved |
|
||||
| Activate | Re-enables a suspended account |
|
||||
| Delete | Removes account; messages remain attributed to user |
|
||||
| Delete | Scrubs account data; messages are removed; threads become read-only |
|
||||
| Change role | Promote member → admin or demote admin → member |
|
||||
|
||||
### CSV Import Format
|
||||
@@ -292,6 +428,7 @@ Jane Smith,jane@example.com,,admin
|
||||
| Rename | Admin only | Owner only | ❌ Not allowed |
|
||||
| Read-only mode | ✅ Optional | ❌ N/A | ❌ N/A |
|
||||
| Duplicate prevention | N/A | ✅ Redirects to existing | ✅ Redirects to existing |
|
||||
| Managed (Group Manager) | ❌ | ✅ Optional | ❌ |
|
||||
|
||||
### @Mention Scoping
|
||||
|
||||
@@ -313,13 +450,45 @@ Any user can set a personal display name for any group:
|
||||
|
||||
---
|
||||
|
||||
## Schedule
|
||||
|
||||
The Schedule page (Team plan) provides a full team calendar:
|
||||
|
||||
- **Desktop view** — Monthly grid with event cards per day
|
||||
- **Mobile view** — Scrollable event list with a date picker
|
||||
- **Event types** — Colour-coded categories created by admins
|
||||
- **Recurring events** — Set daily, weekly, or custom recurrence intervals
|
||||
- **Availability** — Members can mark availability per event
|
||||
- **Keyword search** — Unquoted terms match word prefixes; quoted terms match whole words exactly
|
||||
- **Type filter** — Filter by event type across the full current month
|
||||
|
||||
---
|
||||
|
||||
## Push Notifications
|
||||
|
||||
RosterChirp uses **Firebase Cloud Messaging (FCM)** for push notifications. HTTPS is required.
|
||||
|
||||
### Setup
|
||||
|
||||
1. Create a Firebase project at [console.firebase.google.com](https://console.firebase.google.com)
|
||||
2. Add a **Web app** → copy the config values into `.env`
|
||||
3. Go to **Project Settings → Cloud Messaging → Web Push certificates** → generate a key pair → copy the public key as `FIREBASE_VAPID_KEY`
|
||||
4. Go to **Project Settings → Service accounts → Generate new private key** → download the JSON → stringify it (remove all newlines) → set as `FIREBASE_SERVICE_ACCOUNT`
|
||||
|
||||
Push notifications are sent for:
|
||||
- New messages in private groups (to all members except the sender)
|
||||
- New messages in public channels (to all subscribers except the sender)
|
||||
- Image messages show as `📷 Image`
|
||||
|
||||
---
|
||||
|
||||
## Help Content
|
||||
|
||||
The Getting Started guide is sourced from `data/help.md`. Edit before running `build.sh` — it is baked into the image at build time.
|
||||
|
||||
```bash
|
||||
nano data/help.md
|
||||
./build.sh 1.0.0
|
||||
./build.sh 0.13.1
|
||||
```
|
||||
|
||||
Users can access the guide at any time via **User menu → Help**.
|
||||
@@ -330,37 +499,59 @@ Users can access the guide at any time via **User menu → Help**.
|
||||
|
||||
| Volume | Container path | Contents |
|
||||
|---|---|---|
|
||||
| `jama_db` | `/app/data` | SQLite database (`jama.db`), `help.md` |
|
||||
| `jama_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images |
|
||||
<<<<<<< HEAD
|
||||
| `rosterchirp_db` | `/var/lib/postgresql/data` | PostgreSQL data directory |
|
||||
=======
|
||||
| `rosterchirp_db` | `/app/data` | SQLite database (`rosterchirp.db`), `help.md` |
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
| `rosterchirp_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images |
|
||||
|
||||
### Backup
|
||||
|
||||
```bash
|
||||
# Backup database
|
||||
<<<<<<< HEAD
|
||||
docker compose exec db pg_dump -U rosterchirp rosterchirp | gzip > rosterchirp_db_$(date +%Y%m%d).sql.gz
|
||||
|
||||
# Restore database
|
||||
gunzip -c rosterchirp_db_20240101.sql.gz | docker compose exec -T db psql -U rosterchirp rosterchirp
|
||||
=======
|
||||
docker run --rm \
|
||||
-v jama_db:/data \
|
||||
-v rosterchirp_db:/data \
|
||||
-v $(pwd):/backup alpine \
|
||||
tar czf /backup/jama_db_$(date +%Y%m%d).tar.gz -C /data .
|
||||
tar czf /backup/rosterchirp_db_$(date +%Y%m%d).tar.gz -C /data .
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
|
||||
# Backup uploads
|
||||
docker run --rm \
|
||||
-v jama_uploads:/data \
|
||||
-v rosterchirp_uploads:/data \
|
||||
-v $(pwd):/backup alpine \
|
||||
tar czf /backup/jama_uploads_$(date +%Y%m%d).tar.gz -C /data .
|
||||
tar czf /backup/rosterchirp_uploads_$(date +%Y%m%d).tar.gz -C /data .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Upgrades & Rollbacks
|
||||
|
||||
Database migrations run automatically on startup. There is no manual migration step.
|
||||
|
||||
```bash
|
||||
# Upgrade
|
||||
./build.sh 1.1.0
|
||||
# Set JAMA_VERSION=1.1.0 in .env
|
||||
<<<<<<< HEAD
|
||||
./build.sh 0.13.1
|
||||
# Set ROSTERCHIRP_VERSION=0.13.1 in .env
|
||||
docker compose up -d
|
||||
|
||||
# Rollback
|
||||
# Set JAMA_VERSION=1.0.0 in .env
|
||||
# Set ROSTERCHIRP_VERSION=0.12.x in .env
|
||||
=======
|
||||
./build.sh 1.1.0
|
||||
# Set rosterchirp_VERSION=1.1.0 in .env
|
||||
docker compose up -d
|
||||
|
||||
# Rollback
|
||||
# Set rosterchirp_VERSION=1.0.0 in .env
|
||||
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -372,7 +563,7 @@ Data volumes are untouched in both cases.
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `icon-192.png` / `icon-512.png` | Standard icons — PC PWA shortcuts (`purpose: any`) |
|
||||
| `icon-192.png` / `icon-512.png` | Standard icons — desktop PWA shortcuts (`purpose: any`) |
|
||||
| `icon-192-maskable.png` / `icon-512-maskable.png` | Adaptive icons — Android home screen (`purpose: maskable`); logo at 75% scale on solid background |
|
||||
|
||||
---
|
||||
@@ -403,10 +594,10 @@ cd backend && npm install && npm run dev
|
||||
cd frontend && npm install && npm run dev
|
||||
```
|
||||
|
||||
The Vite dev server proxies all `/api` and `/socket.io` requests to the backend automatically.
|
||||
The Vite dev server proxies all `/api` and `/socket.io` requests to the backend automatically. You will need a running PostgreSQL instance and a `.env` file in the project root.
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
Proprietary — all rights reserved.
|
||||
|
||||
311
Reference/FCM_IMPLEMENTATION_NOTES.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# FCM PWA Implementation Notes
|
||||
_Reference for applying FCM fixes to other projects_
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Guide Key Points (fcm_details.txt)
|
||||
|
||||
### How FCM works (the correct flow)
|
||||
1. User grants notification permission
|
||||
2. Firebase generates a unique FCM token for the device
|
||||
3. Token is stored on your server for targeting
|
||||
4. Server sends push requests to Firebase
|
||||
5. Firebase delivers notifications to the device
|
||||
6. Service worker handles display and click interactions
|
||||
|
||||
### Common vibe-coding failures with FCM
|
||||
|
||||
**1. Service worker confusion**
|
||||
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
|
||||
|
||||
**2. Deprecated API usage**
|
||||
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
|
||||
```javascript
|
||||
const token = await messaging.getToken({
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
```
|
||||
|
||||
**3. Token generation without durable storage**
|
||||
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
|
||||
|
||||
**4. Poor permission flow**
|
||||
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
|
||||
|
||||
**5. Missing notificationclick handler**
|
||||
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
|
||||
|
||||
**6. Silent failures**
|
||||
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
|
||||
|
||||
**7. iOS blind spots**
|
||||
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
|
||||
|
||||
### Correct `getToken()` pattern (from guide)
|
||||
```javascript
|
||||
// Register SW first, then pass it directly to getToken
|
||||
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
|
||||
```
|
||||
|
||||
### Correct `firebase-messaging-sw.js` pattern (from guide)
|
||||
```javascript
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({ /* config */ });
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
self.registration.showNotification(payload.notification.title, {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-notification',
|
||||
data: payload.data
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action === 'close') return;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) return client.focus();
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Code Fixes Applied to fcm-app
|
||||
|
||||
### app.js fixes
|
||||
|
||||
**Fix: `showUserInfo()` missing**
|
||||
Function was called on login and session restore but never defined — crashed immediately on login.
|
||||
```javascript
|
||||
function showUserInfo() {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('userInfo').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setupApp()` wrong element IDs**
|
||||
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
|
||||
```javascript
|
||||
// Wrong
|
||||
document.getElementById('sendNotification').addEventListener('click', sendNotification);
|
||||
// Fixed
|
||||
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||
// Also added id="logoutBtn" to the logout button in index.html
|
||||
```
|
||||
|
||||
**Fix: `logout()` not clearing localStorage**
|
||||
Session was restored on next page load even after logout.
|
||||
```javascript
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
fcmToken = null;
|
||||
localStorage.removeItem('currentUser'); // was missing
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Race condition in messaging initialization**
|
||||
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
|
||||
```javascript
|
||||
let initPromise = null;
|
||||
function initializeFirebase() {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
swRegistration = registration;
|
||||
messaging = firebase.messaging();
|
||||
})
|
||||
.catch((error) => { initPromise = null; throw error; });
|
||||
return initPromise;
|
||||
}
|
||||
// In login():
|
||||
await initializeFirebase(); // ensures messaging is ready before getToken()
|
||||
```
|
||||
|
||||
**Fix: `deleteToken()` invalidating tokens on every page load**
|
||||
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
|
||||
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
|
||||
|
||||
**Fix: Session restore without re-registering token**
|
||||
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
|
||||
```javascript
|
||||
// In setupApp(), after restoring session:
|
||||
if (Notification.permission === 'granted') {
|
||||
initializeFirebase()
|
||||
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Deprecated VAPID/SW API replaced**
|
||||
```javascript
|
||||
// Removed (deprecated):
|
||||
messaging.usePublicVapidKey(VAPID_KEY);
|
||||
messaging.useServiceWorker(registration);
|
||||
const token = await messaging.getToken();
|
||||
|
||||
// Replaced with:
|
||||
const VAPID_KEY = 'your-vapid-key';
|
||||
let swRegistration = null;
|
||||
// swRegistration set inside initializeFirebase() .then()
|
||||
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||
```
|
||||
|
||||
**Fix: Null token guard**
|
||||
`getToken()` can return null — passing null to the server produced a confusing 400 error.
|
||||
```javascript
|
||||
if (!token) {
|
||||
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Error message included server response**
|
||||
```javascript
|
||||
// Before: throw new Error('Failed to register token');
|
||||
// After:
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
```
|
||||
|
||||
**Fix: Duplicate foreground message handlers**
|
||||
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
|
||||
```javascript
|
||||
let foregroundHandlerSetup = false;
|
||||
function handleForegroundMessages() {
|
||||
if (foregroundHandlerSetup) return;
|
||||
foregroundHandlerSetup = true;
|
||||
messaging.onMessage(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `login()` event.preventDefault() crash**
|
||||
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
|
||||
```javascript
|
||||
async function login(event) {
|
||||
if (event) event.preventDefault(); // guard added
|
||||
```
|
||||
|
||||
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
|
||||
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
|
||||
|
||||
**Fix: `notificationclick` handler added to `sw.js`**
|
||||
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
|
||||
|
||||
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
|
||||
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
|
||||
```javascript
|
||||
// Removed from urlsToCache:
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
|
||||
```
|
||||
|
||||
### server.js fixes
|
||||
|
||||
**Fix: `icon`/`badge`/`tag` in wrong notification object**
|
||||
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
|
||||
```javascript
|
||||
// Wrong:
|
||||
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
|
||||
// Fixed:
|
||||
notification: { title, body },
|
||||
webpush: {
|
||||
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `saveTokens()` in route handler not crash-safe**
|
||||
```javascript
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (saveError) {
|
||||
console.error('Failed to persist tokens to disk:', saveError);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
|
||||
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
|
||||
```javascript
|
||||
setInterval(() => {
|
||||
try { saveTokens(); }
|
||||
catch (error) { console.error('Auto-save tokens failed:', error); }
|
||||
}, 30000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Docker / Infrastructure Fixes
|
||||
|
||||
### Root cause of "no other users" bug
|
||||
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
|
||||
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
|
||||
2. This propagated out of `setInterval` as an uncaught exception
|
||||
3. Node.js exited the process
|
||||
4. Docker restarted the container with empty state
|
||||
5. Tokens were never on disk, so restart = all tokens lost
|
||||
|
||||
### Dockerfile fix
|
||||
```dockerfile
|
||||
# Create non-root user AND a writable data directory (while still root)
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
mkdir -p /app/data && \
|
||||
chown nodejs:nodejs /app/data
|
||||
```
|
||||
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
|
||||
|
||||
### docker-compose.yml fix
|
||||
```yaml
|
||||
services:
|
||||
your-app:
|
||||
volumes:
|
||||
- app_data:/app/data # named volume survives container rebuilds
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
```
|
||||
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
|
||||
|
||||
### server.js path fix
|
||||
```javascript
|
||||
// Changed from:
|
||||
const TOKENS_FILE = './tokens.json';
|
||||
// To:
|
||||
const TOKENS_FILE = './data/tokens.json';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for applying to another project
|
||||
|
||||
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
|
||||
- [ ] `notificationclick` handler present in service worker
|
||||
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
|
||||
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
|
||||
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
|
||||
- [ ] `deleteToken()` is NOT called on page load
|
||||
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
|
||||
- [ ] Null/empty token check before sending to server
|
||||
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
|
||||
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
|
||||
- [ ] Docker: data directory created with correct user ownership in Dockerfile
|
||||
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
|
||||
- [ ] Duplicate foreground message handler registration is guarded
|
||||
1013
Reference/fcm_details.txt
Normal file
63
Reference/minor-age-protection.txt
Normal file
@@ -0,0 +1,63 @@
|
||||
RULE: minor age is when DOB is 15 years and under.
|
||||
|
||||
User manager:
|
||||
Enable "Date of Birth" field (default optional unless "Mixed Age" is selected in the (new) as "Login Type" on Settings modal page)
|
||||
Enable "Guardian" field as optional
|
||||
|
||||
My Profile modal:
|
||||
Change: replace tab buttons with a select list labled "SELECT OPTION:" Profile - default selected (on both desktop and mobile)
|
||||
On the "Profile" tab, add two new text inputs: Date of Birth and Phone (same format field verification as used in the user manager field) - once saved, user manager is updated with data from these two fields for the given user
|
||||
Add a new select option "Add Child" (only displayed IF the new "Login Type" setting has either "Guardians Only" or "Mixed Age" selected.
|
||||
|
||||
"Add Child" Form:
|
||||
- (on user's my profile): Firstname, Lastname, Email, DOB, Profile avatar (follow avatar upload rules), number (input box), Add button, Save button (disabled until there is a child added to guardian's child list)
|
||||
- Clicking the Add button will add child to the guardians child list.)
|
||||
- Clicking save, will save the the guardian's child list and will each child entry to players user group.
|
||||
|
||||
Settings modal:
|
||||
Change: replace tab tabs buttons to a select list, labelled "SELECT OPTION" Messages is still the default option (on both desktop and mobile)
|
||||
Add new selection "Login Type" to the list, form details below:
|
||||
|
||||
An option list with brief description under:
|
||||
Option: "Guardian Only"
|
||||
Descriptions: "Parents are required to add their child's details in their profile. They will respond on behalf of the child for events with availability tracking for the "players" group".
|
||||
|
||||
- User manager DOB is optional
|
||||
- "Players" user group entry will be the child's name as an alias to the guardians account, if more than one child each entry will be treated uniquely for mentions and event availabillty responses.
|
||||
- "Players" User Group DM will be disabled/hidden and cannot be added to Multi-Group DMs
|
||||
- The event modal with Availabilty requested for the "players" user group, will have a new drop down select list (under the response buttons, above the note input box - hidden/disabled by default) and will only be unhidden/enable if the event includes the "players" user group, and only displayed for user who have a saved child list of at least one. The select list options will include the guardians child list, AND will also the guardian's name IF multiple user groups are selected for the event, with one be players, and at least one being a user group that the guardian is a member of that is not the players group. There will be a default option of "ALL", if selected, the response and note (if entered) will be the same for all "people" listed in the select list, but responses will be listed indivually in the response list for the event.
|
||||
|
||||
Scenario 1: Event: party, track availability enabled, groups selected: players + parents
|
||||
- select drop down will display option: "All" default selected, then guadians name on row 2 (parent user group), each child's name on susequent rows (listed in the players user group owned by the guardian)
|
||||
- selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each
|
||||
- selecting an idividual name will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list
|
||||
Scenario 2: Event, track availability enabled, groups selected: players
|
||||
- select drop down will display "All" on the top row, each child's name (listed in the players user group and owned by the guardian)
|
||||
- selecting "All" will add each "person" in the select drop down list individually to the reponse list, but with the same availability and note (if entered) for each
|
||||
- selecting an idividual name in the selectlist will add response for that indivual only (so they can each have different responses and notes) - repeats per indivual in the select list
|
||||
Scenario 3: Event, track availability enabled, groups selected: parents
|
||||
- select drop down is hidden
|
||||
- availability response and note is for the guardian only
|
||||
|
||||
Option: "Mixed Age"
|
||||
Descriptions: "Parents, or user managers, are required to add the minor aged child's user account to the guardians user profile. Minor aged users cannot login until this is complete."
|
||||
- User manager DOB is required for all users
|
||||
- Minor aged user's account are automatically suspended
|
||||
on the add child form:
|
||||
- search user input field, will display only a list of minor aged users. Selecting a user fills out the "Add Child" read-only form
|
||||
- "Add Child" Form all fields are read-only: Firstname, Lastname, DOB*, user's avatar, number (input box), Add button when form is filled by selecting a user, Save button (disabled until there is a child added to guardian's child list). Requires admin approval. A message is sent to all users with the managers from from the default admin user account indicating "User Manager requires approval".
|
||||
- The minor user account name that requires gaurdina name approval is bold red in the user manager list. When the "editing" user account, the Gaurdian field will be highlighted in red. Besides the label will be two link options [approve - green] [deny - red] (same size font as the label). Approve will clear the approval required flag and unsuspend the user account, with the guardian name save to the user's profile.
|
||||
- Saving the form will update the minor aged user account with a guardian name, requires a user with manager role approval, once approved, the minor's user account is unsuspended.
|
||||
- Events are handled like they are currently
|
||||
- Guardians are not part of players user group DMs
|
||||
- Guardians do not respond to availability on the childs behalf (new select list on the event form remains hidden/diabled)
|
||||
- any private messages initiated by a user 18 years or older to a minor aged will automatically include the guardian user account as well.
|
||||
-> a modal confirmation will be provide to the 18+ user that they are messaging a user that will also include their parent/guardian.
|
||||
|
||||
Option: All Ages (default selection)
|
||||
- "Add Child" hidden/disbled Profile select option list.
|
||||
- Aliase select list is hidden/disabled
|
||||
- Events are handled like they are currently
|
||||
|
||||
Yellow warning symbol and text below: "This setting can only be set/changed when the user table in the database is empty." (form is read-only unless user manager is empty OR only has users with the admin role).
|
||||
- if the setting is changed and users exisit with admin role, on each subsequent login the user's profile modal popup until the Date of Birth is entered.
|
||||
77
Reference/sw.js.gemini.txt
Normal file
@@ -0,0 +1,77 @@
|
||||
Gemini said
|
||||
I’ve reviewed your Service Worker code. It’s well-structured, but there is a critical architectural "race condition" occurring here that is likely causing the "hit or miss" behavior on mobile.
|
||||
|
||||
On a desktop, the browser keeps the Service Worker "warm" or gives it plenty of time to boot. On mobile (especially Android), the OS is much more aggressive.
|
||||
|
||||
1. The onBackgroundMessage vs. event.waitUntil Problem
|
||||
In your Firebase Messaging block:
|
||||
|
||||
JavaScript
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
// ... logic ...
|
||||
return showRosterChirpNotification({...});
|
||||
});
|
||||
The Flaw: The Firebase onBackgroundMessage handler is a callback, but it is not being awaited by the Service Worker's underlying push event.
|
||||
|
||||
When a push arrives, the browser fires a native push event. The Firebase SDK intercepts this and triggers your onBackgroundMessage. However, because you aren't explicitly telling the browser to "stay awake" while Firebase does its thing, the mobile OS often kills the Service Worker thread before the Firebase SDK even finishes parsing the message or before showRosterChirpNotification completes.
|
||||
|
||||
2. The "Double Listener" Conflict
|
||||
You have both messaging.onBackgroundMessage and a manual self.addEventListener('push').
|
||||
|
||||
You added a check if (messaging) return; inside the push listener to prevent double-firing.
|
||||
|
||||
The Catch: In 2026, the firebase-messaging-compat SDK actually needs that native push event to stay alive. By returning early, you might be accidentally telling the browser "I'm done here," allowing it to terminate the process before the Firebase logic finishes.
|
||||
|
||||
The Fix: "Promisifying" the Background Handler
|
||||
To fix the reliability on mobile, you need to ensure the native push event wraps the Firebase logic in a Promise.
|
||||
|
||||
Modify your code to look like this:
|
||||
|
||||
JavaScript
|
||||
// 1. Remove the return showRosterChirpNotification from onBackgroundMessage
|
||||
// 2. Wrap the logic in the native 'push' event to guarantee execution.
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push received');
|
||||
|
||||
// We use event.waitUntil to tell the Mobile OS: "Don't kill me yet!"
|
||||
event.waitUntil(
|
||||
// If Firebase is ready, we let it process, but we must make sure
|
||||
// we return a promise that resolves only AFTER the notification shows.
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
// You can still use Firebase logic, but ensure it's awaited.
|
||||
// Or, for maximum mobile reliability, parse the event.data directly:
|
||||
const payload = event.data ? event.data.json() : null;
|
||||
|
||||
if (payload) {
|
||||
// Manually handle the notification display here to ensure
|
||||
// the 'waitUntil' scope covers it.
|
||||
return showRosterChirpNotification({
|
||||
title: payload.notification?.title || payload.data?.title || 'New Message',
|
||||
body: payload.notification?.body || payload.data?.body || '',
|
||||
url: payload.data?.url || '/',
|
||||
groupId: payload.data?.groupId || ''
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[SW] Push processing failed', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
3. Why Desktop works but Mobile fails
|
||||
Desktop: The browser process is persistent. It waits for the Firebase callback to finish even if you don't use event.waitUntil.
|
||||
|
||||
Mobile: The moment the push event function finishes executing (which happens instantly if it's just a bunch of if statements and callbacks), the OS is allowed to freeze the thread.
|
||||
|
||||
4. HAProxy / PFSense Check (The "Vary" Header)
|
||||
Since you are using importScripts for Firebase:
|
||||
If HAProxy is caching your sw.js but serving it with a content-type or encoding that makes the mobile browser hesitate, it will fail to update. Ensure HAProxy isn't stripping the Service-Worker-Allowed header if you've set one, and make sure your server sends Cache-Control: no-cache for the sw.js file specifically.
|
||||
|
||||
Summary of adjustments:
|
||||
Remove the if (messaging) return; from your push listener.
|
||||
|
||||
Consolidate your logic inside the push listener using event.waitUntil().
|
||||
|
||||
Check Firebase Console: Ensure the messages you send from your server are marked as High Priority (priority: 'high'), otherwise mobile devices will "batch" them and they'll arrive 10-20 minutes late.
|
||||
880
Reference/vibecode-prompt.md
Normal file
@@ -0,0 +1,880 @@
|
||||
# RosterChirp — Complete Vibe-Code Build Prompt (v0.13.1)
|
||||
|
||||
> **How to use this document**
|
||||
> Paste the contents of any single section (or the whole document) as your opening prompt when starting a new AI coding session. The more context you give upfront, the fewer clarifying rounds you need. This document reflects the real production build of RosterChirp as of v0.12.53.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — What to Build (Product Brief)
|
||||
|
||||
Build a **self-hosted team chat Progressive Web App** called **RosterChirp**.
|
||||
|
||||
It is a full-stack, single-container application that runs entirely inside Docker. Users install it on a private server and access it via a browser or as an installed PWA on desktop/mobile. It supports two deployment modes:
|
||||
|
||||
| Mode | Description |
|
||||
|---|---|
|
||||
| `selfhost` | Single tenant — one schema `public`. Default if APP_TYPE unset. |
|
||||
| `host` | Multi-tenant — one Postgres schema per tenant, provisioned at `{slug}.{HOST_DOMAIN}`. |
|
||||
|
||||
### Core philosophy
|
||||
- Simple to self-host (one `docker compose up`)
|
||||
- Works as an installed PWA on Android, iOS, and desktop Chrome/Edge
|
||||
- Instant real-time messaging via WebSockets (Socket.io)
|
||||
- Push notifications via Firebase Cloud Messaging (FCM), works when app is backgrounded
|
||||
- Multi-tenant via Postgres schema isolation (not separate databases)
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Tech Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Backend runtime | Node.js 20 (Alpine) |
|
||||
| Backend framework | Express.js |
|
||||
| Real-time | Socket.io (server + client) |
|
||||
| Database | PostgreSQL 16 via `pg` npm package |
|
||||
| Auth | JWT in HTTP-only cookies + localStorage, `jsonwebtoken`, `bcryptjs` |
|
||||
| Push notifications | Firebase Cloud Messaging (FCM) — Firebase Admin SDK (backend) + Firebase JS SDK (frontend) |
|
||||
| Image processing | `sharp` (avatar/logo resizing) |
|
||||
| Frontend framework | React 18 + Vite (PWA) |
|
||||
| Frontend styling | Plain CSS with CSS custom properties (no Tailwind, no CSS modules) |
|
||||
| Emoji picker | `@emoji-mart/react` + `@emoji-mart/data` |
|
||||
| Markdown rendering | `marked` (for help modal) |
|
||||
| Container | Docker multi-stage build (builder stage for Vite, runtime stage for Node) |
|
||||
| Orchestration | `docker-compose.yaml` (selfhost) + `docker-compose.host.yaml` (multi-tenant) |
|
||||
| Reverse proxy | Caddy (SSL termination) |
|
||||
|
||||
### Key npm packages (backend)
|
||||
```
|
||||
express, socket.io, pg, bcryptjs, jsonwebtoken,
|
||||
cookie-parser, cors, multer, sharp,
|
||||
firebase-admin, node-fetch
|
||||
```
|
||||
|
||||
### Key npm packages (frontend)
|
||||
```
|
||||
react, react-dom, vite, socket.io-client,
|
||||
@emoji-mart/react, @emoji-mart/data, marked,
|
||||
firebase
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Project File Structure
|
||||
|
||||
```
|
||||
rosterchirp/
|
||||
├── CLAUDE.md
|
||||
├── Dockerfile
|
||||
├── build.sh # VERSION="${1:-X.Y.Z}" — bump here + both package.json files
|
||||
├── docker-compose.yaml # selfhost
|
||||
├── docker-compose.host.yaml # multi-tenant host mode
|
||||
├── Caddyfile.example
|
||||
├── .env.example
|
||||
├── about.json.example
|
||||
├── backend/
|
||||
│ ├── package.json # version bump required
|
||||
│ └── src/
|
||||
│ ├── index.js # Express app, Socket.io, tenant middleware wiring
|
||||
│ ├── middleware/
|
||||
│ │ └── auth.js # JWT auth, teamManagerMiddleware
|
||||
│ ├── models/
|
||||
│ │ ├── db.js # Postgres pool, query helpers, migrations, seeding
|
||||
│ │ └── migrations/ # 001–008 SQL files, auto-applied on startup
|
||||
│ └── routes/
|
||||
│ ├── auth.js # receives io
|
||||
│ ├── groups.js # receives io
|
||||
│ ├── messages.js # receives io
|
||||
│ ├── usergroups.js # receives io
|
||||
│ ├── schedule.js # receives io
|
||||
│ ├── users.js
|
||||
│ ├── settings.js
|
||||
│ ├── push.js
|
||||
│ ├── host.js # RosterChirp-Host control plane only
|
||||
│ ├── about.js
|
||||
│ └── help.js
|
||||
└── frontend/
|
||||
├── package.json # version bump required
|
||||
├── vite.config.js
|
||||
├── index.html # viewport: user-scalable=no (pinch handled via JS)
|
||||
├── public/
|
||||
│ ├── manifest.json
|
||||
│ ├── sw.js # service worker / FCM push
|
||||
│ └── icons/
|
||||
└── src/
|
||||
├── App.jsx
|
||||
├── main.jsx # pinch→font-scale handler, pull-to-refresh blocker, iOS keyboard fix
|
||||
├── index.css # CSS vars, dark mode, --font-scale, mobile autofill fixes
|
||||
├── contexts/
|
||||
│ ├── AuthContext.jsx
|
||||
│ ├── SocketContext.jsx # force transports: ['websocket']
|
||||
│ └── ToastContext.jsx
|
||||
├── pages/
|
||||
│ ├── Chat.jsx # main shell, page routing, all socket wiring
|
||||
│ ├── Login.jsx
|
||||
│ ├── ChangePassword.jsx
|
||||
│ ├── UserManagerPage.jsx
|
||||
│ └── GroupManagerPage.jsx
|
||||
└── components/
|
||||
├── Sidebar.jsx # groupMessagesMode prop
|
||||
├── ChatWindow.jsx
|
||||
├── MessageInput.jsx # onTextChange prop, fixed font size (no --font-scale)
|
||||
├── Message.jsx # fonts scaled via --font-scale
|
||||
├── NavDrawer.jsx
|
||||
├── SchedulePage.jsx # ~1600 lines, desktop+mobile views
|
||||
├── MobileEventForm.jsx
|
||||
├── Avatar.jsx # consistent colour algorithm — must match Sidebar + ChatWindow
|
||||
├── PasswordInput.jsx
|
||||
├── GroupInfoModal.jsx
|
||||
├── ProfileModal.jsx # appearance tab: font-scale slider (saved), pinch is session-only
|
||||
├── SettingsModal.jsx
|
||||
├── BrandingModal.jsx
|
||||
├── HostPanel.jsx
|
||||
├── NewChatModal.jsx
|
||||
├── UserFooter.jsx
|
||||
├── GlobalBar.jsx
|
||||
├── ImageLightbox.jsx
|
||||
├── UserProfilePopup.jsx
|
||||
├── AddChildAliasModal.jsx
|
||||
├── ScheduleManagerModal.jsx
|
||||
├── ColourPickerSheet.jsx
|
||||
└── SupportModal.jsx
|
||||
```
|
||||
|
||||
### Dead code (safe to delete)
|
||||
- `frontend/src/pages/HostAdmin.jsx`
|
||||
- `frontend/src/components/UserManagerModal.jsx`
|
||||
- `frontend/src/components/GroupManagerModal.jsx`
|
||||
- `frontend/src/components/MobileGroupManager.jsx`
|
||||
|
||||
---
|
||||
|
||||
## Part 4 — Database Architecture
|
||||
|
||||
### Connection pool (`db.js`)
|
||||
```javascript
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'db',
|
||||
port: parseInt(process.env.DB_PORT || '5432'),
|
||||
database: process.env.DB_NAME || 'rosterchirp',
|
||||
user: process.env.DB_USER || 'rosterchirp',
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
max: 20,
|
||||
idleTimeoutMillis: 30000,
|
||||
connectionTimeoutMillis: 5000,
|
||||
});
|
||||
```
|
||||
|
||||
### Query helpers
|
||||
```javascript
|
||||
query(schema, sql, params) // SET search_path then SELECT
|
||||
queryOne(schema, sql, params) // returns first row or null
|
||||
queryResult(schema, sql, params) // returns full result object
|
||||
exec(schema, sql, params) // INSERT/UPDATE/DELETE
|
||||
withTransaction(schema, async (client) => { ... })
|
||||
```
|
||||
|
||||
`SET search_path TO {schema}` is called before every query. `assertSafeSchema(name)` validates all schema names against `/^[a-z_][a-z0-9_]*$/`.
|
||||
|
||||
### Migrations
|
||||
Auto-run on startup via `runMigrations(schema)`. Files in `migrations/` are applied in order, tracked in `schema_migrations` table per schema. **Never edit an applied migration — add a new numbered file.**
|
||||
|
||||
Current migrations: 001 (initial schema) → 002 (triggers/indexes) → 003 (tenants) → 004 (host plan) → 005 (U2U restrictions) → 006 (scrub deleted users) → 007 (FCM push) → 008 (rebrand)
|
||||
|
||||
### Seeding order
|
||||
`seedSettings → seedEventTypes → seedAdmin → seedUserGroups`
|
||||
|
||||
All seed functions use `ON CONFLICT DO NOTHING`.
|
||||
|
||||
### Core tables (PostgreSQL — schema-qualified at query time)
|
||||
|
||||
```sql
|
||||
users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member', -- 'admin' | 'member'
|
||||
status TEXT NOT NULL DEFAULT 'active', -- 'active' | 'suspended'
|
||||
is_default_admin BOOLEAN DEFAULT FALSE,
|
||||
must_change_password BOOLEAN DEFAULT TRUE,
|
||||
avatar TEXT,
|
||||
about_me TEXT,
|
||||
display_name TEXT,
|
||||
hide_admin_tag BOOLEAN DEFAULT FALSE,
|
||||
allow_dm INTEGER DEFAULT 1,
|
||||
last_online TIMESTAMPTZ,
|
||||
help_dismissed BOOLEAN DEFAULT FALSE,
|
||||
date_of_birth DATE,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT DEFAULT 'public',
|
||||
owner_id INTEGER REFERENCES users(id),
|
||||
is_default BOOLEAN DEFAULT FALSE,
|
||||
is_readonly BOOLEAN DEFAULT FALSE,
|
||||
is_direct BOOLEAN DEFAULT FALSE,
|
||||
is_managed BOOLEAN DEFAULT FALSE, -- managed private groups (Group Messages mode)
|
||||
direct_peer1_id INTEGER,
|
||||
direct_peer2_id INTEGER,
|
||||
track_availability BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
group_members (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(group_id, user_id)
|
||||
)
|
||||
|
||||
messages (
|
||||
id SERIAL PRIMARY KEY,
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
content TEXT,
|
||||
type TEXT DEFAULT 'text', -- 'text' | 'system'
|
||||
image_url TEXT,
|
||||
reply_to_id INTEGER REFERENCES messages(id),
|
||||
is_deleted BOOLEAN DEFAULT FALSE,
|
||||
is_readonly BOOLEAN DEFAULT FALSE,
|
||||
link_preview JSONB,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
reactions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
emoji TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(message_id, user_id, emoji)
|
||||
)
|
||||
|
||||
notifications (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
type TEXT NOT NULL,
|
||||
message_id INTEGER,
|
||||
group_id INTEGER,
|
||||
from_user_id INTEGER,
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
active_sessions (
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device TEXT NOT NULL DEFAULT 'desktop', -- 'mobile' | 'desktop'
|
||||
token TEXT NOT NULL,
|
||||
ua TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (user_id, device)
|
||||
)
|
||||
-- One session per device type per user. New login on same device displaces old session.
|
||||
-- Displaced socket receives 'session:displaced' event.
|
||||
|
||||
push_subscriptions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
device TEXT DEFAULT 'desktop',
|
||||
fcm_token TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(user_id, device)
|
||||
)
|
||||
|
||||
settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
-- Feature flag keys: feature_branding ('true'/'false'), feature_group_manager,
|
||||
-- feature_schedule_manager, app_type ('RosterChirp-Chat'/'RosterChirp-Brand'/'RosterChirp-Team')
|
||||
|
||||
user_group_names (
|
||||
user_id INTEGER NOT NULL,
|
||||
group_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, group_id)
|
||||
)
|
||||
|
||||
user_groups (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
colour TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
user_group_members (
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (user_group_id, user_id)
|
||||
)
|
||||
|
||||
group_user_groups (
|
||||
group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
PRIMARY KEY (group_id, user_group_id)
|
||||
)
|
||||
|
||||
events (
|
||||
id SERIAL PRIMARY KEY,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
location TEXT,
|
||||
start_at TIMESTAMPTZ NOT NULL,
|
||||
end_at TIMESTAMPTZ NOT NULL,
|
||||
all_day BOOLEAN DEFAULT FALSE,
|
||||
is_public BOOLEAN DEFAULT TRUE,
|
||||
created_by INTEGER REFERENCES users(id),
|
||||
event_type_id INTEGER REFERENCES event_types(id),
|
||||
recurrence_rule JSONB,
|
||||
track_availability BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
event_types (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
colour TEXT NOT NULL DEFAULT '#1a73e8',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
|
||||
event_availability (
|
||||
id SERIAL PRIMARY KEY,
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL, -- 'going' | 'maybe' | 'not_going'
|
||||
note TEXT,
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
UNIQUE(event_id, user_id)
|
||||
)
|
||||
|
||||
tenants (
|
||||
id SERIAL PRIMARY KEY,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
schema_name TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
custom_domain TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
plan TEXT DEFAULT 'team',
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 5 — Multi-Tenant Architecture (Host Mode)
|
||||
|
||||
### Tenant resolution
|
||||
`tenantMiddleware` in `index.js` sets `req.schema` from the HTTP `Host` header before any route runs:
|
||||
|
||||
```javascript
|
||||
// Subdomain tenants: {slug}.{HOST_DOMAIN} → schema 'tenant_{slug}'
|
||||
// Custom domains: looked up in tenants table custom_domain column
|
||||
// Host admin: HOST_DOMAIN itself → schema 'public'
|
||||
const tenantDomainCache = new Map(); // in-process cache, cleared on tenant update
|
||||
```
|
||||
|
||||
### Socket room naming (tenant-isolated)
|
||||
All socket rooms are prefixed with the tenant schema:
|
||||
```javascript
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
// e.g. R('tenant_acme', 'group', 42) → 'tenant_acme:group:42'
|
||||
// Room types: group:{id}, user:{id}, schema:all
|
||||
```
|
||||
|
||||
### Online user tracking
|
||||
```javascript
|
||||
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
||||
// Key includes schema to prevent cross-tenant ID collisions
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 6 — Auth & Session System
|
||||
|
||||
- JWT stored in **HTTP-only cookie** (`token`) AND `localStorage` (for PWA/mobile fallback)
|
||||
- `authMiddleware` in `middleware/auth.js` — verifies JWT, attaches `req.user`
|
||||
- `teamManagerMiddleware` — checks if user is a team manager (role-based feature access)
|
||||
- **Per-device sessions**: `active_sessions` PK is `(user_id, device)` — logging in on mobile doesn't kick out desktop
|
||||
- Device: `mobile` if `/Mobile|Android|iPhone/i.test(ua)`, else `desktop`
|
||||
- `must_change_password = true` redirects to `/change-password` after login
|
||||
- `ADMPW_RESET=true` env var resets default admin password on container start
|
||||
|
||||
---
|
||||
|
||||
## Part 7 — Real-time Architecture (Socket.io)
|
||||
|
||||
### Connection
|
||||
- Socket auth: JWT in `socket.handshake.auth.token`
|
||||
- On connect: user joins `R(schema, 'group', id)` for all their groups, and `R(schema, 'user', userId)` for direct notifications, and `R(schema, 'schema', 'all')` for tenant-wide broadcasts
|
||||
|
||||
### Routes that receive `io`
|
||||
```javascript
|
||||
// All of these are called as: require('./routes/foo')(io)
|
||||
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||||
```
|
||||
|
||||
### Key socket events (server → client)
|
||||
| Event | When |
|
||||
|---|---|
|
||||
| `message:new` | new message in a group |
|
||||
| `message:deleted` | soft delete |
|
||||
| `reaction:updated` | reaction toggled |
|
||||
| `typing:start` / `typing:stop` | typing indicator |
|
||||
| `notification:new` | mention or private message |
|
||||
| `group:updated` | group settings changed |
|
||||
| `group:removed` | user removed from group |
|
||||
| `user:online` / `user:offline` | presence change |
|
||||
| `users:online` | full online user list (on request) |
|
||||
| `session:displaced` | same device logged in elsewhere |
|
||||
| `schedule:event-created/updated/deleted` | schedule changes |
|
||||
|
||||
### Reconnect strategy (SocketContext.jsx)
|
||||
```javascript
|
||||
const socket = io({ transports: ['websocket'] }); // websocket only — no polling
|
||||
// reconnectionDelay: 500, reconnectionDelayMax: 3000, timeout: 8000
|
||||
// visibilitychange → visible: call socket.connect() if disconnected
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 8 — FCM Push Notifications
|
||||
|
||||
### Architecture
|
||||
```
|
||||
Frontend (browser/PWA)
|
||||
└─ Chat.jsx
|
||||
├─ GET /api/push/firebase-config → fetches SDK config
|
||||
├─ Initialises Firebase JS SDK + getMessaging()
|
||||
├─ getToken(messaging, { vapidKey }) → FCM token
|
||||
└─ POST /api/push/subscribe → registers in push_subscriptions
|
||||
|
||||
Backend (push.js)
|
||||
├─ sendPushToUser(schema, userId, payload) → called from messages.js (primary)
|
||||
│ and index.js socket handler (fallback)
|
||||
└─ Firebase Admin SDK → Google FCM servers → device
|
||||
```
|
||||
|
||||
### Message payload
|
||||
```javascript
|
||||
{
|
||||
token: sub.fcm_token,
|
||||
notification: { title, body },
|
||||
data: { url: '/', groupId: '42' },
|
||||
android: { priority: 'high', notification: { sound: 'default' } },
|
||||
webpush: { headers: { Urgency: 'high' }, fcm_options: { link: url } },
|
||||
}
|
||||
```
|
||||
|
||||
### Push trigger logic (messages.js)
|
||||
- Frontend sends messages via `POST /api/messages/group/:groupId` (REST), not socket
|
||||
- **Push must be fired from messages.js**, not just socket handler
|
||||
- Private group: push to all `group_members` except sender
|
||||
- Public group: push to all `DISTINCT user_id FROM push_subscriptions WHERE user_id != sender`
|
||||
- Image messages: body `'📷 Image'`
|
||||
|
||||
### Stale token cleanup
|
||||
`sendPushToUser` catches FCM errors and deletes the `push_subscriptions` row for:
|
||||
`messaging/registration-token-not-registered`, `messaging/invalid-registration-token`, `messaging/invalid-argument`
|
||||
|
||||
### Required env vars
|
||||
```
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_APP_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_VAPID_KEY= # Web Push certificate public key (Cloud Messaging tab)
|
||||
FIREBASE_SERVICE_ACCOUNT= # Full service account JSON, stringified (backend only)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 9 — API Routes
|
||||
|
||||
All routes require `authMiddleware` except login/health.
|
||||
|
||||
### Auth (`/api/auth`)
|
||||
- `POST /login`, `POST /logout`, `POST /change-password`, `GET /me`
|
||||
|
||||
### Users (`/api/users`)
|
||||
- `GET /` — admin: full user list
|
||||
- `POST /` — admin: create user
|
||||
- `PATCH /:id` — admin: update role/status/password
|
||||
- `PATCH /me/profile` — own profile (displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone)
|
||||
- `POST /me/avatar` — multipart: resize to 200×200 webp
|
||||
- `GET /check-display-name?name=`
|
||||
|
||||
### Groups (`/api/groups`)
|
||||
- `GET /` — returns `{ publicGroups, privateGroups }` with last_message, peer data for DMs
|
||||
- `POST /` — create group or DM
|
||||
- `PATCH /:id`, `DELETE /:id`
|
||||
- `POST /:id/members`, `DELETE /:id/members/:userId`, `GET /:id/members`
|
||||
- `POST /:id/custom-name`, `DELETE /:id/custom-name`
|
||||
|
||||
### Messages (`/api/messages`)
|
||||
- `GET /?groupId=&before=&limit=` — 50 per page, cursor-based
|
||||
- `POST /group/:groupId` — send message (REST path, fires push)
|
||||
- `POST /image` — image upload
|
||||
|
||||
### User Groups (`/api/usergroups`)
|
||||
- CRUD for user groups (team roster groupings), member management
|
||||
|
||||
### Schedule (`/api/schedule`)
|
||||
- CRUD for events, event types, availability tracking
|
||||
- `GET /events` — with date range, keyword filter, type filter, availability filter
|
||||
- `POST /events/:id/availability` — set own availability
|
||||
- `GET /events/:id/availability` — get all availability for an event
|
||||
|
||||
### Settings (`/api/settings`)
|
||||
- `GET /`, `PATCH /`, `POST /logo`, `POST /icon/:key`
|
||||
|
||||
### Push (`/api/push`)
|
||||
- `GET /firebase-config` — returns FCM SDK config
|
||||
- `POST /subscribe` — save FCM token
|
||||
- `GET /debug` — admin: list tokens + firebase status
|
||||
- `POST /test` — send test push to own device
|
||||
|
||||
### Host (`/api/host`) — host mode only
|
||||
- Tenant provisioning, plan management, host admin panel
|
||||
|
||||
### About (`/api/about`), Help (`/api/help`)
|
||||
|
||||
---
|
||||
|
||||
## Part 10 — Frontend Architecture
|
||||
|
||||
### Page navigation (Chat.jsx)
|
||||
`page` state: `'chat'` | `'groupmessages'` | `'schedule'` | `'users'` | `'groups'` | `'hostpanel'`
|
||||
|
||||
**Rule:** Every page navigation must call `setActiveGroupId(null)` and `setChatHasText(false)`.
|
||||
|
||||
### Group Messages vs Messages (Sidebar)
|
||||
- `groupMessagesMode={false}` → public groups + non-managed private groups
|
||||
- `groupMessagesMode={true}` → only `is_managed` private groups
|
||||
|
||||
### Unsaved text guard (Chat.jsx → ChatWindow.jsx → MessageInput.jsx)
|
||||
- `MessageInput` fires `onTextChange(val)` on every keystroke and after send
|
||||
- `ChatWindow` converts to boolean: `onHasTextChange?.(!!val.trim())`
|
||||
- `Chat.jsx` stores as `chatHasText`; `selectGroup()` shows `window.confirm` if switching with unsaved text
|
||||
- `MessageInput` resets all state on `group?.id` change via `useEffect`
|
||||
|
||||
### Font scale system
|
||||
- CSS var `--font-scale` on `<html>` element (default `1`, range `0.8`–`2.0`)
|
||||
- **Message fonts** use `calc(Xrem * var(--font-scale))` — they scale
|
||||
- **MessageInput font** is fixed (`0.875rem`) — it does NOT scale
|
||||
- **Slider** (ProfileModal appearance tab) is the saved setting — persisted to `localStorage`
|
||||
- **Pinch zoom** (main.jsx touch handler) is session-only — updates `--font-scale` but does NOT write to localStorage
|
||||
- On startup, `--font-scale` is initialised from the saved localStorage value
|
||||
|
||||
### Avatar colour algorithm
|
||||
Must be **identical** across `Avatar.jsx`, `Sidebar.jsx`, `ChatWindow.jsx`:
|
||||
```javascript
|
||||
const AVATAR_COLORS = ['#1a73e8','#ea4335','#34a853','#fa7b17','#a142f4','#00897b','#e91e8c','#0097a7'];
|
||||
const bg = AVATAR_COLORS[(user.name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||||
```
|
||||
|
||||
### Notification rules (group member changes, usergroups.js)
|
||||
- 1 user added/removed → named system message: `"{Name} has joined/been removed from the conversation."`
|
||||
- 2+ users added/removed → generic: `"N new members have joined/been removed from the conversation."`
|
||||
|
||||
### User deletion (v0.11.11+)
|
||||
Email → `deleted_{id}@deleted`, name → `'Deleted User'`, all messages `is_deleted=TRUE`, DMs `is_readonly=TRUE`, sessions/subscriptions/availability purged.
|
||||
|
||||
---
|
||||
|
||||
## Part 11 — Schedule / Events
|
||||
|
||||
- All date/time stored as `TIMESTAMPTZ`
|
||||
- `buildISO(date, time)` — builds timezone-aware ISO string from date + HH:MM input
|
||||
- `toTimeIn(iso)` — extracts exact HH:MM (no rounding) for edit forms
|
||||
- `roundUpToHalfHour()` — default start time for new events
|
||||
- New events cannot have a start date/time in the past
|
||||
- Recurring events: `expandRecurringEvent` returns occurrences within requested range only
|
||||
- Keyword filter: unquoted = `\bterm` (prefix match), quoted = `\bterm\b` (exact word)
|
||||
- Type filter does NOT shift date window to today (unlike keyword/availability filters)
|
||||
- Clearing keyword also resets `filterFromDate`
|
||||
|
||||
Both `SchedulePage.jsx` and `MobileEventForm.jsx` maintain their own copies of the time utilities (`roundUpToHalfHour`, `parseTypedTime`, `fmt12`, `toTimeIn`, `buildISO`).
|
||||
|
||||
---
|
||||
|
||||
## Part 12 — CSS Design System
|
||||
|
||||
### Variables (`:root` — light mode)
|
||||
```css
|
||||
--primary: #1a73e8;
|
||||
--primary-dark: #1557b0;
|
||||
--primary-light: #e8f0fe;
|
||||
--surface: #ffffff;
|
||||
--surface-variant: #f8f9fa;
|
||||
--background: #f1f3f4;
|
||||
--border: #e0e0e0;
|
||||
--text-primary: #202124;
|
||||
--text-secondary: #5f6368;
|
||||
--text-tertiary: #9aa0a6;
|
||||
--error: #d93025;
|
||||
--success: #188038;
|
||||
--bubble-out: #1a73e8;
|
||||
--bubble-in: #f1f3f4;
|
||||
--radius: 8px;
|
||||
--font: 'Google Sans', 'Roboto', sans-serif;
|
||||
--font-scale: 1; /* adjusted by pinch or slider */
|
||||
```
|
||||
|
||||
### Dark mode (`[data-theme="dark"]`)
|
||||
```css
|
||||
--primary: #4d8fd4;
|
||||
--primary-light: #1a2d4a;
|
||||
--surface: #1e1e2e;
|
||||
--surface-variant: #252535;
|
||||
--background: #13131f;
|
||||
--border: #2e2e45;
|
||||
--text-primary: #e2e2f0;
|
||||
--text-secondary: #9898b8;
|
||||
--text-tertiary: #606080; /* exactly 6 hex digits — a common typo is 7 */
|
||||
--bubble-out: #4d8fd4;
|
||||
--bubble-in: #252535;
|
||||
```
|
||||
|
||||
### Mobile input fixes
|
||||
```css
|
||||
/* Prevent iOS zoom on input focus (requires font-size >= 16px) */
|
||||
@media (max-width: 768px) {
|
||||
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
|
||||
}
|
||||
/* Autofill styling */
|
||||
input:-webkit-autofill {
|
||||
-webkit-box-shadow: 0 0 0 1000px var(--surface) inset !important;
|
||||
-webkit-text-fill-color: var(--text-primary) !important;
|
||||
}
|
||||
```
|
||||
|
||||
### Layout
|
||||
- Desktop: sidebar (320px fixed) + chat area (flex-1)
|
||||
- Mobile (≤768px): sidebar and chat stack — one visible at a time
|
||||
- `--visual-viewport-height` and `--visual-viewport-offset` CSS vars exposed by main.jsx for iOS keyboard handling
|
||||
- `.keyboard-open` class toggled on `<html>` when iOS keyboard is visible
|
||||
|
||||
---
|
||||
|
||||
## Part 13 — Docker & Deployment
|
||||
|
||||
### Dockerfile (multi-stage)
|
||||
```dockerfile
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY frontend/package*.json ./frontend/
|
||||
RUN cd frontend && npm install
|
||||
COPY frontend/ ./frontend/
|
||||
RUN cd frontend && npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY backend/package*.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY backend/ ./
|
||||
COPY --from=builder /app/frontend/dist ./public
|
||||
RUN mkdir -p /app/uploads/avatars /app/uploads/logos /app/uploads/images
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/index.js"]
|
||||
```
|
||||
|
||||
### Version bump — all three locations
|
||||
```
|
||||
backend/package.json "version": "X.Y.Z"
|
||||
frontend/package.json "version": "X.Y.Z"
|
||||
build.sh VERSION="${1:-X.Y.Z}"
|
||||
```
|
||||
|
||||
### Environment variables (key ones)
|
||||
```
|
||||
APP_TYPE=selfhost|host
|
||||
HOST_DOMAIN= # host mode only
|
||||
HOST_ADMIN_KEY= # host mode only
|
||||
JWT_SECRET=
|
||||
DB_HOST=db # set to 'pgbouncer' after Phase 1 scaling
|
||||
DB_NAME=rosterchirp
|
||||
DB_USER=rosterchirp
|
||||
DB_PASSWORD= # avoid ! (shell interpolation issue in docker-compose)
|
||||
ADMIN_EMAIL=
|
||||
ADMIN_NAME=
|
||||
ADMIN_PASS=
|
||||
ADMPW_RESET=true|false
|
||||
APP_NAME=rosterchirp
|
||||
USER_PASS= # default password for bulk-created users
|
||||
DEFCHAT_NAME=General Chat
|
||||
FIREBASE_API_KEY=
|
||||
FIREBASE_PROJECT_ID=
|
||||
FIREBASE_APP_ID=
|
||||
FIREBASE_MESSAGING_SENDER_ID=
|
||||
FIREBASE_VAPID_KEY=
|
||||
FIREBASE_SERVICE_ACCOUNT= # stringified JSON, no newlines
|
||||
VAPID_PUBLIC= # legacy, auto-generated, no longer used for push delivery
|
||||
VAPID_PRIVATE=
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 14 — Scale Architecture (Planned)
|
||||
|
||||
### Phase 1 — PgBouncer (zero code changes)
|
||||
Add PgBouncer service to `docker-compose.host.yaml`. Point `DB_HOST=pgbouncer`. Increase Node pool `max` to 100. Use `POOL_MODE=transaction`. Eliminates the 20-connection bottleneck.
|
||||
|
||||
### Phase 2 — Redis (horizontal scaling)
|
||||
Required for multiple Node instances:
|
||||
1. `@socket.io/redis-adapter` — cross-instance socket fan-out
|
||||
2. Replace `onlineUsers` Map with Redis `SADD`/`SREM` presence keys (`presence:{schema}:{userId}`)
|
||||
3. Replace `tenantDomainCache` Map with Redis hash + TTL
|
||||
4. Move uploads to Cloudflare R2 (S3-compatible) — `@aws-sdk/client-s3`
|
||||
5. Force WebSocket transport only (eliminates polling sticky-session concern)
|
||||
|
||||
**Note on Phase 2:** `SET search_path` per query is safe with PgBouncer in transaction mode. Do NOT use `LISTEN/NOTIFY` or session-level state through PgBouncer.
|
||||
|
||||
---
|
||||
|
||||
## Part 15 — Known Gotchas & Decisions
|
||||
|
||||
| Gotcha | Solution |
|
||||
|---|---|
|
||||
| Multi-tenant schema isolation | Every query must go through `query(schema, ...)` — never raw `pool.query` |
|
||||
| `assertSafeSchema()` | Always validate schema names before use — injection risk |
|
||||
| Socket room names include schema | `R(schema, 'group', id)` not bare `group:{id}` — cross-tenant leakage otherwise |
|
||||
| `onlineUsers` key is `${schema}:${userId}` | Two tenants can share the same integer user ID |
|
||||
| FCM push fired from messages.js REST route | Frontend uses REST POST, not socket, for sending messages |
|
||||
| Pinch zoom is session-only | Remove `localStorage.setItem` from touchend — slider is the saved setting |
|
||||
| MessageInput font is fixed | Do not apply `--font-scale` to `.msg-input` font-size |
|
||||
| iOS keyboard layout | Use `--visual-viewport-height` CSS var, not `100vh`, for the chat layout height |
|
||||
| Avatar colour algorithm | Must be identical in Avatar.jsx, Sidebar.jsx, and ChatWindow.jsx |
|
||||
| `is_managed` groups | Managed private groups appear in Group Messages view, not regular Messages view |
|
||||
| Migrations are SQL files | Not try/catch ALTER TABLE — numbered SQL files in `migrations/` applied in order |
|
||||
| DB_PASSWORD must not contain `!` | Shell interpolation breaks docker-compose env parsing |
|
||||
| Routes accept `io` as parameter | `module.exports = (io) => router` — not default export |
|
||||
| `session:displaced` socket event | Sent to the old socket when a new login displaces a session on the same device type |
|
||||
| `help.md` is NOT in the volume path | Must be at `backend/src/data/help.md` — not in `/app/data/` which is volume-mounted |
|
||||
| Dark mode `--text-tertiary` | Exactly 6 hex digits: `#606080` not `#6060808` |
|
||||
| Web Share API for mobile file download | Use `navigator.share({ files: [...] })` on mobile; fall back to `a.click()` download on desktop |
|
||||
|
||||
---
|
||||
|
||||
## Part 16 — Features Checklist
|
||||
|
||||
### Messaging
|
||||
- [x] Text messages with URL auto-linking and @mentions
|
||||
- [x] Image upload + lightbox
|
||||
- [x] Link preview cards (og: meta, server-side fetch)
|
||||
- [x] Reply-to with quoted preview
|
||||
- [x] Emoji reactions (quick bar + full picker)
|
||||
- [x] Message soft-delete
|
||||
- [x] Typing indicator
|
||||
- [x] Date separators, consecutive-message collapsing
|
||||
- [x] System messages
|
||||
- [x] Emoji-only messages render larger
|
||||
- [x] Infinite scroll / cursor-based pagination (50 per page)
|
||||
|
||||
### Groups & DMs
|
||||
- [x] Public channels, private groups, read-only channels
|
||||
- [x] Managed private groups (Group Messages view)
|
||||
- [x] User-to-user direct messages
|
||||
- [x] Per-user custom group display name
|
||||
- [x] User groups (team roster groupings) with colour coding
|
||||
- [x] Group availability tracking (events)
|
||||
|
||||
### Users & Profiles
|
||||
- [x] Display name, avatar, about me, date of birth, phone
|
||||
- [x] Hide admin tag option
|
||||
- [x] Allow/block DMs toggle
|
||||
- [x] Child/alias user accounts (`AddChildAliasModal`)
|
||||
- [x] Bulk user import via CSV
|
||||
|
||||
### Admin
|
||||
- [x] Settings modal: app name, logo, PWA icons
|
||||
- [x] Branding modal (Brand+ plan)
|
||||
- [x] User manager (full page): create, edit, suspend, reset password, bulk import
|
||||
- [x] Group manager (full page): create groups, manage members, assign user groups
|
||||
- [x] Schedule manager modal: event types with custom colours
|
||||
- [x] Admin can delete any message
|
||||
|
||||
### Schedule
|
||||
- [x] Event creation (one-time + recurring)
|
||||
- [x] Event types with colour coding
|
||||
- [x] Availability tracking (Going / Maybe / Not Going)
|
||||
- [x] Download availability list (Web Share API on mobile, download link on desktop)
|
||||
- [x] Keyword filter (prefix and exact-word modes)
|
||||
- [x] Type filter, date range filter
|
||||
- [x] Desktop and mobile views (separate implementations)
|
||||
|
||||
### PWA / Notifications
|
||||
- [x] Installable PWA (manifest, service worker, icons)
|
||||
- [x] FCM push notifications (Android working; iOS in progress)
|
||||
- [x] App badge on home screen icon
|
||||
- [x] Page title unread count `(N) App Name`
|
||||
- [x] Per-conversation notification grouping
|
||||
|
||||
### UX
|
||||
- [x] Light / dark mode (CSS vars, saved localStorage)
|
||||
- [x] Font scale slider (saved setting) + pinch zoom (session only)
|
||||
- [x] Mobile-responsive layout
|
||||
- [x] Pull-to-refresh blocked in PWA standalone mode
|
||||
- [x] iOS keyboard layout fix (`--visual-viewport-height`)
|
||||
- [x] Getting Started help modal
|
||||
- [x] About modal, Support modal
|
||||
- [x] User profile popup (click any avatar)
|
||||
- [x] NavDrawer (hamburger menu)
|
||||
|
||||
---
|
||||
|
||||
## Part 17 — One-Shot Prompt (Copy-Paste to Start)
|
||||
|
||||
```
|
||||
Build a self-hosted team chat PWA called "RosterChirp". Single Docker container.
|
||||
Supports selfhost (single tenant) and host (multi-tenant via Postgres schema per tenant) modes.
|
||||
|
||||
STACK: Node 20 + Express + Socket.io + PostgreSQL 16 (pg npm package) + JWT
|
||||
(HTTP-only cookie + localStorage) + bcryptjs + React 18 + Vite.
|
||||
Push via Firebase Cloud Messaging (firebase-admin backend, firebase frontend SDK).
|
||||
Images via multer + sharp. Frontend: plain CSS with CSS custom properties, no Tailwind.
|
||||
|
||||
MULTI-TENANT: tenantMiddleware sets req.schema from Host header. assertSafeSchema()
|
||||
validates all schema names. Socket rooms prefixed: `${schema}:${type}:${id}`.
|
||||
onlineUsers Map key is `${schema}:${userId}` to prevent cross-tenant ID collisions.
|
||||
Every DB query calls SET search_path TO {schema} first.
|
||||
|
||||
MIGRATIONS: Numbered SQL files in backend/src/models/migrations/ (001, 002, ...).
|
||||
Auto-applied on startup via runMigrations(schema). Never edit applied migrations.
|
||||
|
||||
ROUTES accept io as parameter: module.exports = (io) => router
|
||||
auth.js(io), groups.js(io), messages.js(io), usergroups.js(io), schedule.js(io)
|
||||
|
||||
KEY FEATURES:
|
||||
- Public/private/readonly channels, managed private groups (Group Messages view),
|
||||
user-to-user DMs, @mentions, emoji reactions, reply-to, image upload, link previews,
|
||||
soft-delete, typing indicator, unread badges, page title (N) count
|
||||
- User groups (team roster groupings) with colour coding
|
||||
- Schedule: events, event types, availability tracking, recurring events
|
||||
- Font scale: --font-scale CSS var on <html>. Message fonts scale with it. MessageInput
|
||||
font is FIXED (no --font-scale). Slider in ProfileModal = saved setting (localStorage).
|
||||
Pinch zoom = session only (touchend must NOT write to localStorage).
|
||||
- FCM push: fired from messages.js REST route (not socket handler). sendPushToUser helper.
|
||||
Stale token cleanup on FCM error codes.
|
||||
- Avatar colour: AVATAR_COLORS array, charCodeAt(0) % length. Must be identical in
|
||||
Avatar.jsx, Sidebar.jsx, ChatWindow.jsx.
|
||||
- User deletion: email scrubbed, messages nulled, DMs set readonly, sessions purged.
|
||||
- Web Share API for mobile file downloads; a.click() fallback for desktop.
|
||||
|
||||
GOTCHAS:
|
||||
- DB_PASSWORD must not contain '!' (shell interpolation in docker-compose)
|
||||
- dark mode --text-tertiary must be exactly 6 hex digits: #606080
|
||||
- help.md at backend/src/data/help.md (NOT /app/data — volume-mounted, shadows files)
|
||||
- Session displaced: socket receives 'session:displaced' when new login takes device slot
|
||||
- iOS keyboard: use --visual-viewport-height CSS var (not 100vh) for chat layout height
|
||||
- Routes that emit socket events receive io as first argument, not default export
|
||||
```
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "rosterchirp-backend",
|
||||
"version": "0.11.25",
|
||||
"description": "TeamChat backend server",
|
||||
"version": "0.13.1",
|
||||
"description": "RosterChirp backend server",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
@@ -20,7 +20,8 @@
|
||||
"sharp": "^0.33.2",
|
||||
"socket.io": "^4.6.1",
|
||||
"csv-parse": "^5.5.6",
|
||||
"pg": "^8.11.3"
|
||||
"pg": "^8.11.3",
|
||||
"web-push": "^3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
|
||||
@@ -224,26 +224,47 @@ io.on('connection', async (socket) => {
|
||||
message.reactions = [];
|
||||
io.to(R('group', groupId)).emit('message:new', message);
|
||||
|
||||
// Push notifications for private groups
|
||||
// Push notifications
|
||||
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
||||
const msgBody = (content || (imageUrl ? '📷 Image' : '')).slice(0, 100);
|
||||
|
||||
if (group.type === 'private') {
|
||||
const members = await query(schema,
|
||||
'SELECT user_id FROM group_members WHERE group_id = $1', [groupId]
|
||||
);
|
||||
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
||||
for (const m of members) {
|
||||
if (m.user_id === userId) continue;
|
||||
const memberKey = `${schema}:${m.user_id}`;
|
||||
if (!onlineUsers.has(memberKey)) {
|
||||
sendPushToUser(schema, m.user_id, {
|
||||
title: senderName,
|
||||
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
|
||||
url: '/', groupId, badge: 1,
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
if (onlineUsers.has(memberKey)) {
|
||||
// In-app notification for connected sockets
|
||||
for (const sid of onlineUsers.get(memberKey)) {
|
||||
io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user });
|
||||
}
|
||||
}
|
||||
// Always send push — when the app is in the foreground FCM delivers
|
||||
// silently (no system notification); when backgrounded or offline the
|
||||
// service worker shows the system notification. This covers the common
|
||||
// Android case where the socket appears online but is silently dead
|
||||
// after the PWA was backgrounded (OS kills WebSocket before ping timeout).
|
||||
sendPushToUser(schema, m.user_id, {
|
||||
title: senderName,
|
||||
body: msgBody,
|
||||
url: '/', groupId, badge: 1,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (group.type === 'public') {
|
||||
// Push to all users who have a push subscription — everyone is implicitly
|
||||
// a member of every public group. Skip the sender.
|
||||
const subUsers = await query(schema,
|
||||
'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
|
||||
[userId]
|
||||
);
|
||||
for (const sub of subUsers) {
|
||||
sendPushToUser(schema, sub.user_id, {
|
||||
title: `${senderName} in ${group.name}`,
|
||||
body: msgBody,
|
||||
url: '/', groupId, badge: 1,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ function adminMiddleware(req, res, next) {
|
||||
}
|
||||
|
||||
async function teamManagerMiddleware(req, res, next) {
|
||||
if (req.user?.role === 'admin') return next();
|
||||
if (req.user?.role === 'admin' || req.user?.role === 'manager') return next();
|
||||
try {
|
||||
const tmSetting = await queryOne(req.schema,
|
||||
"SELECT value FROM settings WHERE key = 'team_tool_managers'"
|
||||
|
||||
@@ -14,13 +14,13 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// APP_TYPE validation — host mode requires HOST_DOMAIN and HOST_ADMIN_KEY.
|
||||
// If either is missing, fall back to selfhost and warn rather than silently
|
||||
// APP_TYPE validation — host mode requires APP_DOMAIN, HOST_SLUG, and HOST_ADMIN_KEY.
|
||||
// If any are missing, fall back to selfhost and warn rather than silently
|
||||
// exposing a broken or insecure host control plane.
|
||||
let APP_TYPE = (process.env.APP_TYPE || 'selfhost').toLowerCase().trim();
|
||||
if (APP_TYPE === 'host') {
|
||||
if (!process.env.HOST_DOMAIN || !process.env.HOST_ADMIN_KEY) {
|
||||
console.warn('[DB] WARNING: APP_TYPE=host requires HOST_DOMAIN and HOST_ADMIN_KEY to be set.');
|
||||
if (!process.env.APP_DOMAIN || !process.env.HOST_SLUG || !process.env.HOST_ADMIN_KEY) {
|
||||
console.warn('[DB] WARNING: APP_TYPE=host requires APP_DOMAIN, HOST_SLUG, and HOST_ADMIN_KEY to be set.');
|
||||
console.warn('[DB] WARNING: Falling back to APP_TYPE=selfhost for safety.');
|
||||
APP_TYPE = 'selfhost';
|
||||
}
|
||||
@@ -52,12 +52,17 @@ function resolveSchema(req) {
|
||||
if (APP_TYPE === 'selfhost') return 'public';
|
||||
|
||||
const host = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||
const baseDomain = (process.env.HOST_DOMAIN || 'rosterchirp.com').toLowerCase();
|
||||
const baseDomain = (process.env.APP_DOMAIN || 'rosterchirp.com').toLowerCase();
|
||||
const hostSlug = (process.env.HOST_SLUG || 'host').toLowerCase();
|
||||
const hostControlDomain = `${hostSlug}.${baseDomain}`;
|
||||
|
||||
// Internal requests (Docker health checks, localhost) → public schema
|
||||
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public';
|
||||
|
||||
// Subdomain: team1.rosterchirp.com → tenant_team1
|
||||
// Host control panel subdomain: chathost.example.com → public schema
|
||||
if (host === hostControlDomain) return 'public';
|
||||
|
||||
// Tenant subdomain: mychat.example.com → tenant_mychat
|
||||
if (host.endsWith(`.${baseDomain}`)) {
|
||||
const slug = host.slice(0, -(baseDomain.length + 1));
|
||||
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`);
|
||||
@@ -67,9 +72,6 @@ function resolveSchema(req) {
|
||||
// Custom domain lookup (populated from host admin DB)
|
||||
if (tenantDomainCache.has(host)) return tenantDomainCache.get(host);
|
||||
|
||||
// Base domain → public schema (host admin panel)
|
||||
if (host === baseDomain || host === `www.${baseDomain}`) return 'public';
|
||||
|
||||
throw new Error(`Unknown tenant for host: ${host}`);
|
||||
}
|
||||
|
||||
@@ -249,7 +251,21 @@ async function seedUserGroups(schema) {
|
||||
const existing = await queryOne(schema,
|
||||
'SELECT id FROM user_groups WHERE name = $1', [name]
|
||||
);
|
||||
if (existing) continue;
|
||||
if (existing) {
|
||||
// Auto-configure feature settings if not already set
|
||||
if (name === 'Players') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[existing.id.toString()]
|
||||
);
|
||||
} else if (name === 'Parents') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[existing.id.toString()]
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the managed DM chat group first
|
||||
const gr = await queryResult(schema,
|
||||
@@ -259,17 +275,31 @@ async function seedUserGroups(schema) {
|
||||
const dmGroupId = gr.rows[0].id;
|
||||
|
||||
// Create the user group linked to the DM group
|
||||
await exec(schema,
|
||||
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING',
|
||||
const ugr = await queryResult(schema,
|
||||
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING id',
|
||||
[name, dmGroupId]
|
||||
);
|
||||
const ugId = ugr.rows[0]?.id;
|
||||
console.log(`[DB:${schema}] Default user group created: ${name}`);
|
||||
|
||||
// Auto-configure feature settings for players/parents groups
|
||||
if (ugId && name === 'Players') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_players_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[ugId.toString()]
|
||||
);
|
||||
} else if (ugId && name === 'Parents') {
|
||||
await exec(schema,
|
||||
"INSERT INTO settings (key, value) VALUES ('feature_guardians_group_id', $1) ON CONFLICT (key) DO NOTHING",
|
||||
[ugId.toString()]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function seedAdmin(schema) {
|
||||
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim();
|
||||
const adminEmail = strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local';
|
||||
const adminEmail = (strip(process.env.ADMIN_EMAIL) || 'admin@rosterchirp.local').toLowerCase();
|
||||
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';
|
||||
|
||||
17
backend/src/models/migrations/009_user_profile_fields.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Migration 009: Extended user profile fields
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone TEXT;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS is_minor BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Back-fill first_name / last_name from existing combined name for non-deleted users
|
||||
UPDATE users
|
||||
SET
|
||||
first_name = SPLIT_PART(TRIM(name), ' ', 1),
|
||||
last_name = CASE
|
||||
WHEN POSITION(' ' IN TRIM(name)) > 0
|
||||
THEN NULLIF(TRIM(SUBSTR(TRIM(name), POSITION(' ' IN TRIM(name)) + 1)), '')
|
||||
ELSE NULL
|
||||
END
|
||||
WHERE first_name IS NULL
|
||||
AND TRIM(name) NOT IN ('Deleted User', '');
|
||||
3
backend/src/models/migrations/010_dob_guardian.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Migration 010: Date of birth and guardian fields
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS date_of_birth DATE;
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL;
|
||||
9
backend/src/models/migrations/011_webpush.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
-- Migration 011: Add Web Push (VAPID) subscription columns for iOS PWA support
|
||||
-- iOS uses the standard W3C Web Push protocol (not FCM). A subscription consists of
|
||||
-- an endpoint URL (web.push.apple.com) plus two crypto keys (p256dh + auth).
|
||||
-- Rows will have either fcm_token set (Android/Chrome) OR the three webpush_* columns
|
||||
-- set (iOS/Firefox/Edge). Never both on the same row.
|
||||
ALTER TABLE push_subscriptions
|
||||
ADD COLUMN IF NOT EXISTS webpush_endpoint TEXT,
|
||||
ADD COLUMN IF NOT EXISTS webpush_p256dh TEXT,
|
||||
ADD COLUMN IF NOT EXISTS webpush_auth TEXT;
|
||||
5
backend/src/models/migrations/012_composite_avatar.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- Migration 012: Add composite_members to groups for private group avatar composites
|
||||
-- Stores up to 4 member previews (id, name, avatar) as a JSONB snapshot.
|
||||
-- Only set for non-managed, non-direct private groups with 3+ members.
|
||||
-- Updated only when a member is added and pre-add membership count was ≤3.
|
||||
ALTER TABLE groups ADD COLUMN IF NOT EXISTS composite_members JSONB;
|
||||
1
backend/src/models/migrations/013_availability_note.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE event_availability ADD COLUMN IF NOT EXISTS note VARCHAR(20);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Exception instances for recurring events (Google Calendar Series-Instance model)
|
||||
-- recurring_master_id: links a standalone exception instance back to its series master
|
||||
-- original_start_at: the virtual occurrence date/time this instance replaced
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS recurring_master_id INTEGER REFERENCES events(id) ON DELETE CASCADE;
|
||||
ALTER TABLE events ADD COLUMN IF NOT EXISTS original_start_at TIMESTAMPTZ;
|
||||
41
backend/src/models/migrations/015_minor_age_protection.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- 015_minor_age_protection.sql
|
||||
-- Adds tables and columns for Guardian Only and Mixed Age login type modes.
|
||||
|
||||
-- 1. guardian_approval_required on users (Mixed Age: minor needs approval before unsuspend)
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS guardian_approval_required BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- 2. guardian_aliases — children as name aliases under a guardian (Guardian Only mode)
|
||||
CREATE TABLE IF NOT EXISTS guardian_aliases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
guardian_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
first_name TEXT NOT NULL,
|
||||
last_name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
date_of_birth DATE,
|
||||
avatar TEXT,
|
||||
phone TEXT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_aliases_guardian ON guardian_aliases(guardian_id);
|
||||
|
||||
-- 3. alias_group_members — links guardian aliases to user groups (e.g. players group)
|
||||
CREATE TABLE IF NOT EXISTS alias_group_members (
|
||||
user_group_id INTEGER NOT NULL REFERENCES user_groups(id) ON DELETE CASCADE,
|
||||
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (user_group_id, alias_id)
|
||||
);
|
||||
|
||||
-- 4. event_alias_availability — availability responses for guardian aliases
|
||||
CREATE TABLE IF NOT EXISTS event_alias_availability (
|
||||
event_id INTEGER NOT NULL REFERENCES events(id) ON DELETE CASCADE,
|
||||
alias_id INTEGER NOT NULL REFERENCES guardian_aliases(id) ON DELETE CASCADE,
|
||||
response TEXT NOT NULL CHECK(response IN ('going','maybe','not_going')),
|
||||
note VARCHAR(20),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (event_id, alias_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_event_alias_availability_event ON event_alias_availability(event_id);
|
||||
16
backend/src/models/migrations/016_guardian_partners.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
-- 016_guardian_partners.sql
|
||||
-- Partner/spouse relationship between guardians.
|
||||
-- Partners share the same child alias list (both can manage it) and can
|
||||
-- respond to events on behalf of each other within shared user groups.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS guardian_partners (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id_1 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
user_id_2 INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (user_id_1, user_id_2),
|
||||
CHECK (user_id_1 < user_id_2)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user1 ON guardian_partners(user_id_1);
|
||||
CREATE INDEX IF NOT EXISTS idx_guardian_partners_user2 ON guardian_partners(user_id_2);
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 017_partner_respond_separately.sql
|
||||
-- Adds respond_separately flag to guardian_partners.
|
||||
-- When true, linked partners can each respond to events on behalf of children
|
||||
-- in the shared alias list, but cannot respond on behalf of each other.
|
||||
|
||||
ALTER TABLE guardian_partners ADD COLUMN IF NOT EXISTS respond_separately BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -12,7 +12,7 @@ module.exports = function(io) {
|
||||
router.post('/login', async (req, res) => {
|
||||
const { email, password, rememberMe } = req.body;
|
||||
try {
|
||||
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE email = $1', [email]);
|
||||
const user = await queryOne(req.schema, 'SELECT * FROM users WHERE LOWER(email) = LOWER($1)', [email]);
|
||||
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||
|
||||
if (user.status === 'suspended') {
|
||||
@@ -62,6 +62,7 @@ module.exports = function(io) {
|
||||
router.post('/logout', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await clearActiveSession(req.schema, req.user.id, req.device);
|
||||
await exec(req.schema, 'DELETE FROM push_subscriptions WHERE user_id=$1 AND device=$2', [req.user.id, req.device]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
@@ -4,6 +4,11 @@ const router = express.Router();
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
|
||||
|
||||
async function getLoginType(schema) {
|
||||
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
return row?.value || 'all_ages';
|
||||
}
|
||||
|
||||
function deleteImageFile(imageUrl) {
|
||||
if (!imageUrl) return;
|
||||
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
|
||||
@@ -13,6 +18,21 @@ function deleteImageFile(imageUrl) {
|
||||
// Schema-aware room name helper
|
||||
const R = (schema, type, id) => `${schema}:${type}:${id}`;
|
||||
|
||||
// Compute and store composite_members for a non-managed private group.
|
||||
// Captures up to 4 current members (excluding deleted users), ordered by name.
|
||||
async function computeAndStoreComposite(schema, groupId) {
|
||||
const members = await query(schema,
|
||||
`SELECT u.id, u.name, u.avatar FROM group_members gm
|
||||
JOIN users u ON gm.user_id = u.id
|
||||
WHERE gm.group_id = $1 AND u.name != 'Deleted User'
|
||||
ORDER BY u.name LIMIT 4`,
|
||||
[groupId]
|
||||
);
|
||||
await exec(schema, 'UPDATE groups SET composite_members=$1 WHERE id=$2',
|
||||
[JSON.stringify(members), groupId]
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = (io) => {
|
||||
|
||||
async function emitGroupNew(schema, io, groupId) {
|
||||
@@ -52,13 +72,22 @@ router.get('/', authMiddleware, async (req, res) => {
|
||||
`);
|
||||
|
||||
const privateGroupsRaw = await query(req.schema, `
|
||||
SELECT g.*, u.name AS owner_name,
|
||||
SELECT g.*, u.name AS owner_name, ug.id AS source_user_group_id,
|
||||
(SELECT COUNT(*) FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE) AS message_count,
|
||||
(SELECT m.content FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message,
|
||||
(SELECT m.created_at FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_at,
|
||||
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id
|
||||
(SELECT m.user_id FROM messages m WHERE m.group_id=g.id AND m.is_deleted=FALSE ORDER BY m.created_at DESC LIMIT 1) AS last_message_user_id,
|
||||
(SELECT json_agg(t) FROM (
|
||||
SELECT u2.id, u2.name, u2.avatar
|
||||
FROM group_members gm2
|
||||
JOIN users u2 ON gm2.user_id = u2.id
|
||||
WHERE gm2.group_id = g.id AND u2.name != 'Deleted User'
|
||||
ORDER BY u2.name LIMIT 4
|
||||
) t) AS member_previews
|
||||
FROM groups g JOIN group_members gm ON g.id=gm.group_id AND gm.user_id=$1
|
||||
LEFT JOIN users u ON g.owner_id=u.id WHERE g.type='private'
|
||||
LEFT JOIN users u ON g.owner_id=u.id
|
||||
LEFT JOIN user_groups ug ON ug.dm_group_id=g.id AND g.is_managed=TRUE AND g.is_multi_group IS NOT TRUE
|
||||
WHERE g.type='private'
|
||||
ORDER BY last_message_at DESC NULLS LAST
|
||||
`, [userId]);
|
||||
|
||||
@@ -160,8 +189,30 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||
const groupId = r.rows[0].id;
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, userId]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]);
|
||||
|
||||
// Mixed Age: if initiator is not a minor and the other user is a minor, auto-add their guardian
|
||||
let guardianAdded = false, guardianName = null;
|
||||
const loginType = await getLoginType(req.schema);
|
||||
if (loginType === 'mixed_age' && !req.user.is_minor) {
|
||||
const otherUserFull = await queryOne(req.schema,
|
||||
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [otherUserId]);
|
||||
if (otherUserFull?.is_minor && otherUserFull.guardian_user_id) {
|
||||
const guardianId = otherUserFull.guardian_user_id;
|
||||
if (guardianId !== userId) {
|
||||
await exec(req.schema,
|
||||
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[groupId, guardianId]);
|
||||
const guardian = await queryOne(req.schema,
|
||||
'SELECT name, display_name FROM users WHERE id=$1', [guardianId]);
|
||||
guardianAdded = true;
|
||||
guardianName = guardian?.display_name || guardian?.name || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await emitGroupNew(req.schema, io, groupId);
|
||||
return res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
||||
const group = await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]);
|
||||
return res.json({ group, guardianAdded, guardianName });
|
||||
}
|
||||
|
||||
// Check for duplicate private group
|
||||
@@ -183,6 +234,7 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||
[name, type||'private', req.user.id, !!isReadonly]
|
||||
);
|
||||
const groupId = r.rows[0].id;
|
||||
const groupGuardianNames = [];
|
||||
if (type === 'public') {
|
||||
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'");
|
||||
for (const u of allUsers) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, u.id]);
|
||||
@@ -195,9 +247,35 @@ router.post('/', authMiddleware, async (req, res) => {
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, uid]);
|
||||
}
|
||||
}
|
||||
// Generate composite avatar for non-managed private groups with 3+ members
|
||||
const totalCount = await queryOne(req.schema, 'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id=$1', [groupId]);
|
||||
if (parseInt(totalCount.cnt) >= 3) {
|
||||
await computeAndStoreComposite(req.schema, groupId);
|
||||
}
|
||||
|
||||
// Mixed Age: auto-add guardians for any minor members (non-minor initiators only)
|
||||
const groupLoginType = await getLoginType(req.schema);
|
||||
if (groupLoginType === 'mixed_age' && !req.user.is_minor && memberIds?.length > 0) {
|
||||
for (const uid of memberIds) {
|
||||
const memberInfo = await queryOne(req.schema,
|
||||
'SELECT is_minor, guardian_user_id FROM users WHERE id=$1', [uid]);
|
||||
if (memberInfo?.is_minor && memberInfo.guardian_user_id && memberInfo.guardian_user_id !== req.user.id) {
|
||||
await exec(req.schema,
|
||||
'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[groupId, memberInfo.guardian_user_id]);
|
||||
const g = await queryOne(req.schema,
|
||||
'SELECT name,display_name FROM users WHERE id=$1', [memberInfo.guardian_user_id]);
|
||||
const gName = g?.display_name || g?.name;
|
||||
if (gName && !groupGuardianNames.includes(gName)) groupGuardianNames.push(gName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await emitGroupNew(req.schema, io, groupId);
|
||||
res.json({ group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]) });
|
||||
res.json({
|
||||
group: await queryOne(req.schema, 'SELECT * FROM groups WHERE id=$1', [groupId]),
|
||||
...(groupGuardianNames.length ? { guardianAdded: true, guardianName: groupGuardianNames.join(', ') } : {}),
|
||||
});
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
@@ -239,6 +317,8 @@ router.post('/:id/members', authMiddleware, async (req, res) => {
|
||||
if (group.owner_id !== req.user.id && req.user.role !== 'admin') return res.status(403).json({ error: 'Only owner can add members' });
|
||||
const targetUser = await queryOne(req.schema, 'SELECT is_default_admin FROM users WHERE id=$1', [userId]);
|
||||
if (targetUser?.is_default_admin) return res.status(400).json({ error: 'Default admin cannot be added to private groups' });
|
||||
// Capture pre-add count to decide if composite should regenerate
|
||||
const preAddCount = await queryOne(req.schema, 'SELECT COUNT(*) AS cnt FROM group_members WHERE group_id=$1', [group.id]);
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [group.id, userId]);
|
||||
const addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
|
||||
@@ -252,6 +332,18 @@ router.post('/:id/members', authMiddleware, async (req, res) => {
|
||||
);
|
||||
sysMsg.reactions = [];
|
||||
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg);
|
||||
// For non-managed private groups, always notify existing members of the updated group,
|
||||
// and regenerate composite when pre-add count was ≤3 and new total reaches ≥3.
|
||||
if (!group.is_managed && !group.is_direct) {
|
||||
const preCount = parseInt(preAddCount.cnt);
|
||||
if (preCount <= 3) {
|
||||
const newTotal = preCount + 1;
|
||||
if (newTotal >= 3) {
|
||||
await computeAndStoreComposite(req.schema, group.id);
|
||||
}
|
||||
}
|
||||
await emitGroupUpdated(req.schema, io, group.id);
|
||||
}
|
||||
io.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:new', { group });
|
||||
res.json({ success: true });
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const router = express.Router();
|
||||
const {
|
||||
query, queryOne, queryResult, exec,
|
||||
@@ -161,7 +162,7 @@ router.post('/tenants', async (req, res) => {
|
||||
// 7. Reload domain cache
|
||||
await reloadTenantCache();
|
||||
|
||||
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com';
|
||||
const baseDomain = process.env.APP_DOMAIN || 'rosterchirp.com';
|
||||
const tenant = tr.rows[0];
|
||||
tenant.url = `https://${slug}.${baseDomain}`;
|
||||
|
||||
@@ -186,7 +187,7 @@ router.post('/tenants', async (req, res) => {
|
||||
// Supports updating: name, plan, customDomain, status
|
||||
|
||||
router.patch('/tenants/:slug', async (req, res) => {
|
||||
const { name, plan, customDomain, status } = req.body;
|
||||
const { name, plan, customDomain, status, adminPassword } = req.body;
|
||||
try {
|
||||
const tenant = await queryOne('public',
|
||||
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug]
|
||||
@@ -224,6 +225,15 @@ router.patch('/tenants/:slug', async (req, res) => {
|
||||
await exec(s, "UPDATE settings SET value=$1 WHERE key='app_type'", [planAppType]);
|
||||
}
|
||||
|
||||
// Reset tenant admin password if provided
|
||||
if (adminPassword && adminPassword.length >= 6) {
|
||||
const hash = bcrypt.hashSync(adminPassword, 10);
|
||||
await exec(tenant.schema_name,
|
||||
"UPDATE users SET password=$1, must_change_password=TRUE, updated_at=NOW() WHERE is_default_admin=TRUE",
|
||||
[hash]
|
||||
);
|
||||
}
|
||||
|
||||
await reloadTenantCache();
|
||||
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
|
||||
res.json({ tenant: updated });
|
||||
@@ -310,7 +320,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 || 'rosterchirp.com';
|
||||
const baseDomain = process.env.APP_DOMAIN || 'rosterchirp.com';
|
||||
res.json({
|
||||
ok: true,
|
||||
appType: process.env.APP_TYPE || 'selfhost',
|
||||
|
||||
@@ -3,6 +3,7 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { sendPushToUser } = require('./push');
|
||||
|
||||
function deleteImageFile(imageUrl) {
|
||||
if (!imageUrl) return;
|
||||
@@ -101,6 +102,32 @@ module.exports = function(io) {
|
||||
`, [r.rows[0].id]);
|
||||
message.reactions = [];
|
||||
io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message);
|
||||
|
||||
// Push notifications
|
||||
const senderName = message.user_display_name || message.user_name || 'Someone';
|
||||
const msgBody = (content?.trim() || '').slice(0, 100);
|
||||
if (group.type === 'private') {
|
||||
const members = await query(req.schema,
|
||||
'SELECT user_id FROM group_members WHERE group_id = $1', [req.params.groupId]
|
||||
);
|
||||
for (const m of members) {
|
||||
if (m.user_id === req.user.id) continue;
|
||||
sendPushToUser(req.schema, m.user_id, {
|
||||
title: senderName, body: msgBody, url: '/', groupId: group.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (group.type === 'public') {
|
||||
const subUsers = await query(req.schema,
|
||||
'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
|
||||
[req.user.id]
|
||||
);
|
||||
for (const sub of subUsers) {
|
||||
sendPushToUser(req.schema, sub.user_id, {
|
||||
title: `${senderName} in ${group.name}`, body: msgBody, url: '/', groupId: group.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
@@ -124,6 +151,31 @@ module.exports = function(io) {
|
||||
);
|
||||
message.reactions = [];
|
||||
io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message);
|
||||
|
||||
// Push notifications for image messages
|
||||
const senderName = message.user_display_name || message.user_name || 'Someone';
|
||||
if (group.type === 'private') {
|
||||
const members = await query(req.schema,
|
||||
'SELECT user_id FROM group_members WHERE group_id = $1', [req.params.groupId]
|
||||
);
|
||||
for (const m of members) {
|
||||
if (m.user_id === req.user.id) continue;
|
||||
sendPushToUser(req.schema, m.user_id, {
|
||||
title: senderName, body: '📷 Image', url: '/', groupId: group.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} else if (group.type === 'public') {
|
||||
const subUsers = await query(req.schema,
|
||||
'SELECT DISTINCT user_id FROM push_subscriptions WHERE (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL) AND user_id != $1',
|
||||
[req.user.id]
|
||||
);
|
||||
for (const sub of subUsers) {
|
||||
sendPushToUser(req.schema, sub.user_id, {
|
||||
title: `${senderName} in ${group.name}`, body: '📷 Image', url: '/', groupId: group.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ message });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ const router = express.Router();
|
||||
const { query, queryOne, exec } = require('../models/db');
|
||||
const { authMiddleware } = require('../middleware/auth');
|
||||
|
||||
// ── Firebase Admin ─────────────────────────────────────────────────────────────
|
||||
// ── Firebase Admin (FCM — Android/Chrome) ──────────────────────────────────────
|
||||
let firebaseAdmin = null;
|
||||
let firebaseApp = null;
|
||||
|
||||
@@ -25,39 +25,122 @@ function getMessaging() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── web-push (VAPID — iOS/Firefox/Edge) ────────────────────────────────────────
|
||||
let webPushReady = false;
|
||||
|
||||
function getWebPush() {
|
||||
if (webPushReady) return require('web-push');
|
||||
const pub = process.env.VAPID_PUBLIC;
|
||||
const priv = process.env.VAPID_PRIVATE;
|
||||
if (!pub || !priv) return null;
|
||||
try {
|
||||
const wp = require('web-push');
|
||||
// Subject must be mailto: or https:// — Apple returns 403 for any other format.
|
||||
const subject = process.env.VAPID_SUBJECT || 'mailto:push@rosterchirp.app';
|
||||
wp.setVapidDetails(subject, pub, priv);
|
||||
webPushReady = true;
|
||||
console.log('[Push] web-push (VAPID) initialised');
|
||||
return wp;
|
||||
} catch (e) {
|
||||
console.error('[Push] web-push init failed:', e.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Called from index.js socket push notifications
|
||||
// Called from messages.js (REST) and index.js (socket) for every outbound push.
|
||||
// Dispatches to FCM (fcm_token rows) or web-push (webpush_endpoint rows) based on
|
||||
// which columns are populated. Both paths run concurrently for a given user.
|
||||
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',
|
||||
`SELECT * FROM push_subscriptions
|
||||
WHERE user_id = $1
|
||||
AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`,
|
||||
[userId]
|
||||
);
|
||||
if (subs.length === 0) {
|
||||
console.log(`[Push] No subscription for user ${userId} (schema=${schema})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const messaging = getMessaging();
|
||||
const wp = getWebPush();
|
||||
|
||||
for (const sub of subs) {
|
||||
try {
|
||||
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) : '',
|
||||
if (sub.fcm_token) {
|
||||
// ── FCM path ──────────────────────────────────────────────────────────
|
||||
if (!messaging) continue;
|
||||
try {
|
||||
await messaging.send({
|
||||
token: sub.fcm_token,
|
||||
notification: {
|
||||
title: payload.title || 'New Message',
|
||||
body: payload.body || '',
|
||||
},
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
groupId: payload.groupId ? String(payload.groupId) : '',
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: { sound: 'default' },
|
||||
},
|
||||
apns: {
|
||||
headers: { 'apns-priority': '10' },
|
||||
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
|
||||
},
|
||||
webpush: {
|
||||
headers: { Urgency: 'high' },
|
||||
notification: {
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
tag: payload.groupId ? `rosterchirp-group-${payload.groupId}` : 'rosterchirp-message',
|
||||
renotify: true,
|
||||
},
|
||||
fcm_options: { link: payload.url || '/' },
|
||||
},
|
||||
});
|
||||
console.log(`[Push] FCM sent to user ${userId} device=${sub.device} schema=${schema}`);
|
||||
} catch (err) {
|
||||
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]);
|
||||
console.log(`[Push] Removed stale FCM token for user ${userId} device=${sub.device}`);
|
||||
}
|
||||
}
|
||||
} else if (sub.webpush_endpoint) {
|
||||
// ── Web Push / VAPID path (iOS, Firefox, Edge) ────────────────────────
|
||||
if (!wp) continue;
|
||||
const subscription = {
|
||||
endpoint: sub.webpush_endpoint,
|
||||
keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth },
|
||||
};
|
||||
const body = JSON.stringify({
|
||||
notification: {
|
||||
title: payload.title || 'New Message',
|
||||
body: payload.body || '',
|
||||
},
|
||||
data: {
|
||||
url: payload.url || '/',
|
||||
groupId: payload.groupId ? String(payload.groupId) : '',
|
||||
icon: '/icons/icon-192.png',
|
||||
},
|
||||
android: { priority: 'high' },
|
||||
webpush: { headers: { Urgency: 'high' } },
|
||||
});
|
||||
} catch (err) {
|
||||
// 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]);
|
||||
try {
|
||||
await wp.sendNotification(subscription, body, { TTL: 86400, urgency: 'high' });
|
||||
console.log(`[Push] WebPush sent to user ${userId} device=${sub.device} schema=${schema}`);
|
||||
} catch (err) {
|
||||
// 404/410 = subscription expired or user unsubscribed — remove the stale row
|
||||
if (err.statusCode === 404 || err.statusCode === 410) {
|
||||
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
|
||||
console.log(`[Push] Removed stale WebPush sub for user ${userId} device=${sub.device}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,13 +159,20 @@ router.get('/firebase-config', (req, res) => {
|
||||
const appId = process.env.FIREBASE_APP_ID;
|
||||
const vapidKey = process.env.FIREBASE_VAPID_KEY;
|
||||
|
||||
if (!apiKey || !projectId || !messagingSenderId || !appId) {
|
||||
if (!apiKey || !projectId || !messagingSenderId || !appId || !vapidKey) {
|
||||
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
|
||||
// Public — iOS frontend fetches this to create a PushManager subscription
|
||||
router.get('/vapid-public-key', (req, res) => {
|
||||
const pub = process.env.VAPID_PUBLIC;
|
||||
if (!pub) return res.status(503).json({ error: 'VAPID not configured' });
|
||||
res.json({ vapidPublicKey: pub });
|
||||
});
|
||||
|
||||
// Register / refresh an FCM token for the logged-in user (Android/Chrome)
|
||||
router.post('/subscribe', authMiddleware, async (req, res) => {
|
||||
const { fcmToken } = req.body;
|
||||
if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
|
||||
@@ -100,7 +190,29 @@ router.post('/subscribe', authMiddleware, async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Remove the FCM token for the logged-in user / device
|
||||
// Register / refresh a Web Push subscription for the logged-in user (iOS/Firefox/Edge)
|
||||
// Body: { endpoint, keys: { p256dh, auth } } — the PushSubscription JSON from the browser
|
||||
router.post('/subscribe-webpush', authMiddleware, async (req, res) => {
|
||||
const { endpoint, keys } = req.body;
|
||||
if (!endpoint || !keys?.p256dh || !keys?.auth) {
|
||||
return res.status(400).json({ error: 'endpoint and keys.p256dh/auth required' });
|
||||
}
|
||||
try {
|
||||
const device = req.device || 'mobile'; // iOS is always mobile
|
||||
await exec(req.schema,
|
||||
'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, webpush_endpoint, webpush_p256dh, webpush_auth)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[req.user.id, device, endpoint, keys.p256dh, keys.auth]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Remove the push subscription for the logged-in user / device
|
||||
router.post('/unsubscribe', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const device = req.device || 'desktop';
|
||||
@@ -112,4 +224,109 @@ router.post('/unsubscribe', authMiddleware, async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Send a test push to the requesting user's own devices.
|
||||
// Covers both FCM tokens and Web Push subscriptions in one call.
|
||||
// mode query param only applies to FCM test messages (notification vs browser).
|
||||
router.post('/test', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const subs = await query(req.schema,
|
||||
`SELECT * FROM push_subscriptions
|
||||
WHERE user_id = $1
|
||||
AND (fcm_token IS NOT NULL OR webpush_endpoint IS NOT NULL)`,
|
||||
[req.user.id]
|
||||
);
|
||||
if (subs.length === 0) {
|
||||
return res.status(404).json({
|
||||
error: 'No push subscription found. Grant notification permission and reload the app first.',
|
||||
});
|
||||
}
|
||||
|
||||
const messaging = getMessaging();
|
||||
const wp = getWebPush();
|
||||
const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
|
||||
const results = [];
|
||||
|
||||
for (const sub of subs) {
|
||||
if (sub.fcm_token) {
|
||||
if (!messaging) {
|
||||
results.push({ device: sub.device, type: 'fcm', status: 'failed', error: 'Firebase Admin not initialised — check FIREBASE_SERVICE_ACCOUNT in .env' });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const message = {
|
||||
token: sub.fcm_token,
|
||||
android: { priority: 'high', notification: { sound: 'default' } },
|
||||
apns: {
|
||||
headers: { 'apns-priority': '10' },
|
||||
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
|
||||
},
|
||||
webpush: {
|
||||
headers: { Urgency: 'high' },
|
||||
notification: { icon: '/icons/icon-192.png', badge: '/icons/icon-192-maskable.png', tag: 'rosterchirp-test' },
|
||||
},
|
||||
};
|
||||
if (mode === 'browser') {
|
||||
message.webpush.notification.title = 'RosterChirp Test (browser)';
|
||||
message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.';
|
||||
message.webpush.fcm_options = { link: '/' };
|
||||
} else {
|
||||
message.notification = { title: 'RosterChirp Test', body: 'Push notifications are working!' };
|
||||
message.data = { url: '/', groupId: '' };
|
||||
message.webpush.fcm_options = { link: '/' };
|
||||
}
|
||||
await messaging.send(message);
|
||||
results.push({ device: sub.device, type: 'fcm', mode, status: 'sent' });
|
||||
} catch (err) {
|
||||
results.push({ device: sub.device, type: 'fcm', mode, status: 'failed', error: err.message, code: err.code });
|
||||
}
|
||||
} else if (sub.webpush_endpoint) {
|
||||
if (!wp) {
|
||||
results.push({ device: sub.device, type: 'webpush', status: 'failed', error: 'VAPID keys not configured — check VAPID_PUBLIC/VAPID_PRIVATE in .env' });
|
||||
continue;
|
||||
}
|
||||
const subscription = {
|
||||
endpoint: sub.webpush_endpoint,
|
||||
keys: { p256dh: sub.webpush_p256dh, auth: sub.webpush_auth },
|
||||
};
|
||||
try {
|
||||
await wp.sendNotification(
|
||||
subscription,
|
||||
JSON.stringify({
|
||||
notification: { title: 'RosterChirp Test', body: 'Push notifications are working!' },
|
||||
data: { url: '/', icon: '/icons/icon-192.png' },
|
||||
}),
|
||||
{ TTL: 300, urgency: 'high' }
|
||||
);
|
||||
results.push({ device: sub.device, type: 'webpush', status: 'sent' });
|
||||
} catch (err) {
|
||||
results.push({ device: sub.device, type: 'webpush', status: 'failed', error: err.message, statusCode: err.statusCode });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Debug endpoint (admin-only) — lists all push subscriptions for this schema
|
||||
router.get('/debug', authMiddleware, async (req, res) => {
|
||||
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
|
||||
try {
|
||||
const subs = await query(req.schema, `
|
||||
SELECT ps.id, ps.user_id, ps.device,
|
||||
ps.fcm_token,
|
||||
ps.webpush_endpoint,
|
||||
u.name, u.email
|
||||
FROM push_subscriptions ps
|
||||
JOIN users u ON u.id = ps.user_id
|
||||
WHERE ps.fcm_token IS NOT NULL OR ps.webpush_endpoint IS NOT NULL
|
||||
ORDER BY u.name, ps.device
|
||||
`);
|
||||
const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY);
|
||||
const firebaseAdminReady = !!getMessaging();
|
||||
const vapidConfigured = !!(process.env.VAPID_PUBLIC && process.env.VAPID_PRIVATE);
|
||||
res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady, vapidConfigured });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = { router, sendPushToUser };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { query, queryOne, queryResult, exec } = require('../models/db');
|
||||
const { query, queryOne, queryResult, exec, withTransaction } = require('../models/db');
|
||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
const multer = require('multer');
|
||||
const { parse: csvParse } = require('csv-parse/sync');
|
||||
@@ -15,37 +15,32 @@ const router = express.Router();
|
||||
// Posts a plain system message to each assigned user group's DM channel
|
||||
// when an event is created or updated.
|
||||
|
||||
async function postEventNotification(schema, eventId, actorId, isUpdate) {
|
||||
async function sendEventMessage(schema, dmGroupId, actorId, content) {
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[dmGroupId, actorId, content]
|
||||
);
|
||||
const msg = await queryOne(schema, `
|
||||
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name,
|
||||
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
|
||||
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
|
||||
`, [r.rows[0].id]);
|
||||
if (msg) { msg.reactions = []; io.to(R(schema, 'group', dmGroupId)).emit('message:new', msg); }
|
||||
}
|
||||
|
||||
async function postEventNotification(schema, eventId, actorId) {
|
||||
try {
|
||||
const event = await queryOne(schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event) return;
|
||||
|
||||
const dateStr = new Date(event.start_at).toLocaleDateString('en-US', {
|
||||
weekday: 'short', month: 'short', day: 'numeric',
|
||||
});
|
||||
const verb = isUpdate ? 'updated' : 'added';
|
||||
const content = `📅 Event ${verb}: "${event.title}" on ${dateStr}`;
|
||||
|
||||
const dateStr = new Date(event.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
|
||||
const groups = await query(schema, `
|
||||
SELECT ug.dm_group_id
|
||||
FROM event_user_groups eug
|
||||
SELECT ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id = eug.user_group_id
|
||||
WHERE eug.event_id = $1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [eventId]);
|
||||
|
||||
for (const { dm_group_id } of groups) {
|
||||
const r = await queryResult(schema,
|
||||
"INSERT INTO messages (group_id,user_id,content,type) VALUES ($1,$2,$3,'system') RETURNING id",
|
||||
[dm_group_id, actorId, content]
|
||||
);
|
||||
const msg = await queryOne(schema, `
|
||||
SELECT m.*, u.name AS user_name, u.display_name AS user_display_name,
|
||||
u.avatar AS user_avatar, u.role AS user_role, u.status AS user_status,
|
||||
u.hide_admin_tag AS user_hide_admin_tag, u.about_me AS user_about_me, u.allow_dm AS user_allow_dm
|
||||
FROM messages m JOIN users u ON m.user_id = u.id WHERE m.id = $1
|
||||
`, [r.rows[0].id]);
|
||||
if (msg) { msg.reactions = []; io.to(R(schema, 'group', dm_group_id)).emit('message:new', msg); }
|
||||
}
|
||||
for (const { dm_group_id } of groups)
|
||||
await sendEventMessage(schema, dm_group_id, actorId, `📅 Event added: "${event.title}" on ${dateStr}`);
|
||||
} catch (e) {
|
||||
console.error('[Schedule] postEventNotification error:', e.message);
|
||||
}
|
||||
@@ -53,8 +48,16 @@ async function postEventNotification(schema, eventId, actorId, isUpdate) {
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function getPartnerId(schema, userId) {
|
||||
const row = await queryOne(schema,
|
||||
'SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1',
|
||||
[userId]
|
||||
);
|
||||
return row?.partner_id || null;
|
||||
}
|
||||
|
||||
async function isToolManagerFn(schema, user) {
|
||||
if (user.role === 'admin') return true;
|
||||
if (user.role === 'admin' || user.role === 'manager') return true;
|
||||
const tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
|
||||
const gm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_group_managers'");
|
||||
const groupIds = [...new Set([...JSON.parse(tm?.value||'[]'), ...JSON.parse(gm?.value||'[]')])];
|
||||
@@ -70,7 +73,33 @@ async function canViewEvent(schema, event, userId, isToolManager) {
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, userId]);
|
||||
return !!assigned;
|
||||
if (assigned) return true;
|
||||
// Also allow if user has an alias in one of the event's user groups (Guardian Only mode)
|
||||
const aliasAssigned = await queryOne(schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, userId]);
|
||||
if (aliasAssigned) return true;
|
||||
// Allow if partner is assigned to the event (directly or via alias)
|
||||
const partnerId = await getPartnerId(schema, userId);
|
||||
if (partnerId) {
|
||||
const partnerAssigned = await queryOne(schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]);
|
||||
if (partnerAssigned) return true;
|
||||
const partnerAliasAssigned = await queryOne(schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, partnerId]);
|
||||
if (partnerAliasAssigned) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function enrichEvent(schema, event) {
|
||||
@@ -177,6 +206,20 @@ router.delete('/event-types/:id', authMiddleware, teamManagerMiddleware, async (
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── User's own groups (for regular users creating events) ─────────────────────
|
||||
|
||||
router.get('/my-groups', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const groups = await query(req.schema, `
|
||||
SELECT ug.id, ug.name FROM user_groups ug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id = ug.id
|
||||
WHERE ugm.user_id = $1
|
||||
ORDER BY ug.name ASC
|
||||
`, [req.user.id]);
|
||||
res.json({ groups });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
router.get('/', authMiddleware, async (req, res) => {
|
||||
@@ -226,94 +269,435 @@ router.get('/:id', authMiddleware, async (req, res) => {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
|
||||
await enrichEvent(req.schema, event);
|
||||
if (event.track_availability && itm) {
|
||||
event.availability = await query(req.schema, `
|
||||
SELECT ea.response, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar
|
||||
const partnerId = await getPartnerId(req.schema, req.user.id);
|
||||
const isMember = !itm && !!(
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, req.user.id]))
|
||||
||
|
||||
// Guardian Only: user has an alias in one of the event's user groups
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, req.user.id]))
|
||||
||
|
||||
// Partner is assigned to this event (user group or alias)
|
||||
(partnerId && !!(
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]))
|
||||
||
|
||||
(await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1 AND ga.guardian_id=$2
|
||||
`, [event.id, partnerId]))
|
||||
))
|
||||
);
|
||||
if (event.track_availability && (itm || isMember)) {
|
||||
// User responses
|
||||
const userAvail = await query(req.schema, `
|
||||
SELECT ea.response, ea.note, ea.updated_at, u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name, u.avatar, FALSE AS is_alias
|
||||
FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
|
||||
`, [req.params.id]);
|
||||
const assignedIds = (await query(req.schema, `
|
||||
SELECT DISTINCT ugm.user_id FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id WHERE eug.event_id=$1
|
||||
`, [req.params.id])).map(r => r.user_id);
|
||||
const respondedIds = new Set(event.availability.map(r => r.user_id));
|
||||
event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length;
|
||||
// Alias responses (Guardian Only mode)
|
||||
const aliasAvail = await query(req.schema, `
|
||||
SELECT eaa.response, eaa.note, eaa.updated_at, ga.id AS alias_id, ga.first_name, ga.last_name, ga.avatar, ga.guardian_id, TRUE AS is_alias
|
||||
FROM event_alias_availability eaa JOIN guardian_aliases ga ON ga.id=eaa.alias_id WHERE eaa.event_id=$1
|
||||
`, [req.params.id]);
|
||||
event.availability = [...userAvail, ...aliasAvail];
|
||||
|
||||
// For non-tool-managers: mask notes on entries that don't belong to them or their aliases
|
||||
if (!itm) {
|
||||
const myAliasIds = new Set(
|
||||
(await query(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)`,
|
||||
[req.user.id])).map(r => r.id)
|
||||
);
|
||||
event.availability = event.availability.map(r => {
|
||||
const isOwn = !r.is_alias && r.user_id === req.user.id;
|
||||
const isOwnAlias = r.is_alias && myAliasIds.has(r.alias_id);
|
||||
return (isOwn || isOwnAlias) ? r : { ...r, note: null };
|
||||
});
|
||||
}
|
||||
|
||||
if (itm) {
|
||||
const assignedRows = await query(req.schema, `
|
||||
SELECT DISTINCT u.id AS user_id, u.name, u.first_name, u.last_name, u.display_name
|
||||
FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
JOIN users u ON u.id=ugm.user_id
|
||||
WHERE eug.event_id=$1
|
||||
`, [req.params.id]);
|
||||
// Also include alias members
|
||||
const assignedAliases = await query(req.schema, `
|
||||
SELECT DISTINCT ga.id AS alias_id, ga.first_name, ga.last_name
|
||||
FROM event_user_groups eug
|
||||
JOIN alias_group_members agm ON agm.user_group_id=eug.user_group_id
|
||||
JOIN guardian_aliases ga ON ga.id=agm.alias_id
|
||||
WHERE eug.event_id=$1
|
||||
`, [req.params.id]);
|
||||
const respondedUserIds = new Set(userAvail.map(r => r.user_id));
|
||||
const respondedAliasIds = new Set(aliasAvail.map(r => r.alias_id));
|
||||
const noResponseRows = [
|
||||
...assignedRows.filter(r => !respondedUserIds.has(r.user_id)),
|
||||
...assignedAliases.filter(r => !respondedAliasIds.has(r.alias_id)).map(r => ({ ...r, is_alias: true })),
|
||||
];
|
||||
event.no_response_count = noResponseRows.length;
|
||||
event.no_response_users = noResponseRows;
|
||||
}
|
||||
|
||||
// Detect if event targets the players group (for responder select dropdown)
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
event.has_players_group = !!(playersGroupId && event.user_groups?.some(g => g.id === playersGroupId));
|
||||
|
||||
// Detect if event targets the guardians group (so guardian shows own name in select)
|
||||
const guardiansRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_guardians_group_id'");
|
||||
const guardiansGroupId = parseInt(guardiansRow?.value);
|
||||
event.in_guardians_group = !!(guardiansGroupId && event.user_groups?.some(g => g.id === guardiansGroupId) &&
|
||||
(
|
||||
(await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, req.user.id]))
|
||||
||
|
||||
(partnerId && await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [guardiansGroupId, partnerId]))
|
||||
));
|
||||
|
||||
// Return current user's aliases (and partner's) for the responder dropdown (Guardian Only)
|
||||
if (event.has_players_group) {
|
||||
event.my_aliases = await query(req.schema,
|
||||
`SELECT id,first_name,last_name,avatar FROM guardian_aliases
|
||||
WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)
|
||||
ORDER BY first_name,last_name`,
|
||||
[req.user.id]
|
||||
);
|
||||
}
|
||||
|
||||
// Return partner user info if they are in one of this event's user groups
|
||||
if (partnerId) {
|
||||
const partnerInGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug
|
||||
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, partnerId]);
|
||||
if (partnerInGroup) {
|
||||
const pUser = await queryOne(req.schema, 'SELECT id,name,display_name,avatar FROM users WHERE id=$1', [partnerId]);
|
||||
const pGp = await queryOne(req.schema,
|
||||
'SELECT respond_separately FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||
[Math.min(req.user.id, partnerId), Math.max(req.user.id, partnerId)]
|
||||
);
|
||||
event.my_partner = pUser ? { ...pUser, respond_separately: pGp?.respond_separately || false } : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
const mine = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
const mine = await queryOne(req.schema, 'SELECT response, note FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
event.my_response = mine?.response || null;
|
||||
event.my_note = mine?.note || null;
|
||||
res.json({ event });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
router.post('/', authMiddleware, async (req, res) => {
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds=[], recurrenceRule } = req.body;
|
||||
if (!title?.trim()) return res.status(400).json({ error: 'Title required' });
|
||||
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
|
||||
try {
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const groupIds = Array.isArray(userGroupIds) ? userGroupIds : [];
|
||||
if (!itm) {
|
||||
// Regular users: must select at least one group they belong to; event always private
|
||||
if (!groupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||
for (const ugId of groupIds) {
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||
}
|
||||
}
|
||||
const effectiveIsPublic = itm ? (isPublic !== false) : false;
|
||||
const r = await queryResult(req.schema, `
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [title.trim(), eventTypeId||null, startAt, endAt, !!allDay, location||null, description||null,
|
||||
isPublic!==false, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
effectiveIsPublic, !!trackAvailability, recurrenceRule||null, req.user.id]);
|
||||
const eventId = r.rows[0].id;
|
||||
for (const ugId of (Array.isArray(userGroupIds) ? userGroupIds : []))
|
||||
for (const ugId of groupIds)
|
||||
await exec(req.schema, 'INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [eventId, ugId]);
|
||||
if (Array.isArray(userGroupIds) && userGroupIds.length > 0)
|
||||
await postEventNotification(req.schema, eventId, req.user.id, false);
|
||||
if (groupIds.length > 0)
|
||||
await postEventNotification(req.schema, eventId, req.user.id);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
res.json({ event: await enrichEvent(req.schema, event) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
router.patch('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope } = req.body;
|
||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||
|
||||
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds);
|
||||
|
||||
// Recurring future scope — update all future occurrences
|
||||
if (recurringScope === 'future' && event.recurrence_rule) {
|
||||
const futureEvents = await query(req.schema, `
|
||||
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL
|
||||
AND start_at >= $3 AND title=$4
|
||||
`, [req.params.id, event.created_by, event.start_at, event.title]);
|
||||
for (const fe of futureEvents)
|
||||
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds);
|
||||
}
|
||||
|
||||
// Clean up availability for users removed from groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||
for (const removedGid of removedGroupIds) {
|
||||
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
|
||||
for (const uid of removedUids) {
|
||||
if (newGroupSet.size > 0) {
|
||||
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
|
||||
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
|
||||
if (stillAssigned) continue;
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, uid]);
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
let { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, userGroupIds, recurrenceRule, recurringScope, occurrenceStart } = req.body;
|
||||
if (!itm) {
|
||||
// Regular users editing their own event: force private, validate group membership
|
||||
isPublic = false;
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' });
|
||||
for (const ugId of userGroupIds) {
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, ugId]);
|
||||
if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
|
||||
}
|
||||
// Preserve any existing groups on this event that the user doesn't belong to
|
||||
// (e.g. groups added by an admin) — silently merge them back into the submitted list
|
||||
const existingGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
const submittedSet = new Set(userGroupIds.map(Number));
|
||||
for (const gid of existingGroupIds) {
|
||||
if (submittedSet.has(gid)) continue;
|
||||
const member = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [req.user.id, gid]);
|
||||
if (!member) userGroupIds.push(gid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
const finalGroups = await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id]);
|
||||
if (finalGroups.length > 0)
|
||||
await postEventNotification(req.schema, req.params.id, req.user.id, true);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
|
||||
|
||||
// Resolve group list for new-event paths (exception instance / future split)
|
||||
// Pre-fetched before any transaction so it uses the regular pool connection
|
||||
const resolvedGroupIds = Array.isArray(userGroupIds)
|
||||
? userGroupIds
|
||||
: (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id])).map(r => r.user_group_id);
|
||||
|
||||
// ── Capture prev group/DM mapping before any mutations ────────────────────
|
||||
const prevGroupRows = await query(req.schema, `
|
||||
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [req.params.id]);
|
||||
const prevGroupIdSet = new Set(prevGroupRows.map(r => r.user_group_id));
|
||||
|
||||
let targetId = Number(req.params.id); // ID of the event to return in the response
|
||||
|
||||
if (event.recurrence_rule && recurringScope === 'this') {
|
||||
// ── EXCEPTION INSTANCE ────────────────────────────────────────────────
|
||||
// 1. Add occurrence date to master's exceptions (hides the virtual occurrence)
|
||||
// 2. INSERT a new standalone event row for this modified occurrence
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
const occDateStr = `${occDate.getFullYear()}-${pad(occDate.getMonth()+1)}-${pad(occDate.getDate())}`;
|
||||
await withTransaction(req.schema, async (client) => {
|
||||
const rule = { ...event.recurrence_rule };
|
||||
const existing = Array.isArray(rule.exceptions) ? rule.exceptions : [];
|
||||
rule.exceptions = [...existing.filter(d => d !== occDateStr), occDateStr];
|
||||
await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
|
||||
|
||||
const r2 = await client.query(`
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,created_by,recurring_master_id,original_start_at)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING id
|
||||
`, [
|
||||
title?.trim() || event.title,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
||||
startAt || occurrenceStart || event.start_at,
|
||||
endAt || event.end_at,
|
||||
allDay !== undefined ? allDay : event.all_day,
|
||||
location !== undefined ? (location || null) : event.location,
|
||||
description !== undefined ? (description || null) : event.description,
|
||||
isPublic !== undefined ? isPublic : event.is_public,
|
||||
trackAvailability !== undefined ? trackAvailability : event.track_availability,
|
||||
event.created_by,
|
||||
event.id,
|
||||
occurrenceStart || event.start_at,
|
||||
]);
|
||||
targetId = r2.rows[0].id;
|
||||
for (const ugId of resolvedGroupIds)
|
||||
await client.query('INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [targetId, ugId]);
|
||||
});
|
||||
|
||||
// Notify: "Event updated" for the occurrence date
|
||||
try {
|
||||
const exceptionGroupRows = await query(req.schema, `
|
||||
SELECT ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [targetId]);
|
||||
const dateStr = new Date(startAt || occurrenceStart || event.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
|
||||
const timeChanged = startAt && new Date(startAt).getTime() !== occDate.getTime();
|
||||
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
|
||||
if (timeChanged) {
|
||||
for (const { dm_group_id } of exceptionGroupRows)
|
||||
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event updated: "${title?.trim() || event.title}" on ${dateStr}`);
|
||||
}
|
||||
if (locationChanged) {
|
||||
const locMsg = location ? `📍 Location updated to "${location}": "${title?.trim() || event.title}" on ${dateStr}` : `📍 Location removed: "${title?.trim() || event.title}" on ${dateStr}`;
|
||||
for (const { dm_group_id } of exceptionGroupRows)
|
||||
await sendEventMessage(req.schema, dm_group_id, req.user.id, locMsg);
|
||||
}
|
||||
} catch (e) { console.error('[Schedule] exception notification error:', e.message); }
|
||||
|
||||
} else if (event.recurrence_rule && recurringScope === 'future') {
|
||||
// ── SERIES SPLIT ──────────────────────────────────────────────────────
|
||||
// Truncate old master to end before this occurrence; INSERT new master starting here
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
if (occDate <= new Date(event.start_at)) {
|
||||
// Splitting at/before the first occurrence = effectively "edit all"
|
||||
await applyEventUpdate(req.schema, event.id, fields, userGroupIds);
|
||||
targetId = event.id;
|
||||
} else {
|
||||
await withTransaction(req.schema, async (client) => {
|
||||
// 1. Truncate old master
|
||||
const endBefore = new Date(occDate);
|
||||
endBefore.setDate(endBefore.getDate() - 1);
|
||||
const rule = { ...event.recurrence_rule };
|
||||
rule.ends = 'on';
|
||||
rule.endDate = `${endBefore.getFullYear()}-${pad(endBefore.getMonth()+1)}-${pad(endBefore.getDate())}`;
|
||||
delete rule.endCount;
|
||||
await client.query('UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), event.id]);
|
||||
|
||||
// 2. INSERT new master with submitted fields
|
||||
const newRecRule = recurrenceRule !== undefined ? recurrenceRule : event.recurrence_rule;
|
||||
const r2 = await client.query(`
|
||||
INSERT INTO events (title,event_type_id,start_at,end_at,all_day,location,description,is_public,track_availability,recurrence_rule,created_by)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING id
|
||||
`, [
|
||||
title?.trim() || event.title,
|
||||
eventTypeId !== undefined ? (eventTypeId || null) : event.event_type_id,
|
||||
startAt || (occurrenceStart || event.start_at),
|
||||
endAt || event.end_at,
|
||||
allDay !== undefined ? allDay : event.all_day,
|
||||
location !== undefined ? (location || null) : event.location,
|
||||
description !== undefined ? (description || null) : event.description,
|
||||
isPublic !== undefined ? isPublic : event.is_public,
|
||||
trackAvailability !== undefined ? trackAvailability : event.track_availability,
|
||||
newRecRule ? JSON.stringify(newRecRule) : null,
|
||||
event.created_by,
|
||||
]);
|
||||
targetId = r2.rows[0].id;
|
||||
for (const ugId of resolvedGroupIds)
|
||||
await client.query('INSERT INTO event_user_groups (event_id,user_group_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [targetId, ugId]);
|
||||
});
|
||||
await postEventNotification(req.schema, targetId, req.user.id);
|
||||
}
|
||||
|
||||
} else {
|
||||
// ── EDIT ALL (or non-recurring direct edit) ───────────────────────────
|
||||
await applyEventUpdate(req.schema, event.id, fields, userGroupIds);
|
||||
targetId = event.id;
|
||||
|
||||
// Clean up availability for users removed from groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
const prevGroupIds = (await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [event.id])).map(r => r.user_group_id);
|
||||
const newGroupSet = new Set(userGroupIds.map(Number));
|
||||
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id));
|
||||
for (const removedGid of removedGroupIds) {
|
||||
const removedUids = (await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [removedGid])).map(r => r.user_id);
|
||||
for (const uid of removedUids) {
|
||||
if (newGroupSet.size > 0) {
|
||||
const ph = [...newGroupSet].map((_,i) => `$${i+2}`).join(',');
|
||||
const stillAssigned = await queryOne(req.schema, `SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id IN (${ph})`, [uid, ...[...newGroupSet]]);
|
||||
if (stillAssigned) continue;
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [event.id, uid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Targeted notifications — only for meaningful changes, only to relevant groups
|
||||
try {
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [event.id]);
|
||||
const finalGroupRows = await query(req.schema, `
|
||||
SELECT eug.user_group_id, ug.dm_group_id FROM event_user_groups eug
|
||||
JOIN user_groups ug ON ug.id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ug.dm_group_id IS NOT NULL
|
||||
`, [event.id]);
|
||||
const allDmIds = finalGroupRows.map(r => r.dm_group_id);
|
||||
const dateStr = new Date(updated.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
|
||||
|
||||
// Newly added groups → "Event added" only to those groups
|
||||
if (Array.isArray(userGroupIds)) {
|
||||
for (const { user_group_id, dm_group_id } of finalGroupRows) {
|
||||
if (!prevGroupIdSet.has(user_group_id))
|
||||
await sendEventMessage(req.schema, dm_group_id, req.user.id, `📅 Event added: "${updated.title}" on ${dateStr}`);
|
||||
}
|
||||
}
|
||||
// Date/time changed → "Event updated" to all groups
|
||||
const timeChanged = (startAt && new Date(startAt).getTime() !== new Date(event.start_at).getTime())
|
||||
|| (endAt && new Date(endAt).getTime() !== new Date(event.end_at).getTime())
|
||||
|| (allDay !== undefined && !!allDay !== !!event.all_day);
|
||||
if (timeChanged) {
|
||||
for (const dmId of allDmIds)
|
||||
await sendEventMessage(req.schema, dmId, req.user.id, `📅 Event updated: "${updated.title}" on ${dateStr}`);
|
||||
}
|
||||
// Location changed → "Location updated" to all groups
|
||||
const locationChanged = location !== undefined && (location || null) !== (event.location || null);
|
||||
if (locationChanged) {
|
||||
const locContent = updated.location
|
||||
? `📍 Location updated to "${updated.location}": "${updated.title}" on ${dateStr}`
|
||||
: `📍 Location removed: "${updated.title}" on ${dateStr}`;
|
||||
for (const dmId of allDmIds)
|
||||
await sendEventMessage(req.schema, dmId, req.user.id, locContent);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Schedule] event update notification error:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [targetId]);
|
||||
res.json({ event: await enrichEvent(req.schema, updated) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id])))
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
if (!itm && event.created_by !== req.user.id) return res.status(403).json({ error: 'Access denied' });
|
||||
const { recurringScope, occurrenceStart } = req.body || {};
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
|
||||
if (event.recurrence_rule && recurringScope === 'all') {
|
||||
// Delete the single base row — all virtual occurrences disappear with it
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
|
||||
} else if (event.recurrence_rule && recurringScope === 'future') {
|
||||
// Truncate the series so it ends before this occurrence
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
if (occDate <= new Date(event.start_at)) {
|
||||
// Occurrence is at or before the base start — delete the whole series
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
} else {
|
||||
const endBefore = new Date(occDate);
|
||||
endBefore.setDate(endBefore.getDate() - 1);
|
||||
const rule = { ...event.recurrence_rule };
|
||||
rule.ends = 'on';
|
||||
rule.endDate = `${endBefore.getFullYear()}-${pad(endBefore.getMonth()+1)}-${pad(endBefore.getDate())}`;
|
||||
delete rule.endCount;
|
||||
await exec(req.schema, 'UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), req.params.id]);
|
||||
}
|
||||
|
||||
} else if (event.recurrence_rule && recurringScope === 'this') {
|
||||
// Add occurrence date to exceptions — base row and other occurrences are untouched
|
||||
const occDate = new Date(occurrenceStart || event.start_at);
|
||||
const occDateStr = `${occDate.getFullYear()}-${pad(occDate.getMonth()+1)}-${pad(occDate.getDate())}`;
|
||||
const rule = { ...event.recurrence_rule };
|
||||
const existing = Array.isArray(rule.exceptions) ? rule.exceptions : [];
|
||||
rule.exceptions = [...existing.filter(d => d !== occDateStr), occDateStr];
|
||||
await exec(req.schema, 'UPDATE events SET recurrence_rule=$1 WHERE id=$2', [JSON.stringify(rule), req.params.id]);
|
||||
|
||||
} else {
|
||||
// Non-recurring single delete
|
||||
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
@@ -325,25 +709,94 @@ router.put('/:id/availability', authMiddleware, async (req, res) => {
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
|
||||
if (!event) return res.status(404).json({ error: 'Not found' });
|
||||
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' });
|
||||
const { response } = req.body;
|
||||
const { response, note, aliasId, forPartnerId } = req.body;
|
||||
if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, req.user.id]);
|
||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW()
|
||||
`, [event.id, req.user.id, response]);
|
||||
res.json({ success: true, response });
|
||||
const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
|
||||
|
||||
if (forPartnerId) {
|
||||
// Respond on behalf of partner — verify partnership and partner's group membership
|
||||
const isPartner = await queryOne(req.schema,
|
||||
'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||
[req.user.id, forPartnerId]);
|
||||
if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
|
||||
const partnerInGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [event.id, forPartnerId]);
|
||||
if (!partnerInGroup) return res.status(403).json({ error: 'Partner is not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, forPartnerId, response, trimmedNote]);
|
||||
return res.json({ success: true, response, note: trimmedNote });
|
||||
}
|
||||
|
||||
if (aliasId) {
|
||||
// Alias response (Guardian Only mode) — verify alias belongs to current user or their partner
|
||||
const alias = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_alias_availability (event_id,alias_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,alias_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, aliasId, response, trimmedNote]);
|
||||
} else {
|
||||
// Regular user response — also allowed if partner is in the event's group
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const avPartner = await getPartnerId(req.schema, req.user.id);
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||
`, [event.id, req.user.id, avPartner || -1]);
|
||||
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' });
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,note,updated_at) VALUES ($1,$2,$3,$4,NOW())
|
||||
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, note=$4, updated_at=NOW()
|
||||
`, [event.id, req.user.id, response, trimmedNote]);
|
||||
}
|
||||
res.json({ success: true, response, note: trimmedNote });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/:id/availability/note', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT response FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'No availability response found' });
|
||||
const trimmedNote = req.body.note ? String(req.body.note).trim().slice(0, 20) : null;
|
||||
await exec(req.schema, 'UPDATE event_availability SET note=$1, updated_at=NOW() WHERE event_id=$2 AND user_id=$3', [trimmedNote, req.params.id, req.user.id]);
|
||||
res.json({ success: true, note: trimmedNote });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.delete('/:id/availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
const { aliasId, forPartnerId } = req.query;
|
||||
if (forPartnerId) {
|
||||
const isPartner = await queryOne(req.schema,
|
||||
'SELECT 1 FROM guardian_partners WHERE (user_id_1=$1 AND user_id_2=$2) OR (user_id_1=$2 AND user_id_2=$1)',
|
||||
[req.user.id, forPartnerId]);
|
||||
if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, forPartnerId]);
|
||||
} else if (aliasId) {
|
||||
const alias = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!alias) return res.status(403).json({ error: 'Alias not found or not yours' });
|
||||
await exec(req.schema, 'DELETE FROM event_alias_availability WHERE event_id=$1 AND alias_id=$2', [req.params.id, aliasId]);
|
||||
} else {
|
||||
await exec(req.schema, 'DELETE FROM event_availability WHERE event_id=$1 AND user_id=$2', [req.params.id, req.user.id]);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
@@ -354,14 +807,15 @@ router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
let saved = 0;
|
||||
const itm = await isToolManagerFn(req.schema, req.user);
|
||||
const bulkPartnerId = await getPartnerId(req.schema, req.user.id);
|
||||
for (const { eventId, response } of responses) {
|
||||
if (!['going','maybe','not_going'].includes(response)) continue;
|
||||
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
|
||||
if (!event || !event.track_availability) continue;
|
||||
const inGroup = await queryOne(req.schema, `
|
||||
SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
|
||||
WHERE eug.event_id=$1 AND ugm.user_id=$2
|
||||
`, [eventId, req.user.id]);
|
||||
WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
|
||||
`, [eventId, req.user.id, bulkPartnerId || -1]);
|
||||
if (!inGroup && !itm) continue;
|
||||
await exec(req.schema, `
|
||||
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW())
|
||||
|
||||
@@ -39,14 +39,16 @@ router.get('/', async (req, res) => {
|
||||
if (admin) obj.admin_email = admin.email;
|
||||
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.
|
||||
// Tell the frontend whether this request came from the host control panel subdomain.
|
||||
// Used to show/hide the Control Panel menu item — only visible on the host's own subdomain.
|
||||
const reqHost = (req.headers.host || '').toLowerCase().split(':')[0];
|
||||
const hostDomain = (process.env.HOST_DOMAIN || '').toLowerCase();
|
||||
const appDomain = (process.env.APP_DOMAIN || '').toLowerCase();
|
||||
const hostSlug = (process.env.HOST_SLUG || 'host').toLowerCase();
|
||||
const hostControlDomain = appDomain ? `${hostSlug}.${appDomain}` : '';
|
||||
obj.is_host_domain = (
|
||||
process.env.APP_TYPE === 'host' &&
|
||||
!!hostDomain &&
|
||||
(reqHost === hostDomain || reqHost === `www.${hostDomain}` || reqHost === 'localhost')
|
||||
!!hostControlDomain &&
|
||||
(reqHost === hostControlDomain || reqHost === 'localhost')
|
||||
) ? 'true' : 'false';
|
||||
res.json({ settings: obj });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
@@ -141,6 +143,37 @@ router.post('/register', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/messages', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { msgPublic, msgGroup, msgPrivateGroup, msgU2U } = req.body;
|
||||
try {
|
||||
if (msgPublic !== undefined) await setSetting(req.schema, 'feature_msg_public', msgPublic ? 'true' : 'false');
|
||||
if (msgGroup !== undefined) await setSetting(req.schema, 'feature_msg_group', msgGroup ? 'true' : 'false');
|
||||
if (msgPrivateGroup !== undefined) await setSetting(req.schema, 'feature_msg_private_group', msgPrivateGroup ? 'true' : 'false');
|
||||
if (msgU2U !== undefined) await setSetting(req.schema, 'feature_msg_u2u', msgU2U ? 'true' : 'false');
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
const VALID_LOGIN_TYPES = ['all_ages', 'guardian_only', 'mixed_age'];
|
||||
|
||||
router.patch('/login-type', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { loginType, playersGroupId, guardiansGroupId } = req.body;
|
||||
if (!VALID_LOGIN_TYPES.includes(loginType)) return res.status(400).json({ error: 'Invalid login type' });
|
||||
try {
|
||||
// Enforce: can only change when no non-admin users exist, UNLESS staying on same value
|
||||
const existing = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
const current = existing?.value || 'all_ages';
|
||||
if (loginType !== current) {
|
||||
const { count } = await queryOne(req.schema, "SELECT COUNT(*)::int AS count FROM users WHERE role != 'admin' AND status != 'deleted'");
|
||||
if (count > 0) return res.status(400).json({ error: 'Login Type can only be changed when no non-admin users exist.' });
|
||||
}
|
||||
await setSetting(req.schema, 'feature_login_type', loginType);
|
||||
await setSetting(req.schema, 'feature_players_group_id', playersGroupId != null ? String(playersGroupId) : '');
|
||||
await setSetting(req.schema, 'feature_guardians_group_id', guardiansGroupId != null ? String(guardiansGroupId) : '');
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { toolManagers } = req.body;
|
||||
try {
|
||||
|
||||
@@ -156,8 +156,8 @@ router.patch('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (re
|
||||
`, [mg.id, uid]);
|
||||
if (!stillIn) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
|
||||
io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',mg.dm_group_id));
|
||||
io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
|
||||
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
}
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `A group has been removed from this conversation.`);
|
||||
@@ -175,7 +175,7 @@ router.delete('/multigroup/:id', authMiddleware, teamManagerMiddleware, async (r
|
||||
if (mg.dm_group_id) {
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [mg.dm_group_id])).map(r => r.user_id);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [mg.dm_group_id]);
|
||||
for (const uid of members) io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
for (const uid of members) io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM multi_group_dms WHERE id=$1', [mg.id]);
|
||||
res.json({ success: true });
|
||||
@@ -193,6 +193,14 @@ router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /byuser/:userId — user group IDs for a specific user
|
||||
router.get('/byuser/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const rows = await query(req.schema, 'SELECT user_group_id FROM user_group_members WHERE user_id=$1', [req.params.userId]);
|
||||
res.json({ groupIds: rows.map(r => r.user_group_id) });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// GET /:id
|
||||
router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
@@ -203,23 +211,36 @@ router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id
|
||||
WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
|
||||
`, [req.params.id]);
|
||||
res.json({ group, members });
|
||||
const aliasMembers = await query(req.schema, `
|
||||
SELECT ga.id, ga.first_name, ga.last_name,
|
||||
ga.first_name || ' ' || ga.last_name AS name,
|
||||
ga.guardian_id, ga.avatar, ga.date_of_birth
|
||||
FROM alias_group_members agm
|
||||
JOIN guardian_aliases ga ON ga.id = agm.alias_id
|
||||
WHERE agm.user_group_id=$1
|
||||
ORDER BY ga.first_name, ga.last_name ASC
|
||||
`, [req.params.id]);
|
||||
res.json({ group, members, aliasMembers });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// POST / — create user group
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, memberIds = [] } = req.body;
|
||||
const { name, memberIds = [], noDm = false } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema, 'SELECT id FROM user_groups WHERE LOWER(name)=LOWER($1)', [name.trim()]);
|
||||
if (existing) return res.status(400).json({ error: 'Name already in use' });
|
||||
// Create the managed DM group
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||
[name.trim()]
|
||||
);
|
||||
const dmGroupId = gr.rows[0].id;
|
||||
|
||||
let dmGroupId = null;
|
||||
if (!noDm) {
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||
[name.trim()]
|
||||
);
|
||||
dmGroupId = gr.rows[0].id;
|
||||
}
|
||||
|
||||
const ugr = await queryResult(req.schema,
|
||||
'INSERT INTO user_groups (name,dm_group_id) VALUES ($1,$2) RETURNING id',
|
||||
[name.trim(), dmGroupId]
|
||||
@@ -229,7 +250,7 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
for (const uid of memberIds) {
|
||||
if (defaultAdmin && uid === defaultAdmin.id) continue;
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ugId, uid]);
|
||||
await addUserSilent(req.schema, dmGroupId, uid);
|
||||
if (dmGroupId) await addUserSilent(req.schema, dmGroupId, uid);
|
||||
}
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ugId]);
|
||||
res.json({ userGroup: ug });
|
||||
@@ -238,9 +259,9 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
|
||||
// PATCH /:id
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name, memberIds } = req.body;
|
||||
const { name, memberIds, createDm = false, aliasMemberIds } = req.body;
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
let ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
if (name && name.trim() !== ug.name) {
|
||||
@@ -250,7 +271,24 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
if (ug.dm_group_id) await exec(req.schema, 'UPDATE groups SET name=$1, updated_at=NOW() WHERE id=$2', [name.trim(), ug.dm_group_id]);
|
||||
}
|
||||
|
||||
if (Array.isArray(memberIds) && ug.dm_group_id) {
|
||||
// Create DM group if requested and one doesn't exist yet
|
||||
if (createDm && !ug.dm_group_id) {
|
||||
const groupName = (name?.trim()) || ug.name;
|
||||
const gr = await queryResult(req.schema,
|
||||
"INSERT INTO groups (name,type,is_readonly,is_managed) VALUES ($1,'private',FALSE,TRUE) RETURNING id",
|
||||
[groupName]
|
||||
);
|
||||
const newDmId = gr.rows[0].id;
|
||||
await exec(req.schema, 'UPDATE user_groups SET dm_group_id=$1, updated_at=NOW() WHERE id=$2', [newDmId, ug.id]);
|
||||
ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [ug.id]);
|
||||
// Add all current members to the new DM silently (no per-user join messages for a bulk creation)
|
||||
const currentMembers = await query(req.schema, 'SELECT user_id FROM user_group_members WHERE user_group_id=$1', [ug.id]);
|
||||
for (const { user_id } of currentMembers) {
|
||||
await addUserSilent(req.schema, newDmId, user_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(memberIds)) {
|
||||
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
|
||||
const newIds = new Set(memberIds.map(Number).filter(Boolean));
|
||||
if (defaultAdmin) newIds.delete(defaultAdmin.id); // default admin cannot be in user groups
|
||||
@@ -260,32 +298,37 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
for (const uid of newIds) {
|
||||
if (!currentSet.has(uid)) {
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, uid]);
|
||||
await addUserSilent(req.schema, ug.dm_group_id, uid);
|
||||
if (ug.dm_group_id) await addUserSilent(req.schema, ug.dm_group_id, uid);
|
||||
addedUids.push(uid);
|
||||
}
|
||||
}
|
||||
for (const uid of currentSet) {
|
||||
if (!newIds.has(uid)) {
|
||||
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, uid]);
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]);
|
||||
io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',ug.dm_group_id));
|
||||
io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id });
|
||||
if (ug.dm_group_id) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, uid]);
|
||||
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
|
||||
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id });
|
||||
}
|
||||
io.to(R(req.schema,'user',uid)).emit('schedule:refresh');
|
||||
removedUids.push(uid);
|
||||
}
|
||||
}
|
||||
|
||||
// Notification rule: single user → named message; multiple users → one generic message
|
||||
if (addedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [addedUids[0]]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
} else if (addedUids.length > 1) {
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${addedUids.length} new members have joined the conversation.`);
|
||||
}
|
||||
if (removedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [removedUids[0]]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
} else if (removedUids.length > 1) {
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${removedUids.length} members have been removed from the conversation.`);
|
||||
// Notification rule (only if DM exists): single user → named message; multiple → generic
|
||||
if (ug.dm_group_id) {
|
||||
if (addedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [addedUids[0]]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
} else if (addedUids.length > 1) {
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${addedUids.length} new members have joined the conversation.`);
|
||||
}
|
||||
if (removedUids.length === 1) {
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [removedUids[0]]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
} else if (removedUids.length > 1) {
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${removedUids.length} members have been removed from the conversation.`);
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate to multi-group DMs
|
||||
@@ -303,8 +346,8 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
`, [mg.id, uid]);
|
||||
if (!stillIn) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, uid]);
|
||||
io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',mg.dm_group_id));
|
||||
io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
|
||||
io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
}
|
||||
}
|
||||
if (addedUids.length === 1) {
|
||||
@@ -322,6 +365,24 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
}
|
||||
}
|
||||
|
||||
// Alias member management (Guardian Only mode — players group)
|
||||
if (Array.isArray(aliasMemberIds)) {
|
||||
const newAliasIds = new Set(aliasMemberIds.map(Number).filter(Boolean));
|
||||
const currentAliasSet = new Set(
|
||||
(await query(req.schema, 'SELECT alias_id FROM alias_group_members WHERE user_group_id=$1', [ug.id])).map(r => r.alias_id)
|
||||
);
|
||||
for (const aid of newAliasIds) {
|
||||
if (!currentAliasSet.has(aid)) {
|
||||
await exec(req.schema, 'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, aid]);
|
||||
}
|
||||
}
|
||||
for (const aid of currentAliasSet) {
|
||||
if (!newAliasIds.has(aid)) {
|
||||
await exec(req.schema, 'DELETE FROM alias_group_members WHERE user_group_id=$1 AND alias_id=$2', [ug.id, aid]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
res.json({ group: updated });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
@@ -335,7 +396,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
if (ug.dm_group_id) {
|
||||
const members = (await query(req.schema, 'SELECT user_id FROM group_members WHERE group_id=$1', [ug.dm_group_id])).map(r => r.user_id);
|
||||
await exec(req.schema, 'DELETE FROM groups WHERE id=$1', [ug.dm_group_id]);
|
||||
for (const uid of members) { io.in(R(schema,'user',uid)).socketsLeave(R(schema,'group',ug.dm_group_id)); io.to(R(schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); }
|
||||
for (const uid of members) { io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id)); io.to(R(req.schema,'user',uid)).emit('group:deleted', { groupId: ug.dm_group_id }); }
|
||||
}
|
||||
await exec(req.schema, 'DELETE FROM user_groups WHERE id=$1', [ug.id]);
|
||||
res.json({ success: true });
|
||||
@@ -343,6 +404,82 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
|
||||
});
|
||||
|
||||
|
||||
// POST /:id/members/:userId — add a single user to a group (with DM + notifications)
|
||||
router.post('/:id/members/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
const userId = parseInt(req.params.userId);
|
||||
const defaultAdmin = await queryOne(req.schema, 'SELECT id FROM users WHERE is_default_admin=TRUE');
|
||||
if (defaultAdmin && userId === defaultAdmin.id) return res.status(400).json({ error: 'Cannot add default admin to user groups' });
|
||||
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, userId]);
|
||||
|
||||
if (ug.dm_group_id) {
|
||||
await addUserSilent(req.schema, ug.dm_group_id, userId);
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined the conversation.`);
|
||||
}
|
||||
|
||||
// Propagate to multi-group DMs
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
|
||||
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
|
||||
`, [ug.id]);
|
||||
for (const mg of mgDms) {
|
||||
if (!mg.dm_group_id) continue;
|
||||
await addUserSilent(req.schema, mg.dm_group_id, userId);
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has joined this conversation.`);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// DELETE /:id/members/:userId — remove a single user from a group (with DM + notifications)
|
||||
router.delete('/:id/members/:userId', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
|
||||
if (!ug) return res.status(404).json({ error: 'Not found' });
|
||||
const userId = parseInt(req.params.userId);
|
||||
|
||||
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, userId]);
|
||||
|
||||
io.to(R(req.schema,'user',userId)).emit('schedule:refresh');
|
||||
|
||||
if (ug.dm_group_id) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [ug.dm_group_id, userId]);
|
||||
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',ug.dm_group_id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: ug.dm_group_id });
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, ug.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from the conversation.`);
|
||||
}
|
||||
|
||||
// Propagate to multi-group DMs
|
||||
const mgDms = await query(req.schema, `
|
||||
SELECT mgd.id, mgd.dm_group_id FROM multi_group_dm_members mgdm
|
||||
JOIN multi_group_dms mgd ON mgd.id=mgdm.multi_group_dm_id WHERE mgdm.user_group_id=$1
|
||||
`, [ug.id]);
|
||||
for (const mg of mgDms) {
|
||||
if (!mg.dm_group_id) continue;
|
||||
const stillIn = await queryOne(req.schema, `
|
||||
SELECT 1 FROM multi_group_dm_members mgdm JOIN user_group_members ugm ON ugm.user_group_id=mgdm.user_group_id
|
||||
WHERE mgdm.multi_group_dm_id=$1 AND ugm.user_id=$2
|
||||
`, [mg.id, userId]);
|
||||
if (!stillIn) {
|
||||
await exec(req.schema, 'DELETE FROM group_members WHERE group_id=$1 AND user_id=$2', [mg.dm_group_id, userId]);
|
||||
io.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',mg.dm_group_id));
|
||||
io.to(R(req.schema,'user',userId)).emit('group:deleted', { groupId: mg.dm_group_id });
|
||||
const u = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
|
||||
await postSysMsg(req.schema, mg.dm_group_id, req.user.id, `${u?.display_name||u?.name||'A user'} has been removed from this conversation.`);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// ── U2U DM Restrictions ───────────────────────────────────────────────────────
|
||||
|
||||
// GET /:id/restrictions — get blocked group IDs for a user group
|
||||
|
||||
@@ -4,7 +4,7 @@ const multer = require('multer');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const { query, queryOne, queryResult, exec, addUserToPublicGroups, getOrCreateSupportGroup } = require('../models/db');
|
||||
const { authMiddleware, adminMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
const { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
|
||||
|
||||
const avatarStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/avatars',
|
||||
@@ -16,6 +16,17 @@ const uploadAvatar = multer({
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
// Alias avatar upload (separate from user avatar so filename doesn't collide)
|
||||
const aliasAvatarStorage = multer.diskStorage({
|
||||
destination: '/app/uploads/avatars',
|
||||
filename: (req, file, cb) => cb(null, `alias_${req.params.aliasId}_${Date.now()}${path.extname(file.originalname)}`),
|
||||
});
|
||||
const uploadAliasAvatar = multer({
|
||||
storage: aliasAvatarStorage,
|
||||
limits: { fileSize: 2 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')),
|
||||
});
|
||||
|
||||
async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||
const existing = await query(schema,
|
||||
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)",
|
||||
@@ -29,37 +40,58 @@ async function resolveUniqueName(schema, baseName, excludeId = null) {
|
||||
|
||||
function isValidEmail(e) { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e); }
|
||||
|
||||
// Returns true if the given date-of-birth string corresponds to age <= 15
|
||||
function isMinorFromDOB(dob) {
|
||||
if (!dob) return false;
|
||||
const birth = new Date(dob);
|
||||
if (isNaN(birth)) return false;
|
||||
const today = new Date();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age <= 15;
|
||||
}
|
||||
|
||||
async function getLoginType(schema) {
|
||||
const row = await queryOne(schema, "SELECT value FROM settings WHERE key='feature_login_type'");
|
||||
return row?.value || 'all_ages';
|
||||
}
|
||||
|
||||
// List users
|
||||
router.get('/', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const users = await query(req.schema,
|
||||
"SELECT id,name,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY created_at ASC"
|
||||
"SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,is_default_admin,must_change_password,avatar,about_me,display_name,allow_dm,created_at,last_online FROM users WHERE status != 'deleted' ORDER BY name ASC"
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Search users
|
||||
// When q is empty (full-list load by GroupManagerPage / NewChatModal) — return ALL active users,
|
||||
// no LIMIT, so the complete roster is available for member-picker UIs.
|
||||
// When q is non-empty (typed search / mention autocomplete) — keep LIMIT 10 for performance.
|
||||
router.get('/search', authMiddleware, async (req, res) => {
|
||||
const { q, groupId } = req.query;
|
||||
const isTyped = q && q.length > 0;
|
||||
try {
|
||||
let users;
|
||||
if (groupId) {
|
||||
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(groupId)]);
|
||||
if (group && (group.type === 'private' || group.is_direct)) {
|
||||
users = await query(req.schema,
|
||||
"SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) LIMIT 10",
|
||||
`SELECT u.id,u.name,u.display_name,u.avatar,u.role,u.status,u.hide_admin_tag,u.allow_dm,u.is_minor,u.is_default_admin FROM users u JOIN group_members gm ON gm.user_id=u.id AND gm.group_id=$1 WHERE u.status='active' AND u.id!=$2 AND (u.name ILIKE $3 OR u.display_name ILIKE $3) ORDER BY u.name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||
[parseInt(groupId), req.user.id, `%${q}%`]
|
||||
);
|
||||
} else {
|
||||
users = await query(req.schema,
|
||||
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) LIMIT 10",
|
||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor,is_default_admin FROM users WHERE status='active' AND id!=$1 AND (name ILIKE $2 OR display_name ILIKE $2) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||
[req.user.id, `%${q}%`]
|
||||
);
|
||||
}
|
||||
} else {
|
||||
users = await query(req.schema,
|
||||
"SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) LIMIT 10",
|
||||
`SELECT id,name,display_name,avatar,role,status,hide_admin_tag,allow_dm,is_minor,is_default_admin FROM users WHERE status='active' AND (name ILIKE $1 OR display_name ILIKE $1) ORDER BY name ASC${isTyped ? ' LIMIT 10' : ''}`,
|
||||
[`%${q}%`]
|
||||
);
|
||||
}
|
||||
@@ -81,60 +113,156 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// Create user
|
||||
router.post('/', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
const { name, email, password, role } = req.body;
|
||||
if (!name || !email) return res.status(400).json({ error: 'Name and email required' });
|
||||
if (!isValidEmail(email)) return res.status(400).json({ error: 'Invalid email address' });
|
||||
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { firstName, lastName, email, password, role, phone, dateOfBirth } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim() || !email)
|
||||
return res.status(400).json({ error: 'First name, last name and email required' });
|
||||
if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
|
||||
const validRoles = ['member', 'admin', 'manager'];
|
||||
const assignedRole = validRoles.includes(role) ? role : 'member';
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
try {
|
||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email = $1 AND status != 'deleted'", [email]);
|
||||
const loginType = await getLoginType(req.schema);
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
// In mixed_age mode, minors start suspended and need guardian approval
|
||||
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||
|
||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE LOWER(email) = LOWER($1) AND status != 'deleted'", [email.trim()]);
|
||||
if (exists) return res.status(400).json({ error: 'Email already in use' });
|
||||
const resolvedName = await resolveUniqueName(req.schema, name.trim());
|
||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
|
||||
[resolvedName, email, hash, role === 'admin' ? 'admin' : 'member']
|
||||
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,date_of_birth,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING id",
|
||||
[resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
|
||||
);
|
||||
const userId = r.rows[0].id;
|
||||
await addUserToPublicGroups(req.schema, userId);
|
||||
if (role === 'admin') {
|
||||
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||
if (assignedRole === 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||
}
|
||||
const user = await queryOne(req.schema, 'SELECT id,name,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,created_at FROM users WHERE id=$1',
|
||||
[userId]
|
||||
);
|
||||
res.json({ user, pendingApproval: initStatus === 'suspended' });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update user (general — name components, phone, DOB, is_minor, role, optional password reset)
|
||||
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
|
||||
const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim())
|
||||
return res.status(400).json({ error: 'First and last name required' });
|
||||
const validRoles = ['member', 'admin', 'manager'];
|
||||
if (!validRoles.includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
if (target.is_default_admin && role !== 'admin')
|
||||
return res.status(403).json({ error: 'Cannot change default admin role' });
|
||||
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
const name = `${firstName.trim()} ${lastName.trim()}`;
|
||||
const resolvedName = await resolveUniqueName(req.schema, name, id);
|
||||
|
||||
// Validate guardian if provided
|
||||
let guardianId = null;
|
||||
if (guardianUserId) {
|
||||
const gUser = await queryOne(req.schema, 'SELECT id,is_minor FROM users WHERE id=$1 AND status=$2', [parseInt(guardianUserId), 'active']);
|
||||
if (!gUser) return res.status(400).json({ error: 'Guardian user not found or inactive' });
|
||||
if (gUser.is_minor) return res.status(400).json({ error: 'A minor cannot be a guardian' });
|
||||
guardianId = gUser.id;
|
||||
}
|
||||
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,date_of_birth=$6,guardian_user_id=$7,role=$8,updated_at=NOW() WHERE id=$9',
|
||||
[resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
|
||||
);
|
||||
if (password && password.length >= 6) {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
await exec(req.schema, 'UPDATE users SET password=$1,must_change_password=TRUE,updated_at=NOW() WHERE id=$2', [hash, id]);
|
||||
}
|
||||
if (role === 'admin' && target.role !== 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, id]);
|
||||
}
|
||||
// Auto-unsuspend minor in players group if both guardian and DOB are now set
|
||||
if (isMinor && guardianId && dob && target.status === 'suspended') {
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
if (playersGroupId) {
|
||||
const inPlayers = await queryOne(req.schema, 'SELECT 1 FROM user_group_members WHERE user_id=$1 AND user_group_id=$2', [id, playersGroupId]);
|
||||
if (inPlayers) {
|
||||
await exec(req.schema, "UPDATE users SET status='active',updated_at=NOW() WHERE id=$1", [id]);
|
||||
await addUserToPublicGroups(req.schema, id);
|
||||
}
|
||||
}
|
||||
}
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Bulk create
|
||||
router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { users } = req.body;
|
||||
const results = { created: [], skipped: [] };
|
||||
const seenEmails = new Set();
|
||||
const defaultPw = process.env.USER_PASS || 'user@1234';
|
||||
const validRoles = ['member', 'manager', 'admin'];
|
||||
try {
|
||||
for (const u of users) {
|
||||
const email = (u.email || '').trim().toLowerCase();
|
||||
const name = (u.name || '').trim();
|
||||
if (!name || !email) { results.skipped.push({ email: email || '(blank)', reason: 'Missing name or email' }); continue; }
|
||||
if (!isValidEmail(email)) { results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
|
||||
if (seenEmails.has(email)) { results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
||||
const email = (u.email || '').trim().toLowerCase();
|
||||
const firstName = (u.firstName || '').trim();
|
||||
const lastName = (u.lastName || '').trim();
|
||||
// Support legacy name field too
|
||||
const name = (firstName && lastName) ? `${firstName} ${lastName}` : (u.name || '').trim();
|
||||
if (!email) { results.skipped.push({ email: '(blank)', reason: 'Email required' }); continue; }
|
||||
if (!isValidEmail(email)){ results.skipped.push({ email, reason: 'Invalid email address' }); continue; }
|
||||
if (!name) { results.skipped.push({ email, reason: 'First and last name required' }); continue; }
|
||||
if (seenEmails.has(email)){ results.skipped.push({ email, reason: 'Duplicate email in CSV' }); continue; }
|
||||
seenEmails.add(email);
|
||||
const exists = await queryOne(req.schema, "SELECT id FROM users WHERE email=$1 AND status != 'deleted'", [email]);
|
||||
if (exists) { results.skipped.push({ email, reason: 'Email already exists' }); continue; }
|
||||
try {
|
||||
const resolvedName = await resolveUniqueName(req.schema, name);
|
||||
const pw = (u.password || '').trim() || defaultPw;
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const newRole = u.role === 'admin' ? 'admin' : 'member';
|
||||
const pw = (u.password || '').trim() || defaultPw;
|
||||
const hash = bcrypt.hashSync(pw, 10);
|
||||
const newRole = validRoles.includes(u.role) ? u.role : 'member';
|
||||
const fn = firstName || name.split(' ')[0] || '';
|
||||
const ln = lastName || name.split(' ').slice(1).join(' ') || '';
|
||||
const dob = (u.dateOfBirth || u.dob || '').trim() || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
const loginType = await getLoginType(req.schema);
|
||||
const initStatus = (loginType === 'mixed_age' && isMinor) ? 'suspended' : 'active';
|
||||
const r = await queryResult(req.schema,
|
||||
"INSERT INTO users (name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,'active',TRUE) RETURNING id",
|
||||
[resolvedName, email, hash, newRole]
|
||||
"INSERT INTO users (name,first_name,last_name,email,password,role,date_of_birth,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE) RETURNING id",
|
||||
[resolvedName, fn, ln, email, hash, newRole, dob, isMinor, initStatus]
|
||||
);
|
||||
await addUserToPublicGroups(req.schema, r.rows[0].id);
|
||||
const userId = r.rows[0].id;
|
||||
if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
|
||||
if (newRole === 'admin') {
|
||||
const sgId = await getOrCreateSupportGroup(req.schema);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, r.rows[0].id]);
|
||||
if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
|
||||
}
|
||||
// Add to user group if specified (silent — user was just created, no socket needed)
|
||||
if (u.userGroupId) {
|
||||
const ug = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [u.userGroupId]);
|
||||
if (ug) {
|
||||
await exec(req.schema, 'INSERT INTO user_group_members (user_group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.id, userId]);
|
||||
if (ug.dm_group_id) {
|
||||
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [ug.dm_group_id, userId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.created.push(email);
|
||||
} catch (e) { results.skipped.push({ email, reason: e.message }); }
|
||||
@@ -144,7 +272,7 @@ router.post('/bulk', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// Patch name
|
||||
router.patch('/:id/name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.patch('/:id/name', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { name } = req.body;
|
||||
if (!name?.trim()) return res.status(400).json({ error: 'Name required' });
|
||||
try {
|
||||
@@ -157,9 +285,9 @@ router.patch('/:id/name', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// Patch role
|
||||
router.patch('/:id/role', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.patch('/:id/role', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { role } = req.body;
|
||||
if (!['member','admin'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
if (!['member','admin','manager'].includes(role)) return res.status(400).json({ error: 'Invalid role' });
|
||||
try {
|
||||
const target = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!target) return res.status(404).json({ error: 'User not found' });
|
||||
@@ -174,7 +302,7 @@ router.patch('/:id/role', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
});
|
||||
|
||||
// Reset password
|
||||
router.patch('/:id/reset-password', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.patch('/:id/reset-password', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 6) return res.status(400).json({ error: 'Password too short' });
|
||||
try {
|
||||
@@ -185,7 +313,7 @@ router.patch('/:id/reset-password', authMiddleware, adminMiddleware, async (req,
|
||||
});
|
||||
|
||||
// Suspend / activate / delete
|
||||
router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.patch('/:id/suspend', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'User not found' });
|
||||
@@ -196,13 +324,13 @@ router.patch('/:id/suspend', authMiddleware, adminMiddleware, async (req, res)
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
router.patch('/:id/activate', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.patch('/:id/activate', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, "UPDATE users SET status='active', updated_at=NOW() WHERE id=$1", [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
router.delete('/:id', authMiddleware, adminMiddleware, async (req, res) => {
|
||||
router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const t = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [req.params.id]);
|
||||
if (!t) return res.status(404).json({ error: 'User not found' });
|
||||
@@ -216,6 +344,10 @@ router.delete('/:id', authMiddleware, adminMiddleware, async (req, res)
|
||||
status = 'deleted',
|
||||
email = $1,
|
||||
name = 'Deleted User',
|
||||
first_name = NULL,
|
||||
last_name = NULL,
|
||||
phone = NULL,
|
||||
is_minor = FALSE,
|
||||
display_name = NULL,
|
||||
avatar = NULL,
|
||||
about_me = NULL,
|
||||
@@ -258,7 +390,7 @@ router.delete('/:id', authMiddleware, adminMiddleware, async (req, res)
|
||||
|
||||
// Update own profile
|
||||
router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||
const { displayName, aboutMe, hideAdminTag, allowDm } = req.body;
|
||||
const { displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth, phone } = req.body;
|
||||
try {
|
||||
if (displayName) {
|
||||
const conflict = await queryOne(req.schema,
|
||||
@@ -267,12 +399,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => {
|
||||
);
|
||||
if (conflict) return res.status(400).json({ error: 'Display name already in use' });
|
||||
}
|
||||
const dob = dateOfBirth || null;
|
||||
const isMinor = isMinorFromDOB(dob);
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, updated_at=NOW() WHERE id=$5',
|
||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, req.user.id]
|
||||
'UPDATE users SET display_name=$1, about_me=$2, hide_admin_tag=$3, allow_dm=$4, date_of_birth=$5, is_minor=$6, phone=$7, updated_at=NOW() WHERE id=$8',
|
||||
[displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
|
||||
);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm FROM users WHERE id=$1',
|
||||
'SELECT id,name,email,role,status,avatar,about_me,display_name,hide_admin_tag,allow_dm,date_of_birth,phone FROM users WHERE id=$1',
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ user });
|
||||
@@ -310,4 +444,345 @@ router.post('/me/avatar', authMiddleware, uploadAvatar.single('avatar'), async (
|
||||
}
|
||||
});
|
||||
|
||||
// ── Guardian alias routes (Guardian Only mode) ──────────────────────────────
|
||||
|
||||
// List ALL aliases — admin/manager only (for Group Manager alias management)
|
||||
router.get('/aliases-all', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
try {
|
||||
const aliases = await query(req.schema,
|
||||
`SELECT ga.id, ga.first_name, ga.last_name, ga.guardian_id, ga.avatar, ga.date_of_birth,
|
||||
u.name AS guardian_name, u.display_name AS guardian_display_name
|
||||
FROM guardian_aliases ga
|
||||
JOIN users u ON u.id = ga.guardian_id
|
||||
ORDER BY ga.first_name, ga.last_name`,
|
||||
);
|
||||
res.json({ aliases });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Get current user's partner (spouse/partner relationship)
|
||||
router.get('/me/partner', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const partner = await queryOne(req.schema,
|
||||
`SELECT u.id, u.name, u.display_name, u.avatar, gp.respond_separately
|
||||
FROM guardian_partners gp
|
||||
JOIN users u ON u.id = CASE WHEN gp.user_id_1=$1 THEN gp.user_id_2 ELSE gp.user_id_1 END
|
||||
WHERE gp.user_id_1=$1 OR gp.user_id_2=$1`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ partner: partner || null });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Set partner (replaces any existing partnership for this user)
|
||||
// If the partner is changing to a different person, the user's child aliases are also removed.
|
||||
router.post('/me/partner', authMiddleware, async (req, res) => {
|
||||
const userId = req.user.id;
|
||||
const partnerId = parseInt(req.body.partnerId);
|
||||
const respondSeparately = !!req.body.respondSeparately;
|
||||
if (!partnerId || partnerId === userId) return res.status(400).json({ error: 'Invalid partner' });
|
||||
const uid1 = Math.min(userId, partnerId);
|
||||
const uid2 = Math.max(userId, partnerId);
|
||||
try {
|
||||
// Check current partner before replacing
|
||||
const currentRow = await queryOne(req.schema,
|
||||
`SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END AS partner_id
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1`,
|
||||
[userId]
|
||||
);
|
||||
const currentPartnerId = currentRow?.partner_id ? parseInt(currentRow.partner_id) : null;
|
||||
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [userId]);
|
||||
// If switching to a different partner, remove user's own child aliases
|
||||
if (currentPartnerId && currentPartnerId !== partnerId) {
|
||||
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [userId]);
|
||||
}
|
||||
await exec(req.schema, 'INSERT INTO guardian_partners (user_id_1,user_id_2,respond_separately) VALUES ($1,$2,$3)', [uid1, uid2, respondSeparately]);
|
||||
const partner = await queryOne(req.schema,
|
||||
'SELECT id,name,display_name,avatar FROM users WHERE id=$1',
|
||||
[partnerId]
|
||||
);
|
||||
res.json({ partner: { ...partner, respond_separately: respondSeparately } });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update respond_separately on existing partnership
|
||||
router.patch('/me/partner', authMiddleware, async (req, res) => {
|
||||
const respondSeparately = !!req.body.respondSeparately;
|
||||
try {
|
||||
await exec(req.schema,
|
||||
'UPDATE guardian_partners SET respond_separately=$1 WHERE user_id_1=$2 OR user_id_2=$2',
|
||||
[respondSeparately, req.user.id]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Remove partner — also removes the requesting user's child aliases
|
||||
router.delete('/me/partner', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE guardian_id=$1', [req.user.id]);
|
||||
await exec(req.schema, 'DELETE FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1', [req.user.id]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// List current user's aliases (includes partner's aliases)
|
||||
router.get('/me/aliases', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const aliases = await query(req.schema,
|
||||
`SELECT id,first_name,last_name,email,date_of_birth,avatar,phone
|
||||
FROM guardian_aliases
|
||||
WHERE guardian_id=$1
|
||||
OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$1 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$1 OR user_id_2=$1
|
||||
)
|
||||
ORDER BY first_name,last_name`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ aliases });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Create alias
|
||||
router.post('/me/aliases', authMiddleware, async (req, res) => {
|
||||
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||
try {
|
||||
const r = await queryResult(req.schema,
|
||||
'INSERT INTO guardian_aliases (guardian_id,first_name,last_name,email,date_of_birth,phone) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id',
|
||||
[req.user.id, firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null]
|
||||
);
|
||||
const aliasId = r.rows[0].id;
|
||||
|
||||
// Auto-add alias to players group if designated
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
if (playersGroupId) {
|
||||
await exec(req.schema,
|
||||
'INSERT INTO alias_group_members (user_group_id,alias_id) VALUES ($1,$2) ON CONFLICT DO NOTHING',
|
||||
[playersGroupId, aliasId]
|
||||
);
|
||||
}
|
||||
const alias = await queryOne(req.schema,
|
||||
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
|
||||
[aliasId]
|
||||
);
|
||||
res.json({ alias });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Update alias
|
||||
router.patch('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
const { firstName, lastName, email, dateOfBirth, phone } = req.body;
|
||||
if (!firstName?.trim() || !lastName?.trim()) return res.status(400).json({ error: 'First and last name required' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
await exec(req.schema,
|
||||
'UPDATE guardian_aliases SET first_name=$1,last_name=$2,email=$3,date_of_birth=$4,phone=$5,updated_at=NOW() WHERE id=$6',
|
||||
[firstName.trim(), lastName.trim(), email?.trim() || null, dateOfBirth || null, phone?.trim() || null, aliasId]
|
||||
);
|
||||
const alias = await queryOne(req.schema,
|
||||
'SELECT id,first_name,last_name,email,date_of_birth,avatar,phone FROM guardian_aliases WHERE id=$1',
|
||||
[aliasId]
|
||||
);
|
||||
res.json({ alias });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Delete alias
|
||||
router.delete('/me/aliases/:aliasId', authMiddleware, async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
try {
|
||||
const existing = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
await exec(req.schema, 'DELETE FROM guardian_aliases WHERE id=$1', [aliasId]);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Upload alias avatar
|
||||
router.post('/me/aliases/:aliasId/avatar', authMiddleware, uploadAliasAvatar.single('avatar'), async (req, res) => {
|
||||
const aliasId = parseInt(req.params.aliasId);
|
||||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||||
try {
|
||||
const existing = await queryOne(req.schema,
|
||||
`SELECT id FROM guardian_aliases WHERE id=$1 AND (
|
||||
guardian_id=$2 OR guardian_id IN (
|
||||
SELECT CASE WHEN user_id_1=$2 THEN user_id_2 ELSE user_id_1 END
|
||||
FROM guardian_partners WHERE user_id_1=$2 OR user_id_2=$2
|
||||
)
|
||||
)`,
|
||||
[aliasId, req.user.id]);
|
||||
if (!existing) return res.status(404).json({ error: 'Alias not found' });
|
||||
const sharp = require('sharp');
|
||||
const filePath = req.file.path;
|
||||
const MAX_DIM = 256;
|
||||
const image = sharp(filePath);
|
||||
const meta = await image.metadata();
|
||||
const needsResize = meta.width > MAX_DIM || meta.height > MAX_DIM;
|
||||
let avatarUrl;
|
||||
if (req.file.size >= 500 * 1024 || needsResize) {
|
||||
const outPath = filePath.replace(/\.[^.]+$/, '.webp');
|
||||
await sharp(filePath).resize(MAX_DIM,MAX_DIM,{fit:'cover',withoutEnlargement:true}).webp({quality:82}).toFile(outPath);
|
||||
require('fs').unlinkSync(filePath);
|
||||
avatarUrl = `/uploads/avatars/${path.basename(outPath)}`;
|
||||
} else {
|
||||
avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
}
|
||||
await exec(req.schema, 'UPDATE guardian_aliases SET avatar=$1,updated_at=NOW() WHERE id=$2', [avatarUrl, aliasId]);
|
||||
res.json({ avatarUrl });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Search minor users (Mixed Age — for Add Child in profile)
|
||||
router.get('/search-minors', authMiddleware, async (req, res) => {
|
||||
const { q } = req.query;
|
||||
try {
|
||||
const users = await query(req.schema,
|
||||
`SELECT id,name,first_name,last_name,date_of_birth,avatar,phone FROM users
|
||||
WHERE is_minor=TRUE AND status='suspended' AND guardian_user_id IS NULL AND status!='deleted'
|
||||
AND (name ILIKE $1 OR first_name ILIKE $1 OR last_name ILIKE $1)
|
||||
ORDER BY name ASC LIMIT 20`,
|
||||
[`%${q || ''}%`]
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Approve guardian link (Mixed Age — manager+ sets guardian, clears approval flag, unsuspends)
|
||||
router.patch('/:id/approve-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||
if (!minor.guardian_approval_required) return res.status(400).json({ error: 'No pending approval' });
|
||||
await exec(req.schema,
|
||||
"UPDATE users SET guardian_approval_required=FALSE,status='active',updated_at=NOW() WHERE id=$1",
|
||||
[id]
|
||||
);
|
||||
await addUserToPublicGroups(req.schema, id);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Deny guardian link (Mixed Age — clears guardian, keeps suspended)
|
||||
router.patch('/:id/deny-guardian', authMiddleware, teamManagerMiddleware, async (req, res) => {
|
||||
const id = parseInt(req.params.id);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [id]);
|
||||
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET guardian_approval_required=FALSE,guardian_user_id=NULL,updated_at=NOW() WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,phone,is_minor,date_of_birth,guardian_user_id,guardian_approval_required,email,role,status FROM users WHERE id=$1',
|
||||
[id]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// List minor players available for this guardian to claim (Mixed Age — Family Manager)
|
||||
// Returns minors in the players group who either have no guardian yet or are already linked to me.
|
||||
router.get('/minor-players', authMiddleware, async (req, res) => {
|
||||
try {
|
||||
const playersRow = await queryOne(req.schema, "SELECT value FROM settings WHERE key='feature_players_group_id'");
|
||||
const playersGroupId = parseInt(playersRow?.value);
|
||||
if (!playersGroupId) return res.json({ users: [] });
|
||||
const users = await query(req.schema,
|
||||
`SELECT u.id,u.name,u.first_name,u.last_name,u.date_of_birth,u.avatar,u.status,u.guardian_user_id
|
||||
FROM users u
|
||||
JOIN user_group_members ugm ON ugm.user_id=u.id AND ugm.user_group_id=$1
|
||||
WHERE u.is_minor=TRUE AND u.status!='deleted'
|
||||
AND (u.guardian_user_id IS NULL OR u.guardian_user_id=$2)
|
||||
ORDER BY u.first_name,u.last_name`,
|
||||
[playersGroupId, req.user.id]
|
||||
);
|
||||
res.json({ users });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Claim minor as guardian (Mixed Age — Family Manager direct link, no approval needed)
|
||||
// dateOfBirth is required to activate the minor — without it the guardian is saved but the account stays suspended.
|
||||
router.post('/me/guardian-children/:minorId', authMiddleware, async (req, res) => {
|
||||
const minorId = parseInt(req.params.minorId);
|
||||
const { dateOfBirth } = req.body;
|
||||
try {
|
||||
const minor = await queryOne(req.schema, "SELECT * FROM users WHERE id=$1 AND status!='deleted'", [minorId]);
|
||||
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||
if (!minor.is_minor) return res.status(400).json({ error: 'User is not a minor' });
|
||||
if (minor.guardian_user_id && minor.guardian_user_id !== req.user.id)
|
||||
return res.status(409).json({ error: 'This minor already has a guardian' });
|
||||
const dob = dateOfBirth || minor.date_of_birth || null;
|
||||
const isMinor = dob ? isMinorFromDOB(dob) : minor.is_minor;
|
||||
const shouldActivate = !!dob;
|
||||
const newStatus = shouldActivate ? 'active' : 'suspended';
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=FALSE,date_of_birth=$2,is_minor=$3,status=$4,updated_at=NOW() WHERE id=$5',
|
||||
[req.user.id, dob, isMinor, newStatus, minorId]
|
||||
);
|
||||
if (shouldActivate) await addUserToPublicGroups(req.schema, minorId);
|
||||
const user = await queryOne(req.schema,
|
||||
'SELECT id,name,first_name,last_name,date_of_birth,avatar,status,guardian_user_id FROM users WHERE id=$1',
|
||||
[minorId]
|
||||
);
|
||||
res.json({ user });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Remove minor from guardian's list (Mixed Age — re-suspends the minor)
|
||||
router.delete('/me/guardian-children/:minorId', authMiddleware, async (req, res) => {
|
||||
const minorId = parseInt(req.params.minorId);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
|
||||
if (!minor) return res.status(404).json({ error: 'User not found' });
|
||||
if (minor.guardian_user_id !== req.user.id)
|
||||
return res.status(403).json({ error: 'You are not the guardian of this user' });
|
||||
await exec(req.schema,
|
||||
"UPDATE users SET guardian_user_id=NULL,status='suspended',updated_at=NOW() WHERE id=$1",
|
||||
[minorId]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
// Guardian self-link (Mixed Age — user links themselves as guardian of a minor, triggers approval)
|
||||
router.patch('/me/link-minor/:minorId', authMiddleware, async (req, res) => {
|
||||
const minorId = parseInt(req.params.minorId);
|
||||
try {
|
||||
const minor = await queryOne(req.schema, 'SELECT * FROM users WHERE id=$1', [minorId]);
|
||||
if (!minor) return res.status(404).json({ error: 'Minor user not found' });
|
||||
if (!minor.is_minor) return res.status(400).json({ error: 'User is not flagged as a minor' });
|
||||
if (minor.guardian_user_id && !minor.guardian_approval_required)
|
||||
return res.status(400).json({ error: 'This minor already has an approved guardian' });
|
||||
await exec(req.schema,
|
||||
'UPDATE users SET guardian_user_id=$1,guardian_approval_required=TRUE,updated_at=NOW() WHERE id=$2',
|
||||
[req.user.id, minorId]
|
||||
);
|
||||
res.json({ success: true, pendingApproval: true });
|
||||
} catch (e) { res.status(500).json({ error: e.message }); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
2
build.sh
@@ -13,7 +13,7 @@
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-0.12.1}"
|
||||
VERSION="${1:-0.13.1}"
|
||||
ACTION="${2:-}"
|
||||
REGISTRY="${REGISTRY:-}"
|
||||
IMAGE_NAME="rosterchirp"
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
#
|
||||
# Required .env additions for host mode:
|
||||
# APP_TYPE=host
|
||||
# HOST_DOMAIN=rosterchirp.com
|
||||
# APP_DOMAIN=example.com
|
||||
# HOST_SLUG=chathost
|
||||
# HOST_ADMIN_KEY=your_secret_host_admin_key
|
||||
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider)
|
||||
|
||||
@@ -36,7 +37,8 @@ services:
|
||||
- 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}
|
||||
- APP_DOMAIN=${APP_DOMAIN:?APP_DOMAIN is required in host mode}
|
||||
- HOST_SLUG=${HOST_SLUG:?HOST_SLUG is required in host mode}
|
||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
|
||||
volumes:
|
||||
- rosterchirp_uploads:/app/uploads
|
||||
|
||||
@@ -21,7 +21,8 @@ services:
|
||||
- DB_NAME=${DB_NAME:-rosterchirp}
|
||||
- DB_USER=${DB_USER:-rosterchirp}
|
||||
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required}
|
||||
- HOST_DOMAIN=${HOST_DOMAIN:-}
|
||||
- APP_DOMAIN=${APP_DOMAIN:-}
|
||||
- HOST_SLUG=${HOST_SLUG:-}
|
||||
- HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
|
||||
- FIREBASE_API_KEY=${FIREBASE_API_KEY:-}
|
||||
- FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID:-}
|
||||
|
||||
15
fcm-app/.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.nyc_output
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
ssl/
|
||||
icon-*.png
|
||||
18
fcm-app/.env
Normal file
@@ -0,0 +1,18 @@
|
||||
# Firebase Configuration
|
||||
FIREBASE_PROJECT_ID=fcmtest-push
|
||||
FIREBASE_PRIVATE_KEY_ID=ac38f0122d21b6db2e7cfae4ed2120d848afcb13
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7S59WBylwnzgq\nYUpbwj4vzoLa6MtC7K/ZrB2Uxj1QuqdbnMsFid9RkWs+z86FUH/DgGyABnhhuBxO\nK8yQ+f1WR6deM7v1xFLrmYVDLk/7VGNGtn/xmQ7yjJPLFLqNplPWxjz8StJDiRRh\nFjPewGFrk/afDy0garsJTP6tK1IRGIf/dvIdBiCHQ1xpmWwkNDb1xNFSWx3JpN9m\nEbsMZBo5Af2jL044Z4jLEO+y32freiRoZBG4KG6Jb4+xo2qwjxFATmychpc9xEsf\nrMyOaV7omuhqOmjK3PfSotZnYyYAat8kerATe/EZsRtlTh1UHsiN+1FNy/RPV5s8\nTFYWf7a/AgMBAAECggEAJ7Ce01wxK+yRumljmI5RH1Bj6n/qkwQVP8t5eU2JMNJd\nJMzVORc+e8qVL3paCWZFrOhKFddJK2wYk3g0oYRYazBEB3JvImW4LLUbyGDIEjqP\nzyxdcJU+1ad0qlR6NApLOfhIdC5m4GjsKKbL1yhtfJ6eZJaSuYvkltP6JDhJ69Uq\nLdtA2dA5RGr1W1I8G3Yw4tNw5ImrfxbD7sO1y7A2aI5ZRL4/fOK0QCjbu8dznqPg\n8qT4dqabIRWTdM70ixEqfojQwNmL1w4wVajX470jn8iJZau0QMpJVfm2PtBxzXcM\nuQU+kP6b7BrFvKJ4LD0UOweiDQncfnKiNamMZKQgAQKBgQDcobi+lhkYxekvztq/\nv0d3RqgpmnABg1dPvNYbFV1WPjzCy/Pv87HFROb0LA/xNQKjA+2ss+LDEZXgSRuV\n7ovEQ2Zib/TyN10ihYGpIbXlbxz9rEtsatIuynKvYFlWm/v1S5LnPkCXlkHLi+cO\n2Z6DniGjCLqB4w5ZqkYzWVnSfwKBgQDZUdh5VRAR/ge1Vi5QtpQKuaZRvxjS+GZH\nmJNuIfm/+9zKakOMXgieT1wyTFr6I7955h967BrfO/djtvAQca+7l68hlyTgS4bf\n+nEVCTd3wwAbcEXOubpgnyLzQeaztRTFkcpyTZ2eVGraoAjijsElOtbJBbu9xaqS\nOoH4Adt7wQKBgQDNppSMWV41QCx2Goq9li6oGB0hAkoKrwEQWwT7I7PncoWyUOck\nr3LxXKMlz3hgrbeyeTPt+ZKRnu+jqqFi5II0w1pIwPCBYWeXiPftzXU90Y8lSJbZ\nDMyzPpMds2Iyn5x/7RyWHOmaIj1b3CDYL7JYHmpeDAHElf7HRza+IDfgQwKBgBTQ\nfwBYAlsGzqwynesDIbjJQUHRIMqMGhe/aFeDD42wzNviQ6f9Faw8A6OZppkQtXUy\nck9ur8Az2SUGz4VzrhY0mASKmnCVK0zmitAt+s8QsUDvhvAe39gDRfCwni0WKfAm\nX5KFFpSklztrWo6Ah8VOFmZYkzvA4+5vhiU/4ErBAoGAboI2WX/JNd8A5KQgRRpT\n5RkNLbhgg1TaBBEdfCkpuCJbpghAnfpvg/2lTtbLJ7SbAijmldrT5nNbhVNxAgYM\nZgOcoZJPBGi1AB1HzlkcGO/C9/H1tnEBB6ECbQ3yaz0n8TLUuJqHGwsomJJVPACT\n2FSNbfQ0TqCs1ba+Hx9iQBQ=\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-fbsvc@fcmtest-push.iam.gserviceaccount.com
|
||||
FIREBASE_CLIENT_ID=103917424542871804597
|
||||
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
||||
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
|
||||
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-fbsvc%40fcmtest-push.iam.gserviceaccount.com
|
||||
|
||||
# VAPID Key for Web Push
|
||||
VAPID_KEY=BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
TZ=America/Toronto
|
||||
15
fcm-app/.env.example
Normal file
@@ -0,0 +1,15 @@
|
||||
# Firebase Configuration
|
||||
FIREBASE_PROJECT_ID=your-project-id
|
||||
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
|
||||
FIREBASE_CLIENT_ID=your-client-id
|
||||
FIREBASE_AUTH_URI=https://accounts.google.com/o/oauth2/auth
|
||||
FIREBASE_TOKEN_URI=https://oauth2.googleapis.com/token
|
||||
FIREBASE_AUTH_PROVIDER_X509_CERT_URL=https://www.googleapis.com/oauth2/v1/certs
|
||||
FIREBASE_CLIENT_X509_CERT_URL=https://www.googleapis.com/robot/v1/metadata/x509/firebase-adminsdk-...%40your-project-id.iam.gserviceaccount.com
|
||||
|
||||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
TZ=America/Toronto
|
||||
33
fcm-app/Dockerfile
Normal file
@@ -0,0 +1,33 @@
|
||||
# Use Node.js 18 LTS
|
||||
FROM node:18-alpine
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files first (for better layer caching)
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies and wget
|
||||
RUN npm install --omit=dev && apk add --no-cache wget
|
||||
|
||||
# Create non-root user and a writable data directory before copying app code
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
mkdir -p /app/data && \
|
||||
chown nodejs:nodejs /app/data
|
||||
|
||||
# Copy application code (exclude node_modules via .dockerignore)
|
||||
COPY --chown=nodejs:nodejs . .
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) })"
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
311
fcm-app/FCM_IMPLEMENTATION_NOTES.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# FCM PWA Implementation Notes
|
||||
_Reference for applying FCM fixes to other projects_
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Guide Key Points (fcm_details.txt)
|
||||
|
||||
### How FCM works (the correct flow)
|
||||
1. User grants notification permission
|
||||
2. Firebase generates a unique FCM token for the device
|
||||
3. Token is stored on your server for targeting
|
||||
4. Server sends push requests to Firebase
|
||||
5. Firebase delivers notifications to the device
|
||||
6. Service worker handles display and click interactions
|
||||
|
||||
### Common vibe-coding failures with FCM
|
||||
|
||||
**1. Service worker confusion**
|
||||
Auto-generated setups often register multiple service workers or put Firebase logic in the wrong file. The dedicated `firebase-messaging-sw.js` must be served from root scope. Splitting logic across a redirect stub (`importScripts('/sw.js')`) causes background notifications to silently fail.
|
||||
|
||||
**2. Deprecated API usage**
|
||||
Using `messaging.usePublicVapidKey()` and `messaging.useServiceWorker()` instead of passing options directly to `getToken()`. The correct modern pattern is:
|
||||
```javascript
|
||||
const token = await messaging.getToken({
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
```
|
||||
|
||||
**3. Token generation without durable storage**
|
||||
Tokens disappear when users switch devices, clear storage, or the server restarts. Without a persistent store (file, database) and proper Docker volume mounts, tokens are lost on every restart.
|
||||
|
||||
**4. Poor permission flow**
|
||||
Requesting notification permission immediately on page load gets denied by users. Permission should be requested on a meaningful user action (e.g. login), not on first visit.
|
||||
|
||||
**5. Missing notificationclick handler**
|
||||
Without a `notificationclick` handler in the service worker, clicking a notification does nothing. Users expect it to open or focus the app.
|
||||
|
||||
**6. Silent failures**
|
||||
Tokens can be null, service workers can fail to register, VAPID keys can be wrong — and nothing surfaces in the UI. Every layer needs explicit error checking and user-visible feedback.
|
||||
|
||||
**7. iOS blind spots**
|
||||
iOS requires the PWA to be added to the home screen, strict HTTPS, and a correctly structured manifest. Test on real iOS devices, not just Chrome on Android/desktop.
|
||||
|
||||
### Correct `getToken()` pattern (from guide)
|
||||
```javascript
|
||||
// Register SW first, then pass it directly to getToken
|
||||
const registration = await navigator.serviceWorker.register('/firebase-messaging-sw.js');
|
||||
const token = await getToken(messaging, {
|
||||
vapidKey: VAPID_KEY,
|
||||
serviceWorkerRegistration: registration
|
||||
});
|
||||
if (!token) throw new Error('getToken() returned empty — check VAPID key and SW');
|
||||
```
|
||||
|
||||
### Correct `firebase-messaging-sw.js` pattern (from guide)
|
||||
```javascript
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({ /* config */ });
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
self.registration.showNotification(payload.notification.title, {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-notification',
|
||||
data: payload.data
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
if (event.action === 'close') return;
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) return client.focus();
|
||||
}
|
||||
if (clients.openWindow) return clients.openWindow('/');
|
||||
})
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Code Fixes Applied to fcm-app
|
||||
|
||||
### app.js fixes
|
||||
|
||||
**Fix: `showUserInfo()` missing**
|
||||
Function was called on login and session restore but never defined — crashed immediately on login.
|
||||
```javascript
|
||||
function showUserInfo() {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('userInfo').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setupApp()` wrong element IDs**
|
||||
`getElementById('sendNotification')` and `getElementById('logoutBtn')` returned null — no element with those IDs existed in the HTML.
|
||||
```javascript
|
||||
// Wrong
|
||||
document.getElementById('sendNotification').addEventListener('click', sendNotification);
|
||||
// Fixed
|
||||
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||
// Also added id="logoutBtn" to the logout button in index.html
|
||||
```
|
||||
|
||||
**Fix: `logout()` not clearing localStorage**
|
||||
Session was restored on next page load even after logout.
|
||||
```javascript
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
fcmToken = null;
|
||||
localStorage.removeItem('currentUser'); // was missing
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Race condition in messaging initialization**
|
||||
`initializeFirebase()` was fire-and-forget. When called again from `login()`, it returned early setting `messaging = firebase.messaging()` without the VAPID key or SW being configured. Now returns and caches a promise:
|
||||
```javascript
|
||||
let initPromise = null;
|
||||
function initializeFirebase() {
|
||||
if (initPromise) return initPromise;
|
||||
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
swRegistration = registration;
|
||||
messaging = firebase.messaging();
|
||||
})
|
||||
.catch((error) => { initPromise = null; throw error; });
|
||||
return initPromise;
|
||||
}
|
||||
// In login():
|
||||
await initializeFirebase(); // ensures messaging is ready before getToken()
|
||||
```
|
||||
|
||||
**Fix: `deleteToken()` invalidating tokens on every page load**
|
||||
`deleteToken()` was called on every page load, invalidating the push subscription. The server still held the old (now invalid) token. When another device sent, the stale token failed and `recipients` stayed 0.
|
||||
Solution: removed `deleteToken()` entirely — it's not needed when `serviceWorkerRegistration` is passed directly to `getToken()`.
|
||||
|
||||
**Fix: Session restore without re-registering token**
|
||||
When a user's session was restored from localStorage, `showUserInfo()` was called but no new FCM token was generated or sent to the server. After a server restart the server had no record of the token.
|
||||
```javascript
|
||||
// In setupApp(), after restoring session:
|
||||
if (Notification.permission === 'granted') {
|
||||
initializeFirebase()
|
||||
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Deprecated VAPID/SW API replaced**
|
||||
```javascript
|
||||
// Removed (deprecated):
|
||||
messaging.usePublicVapidKey(VAPID_KEY);
|
||||
messaging.useServiceWorker(registration);
|
||||
const token = await messaging.getToken();
|
||||
|
||||
// Replaced with:
|
||||
const VAPID_KEY = 'your-vapid-key';
|
||||
let swRegistration = null;
|
||||
// swRegistration set inside initializeFirebase() .then()
|
||||
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||
```
|
||||
|
||||
**Fix: Null token guard**
|
||||
`getToken()` can return null — passing null to the server produced a confusing 400 error.
|
||||
```javascript
|
||||
if (!token) {
|
||||
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: Error message included server response**
|
||||
```javascript
|
||||
// Before: throw new Error('Failed to register token');
|
||||
// After:
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
```
|
||||
|
||||
**Fix: Duplicate foreground message handlers**
|
||||
`handleForegroundMessages()` was called on every login, stacking up `onMessage` listeners.
|
||||
```javascript
|
||||
let foregroundHandlerSetup = false;
|
||||
function handleForegroundMessages() {
|
||||
if (foregroundHandlerSetup) return;
|
||||
foregroundHandlerSetup = true;
|
||||
messaging.onMessage(/* ... */);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `login()` event.preventDefault() crash**
|
||||
Button called `login()` with no argument, so `event.preventDefault()` threw on undefined.
|
||||
```javascript
|
||||
async function login(event) {
|
||||
if (event) event.preventDefault(); // guard added
|
||||
```
|
||||
|
||||
**Fix: `firebase-messaging-sw.js` redirect stub replaced**
|
||||
File was `importScripts('/sw.js')` — a vibe-code anti-pattern. Replaced with full Firebase messaging setup including `onBackgroundMessage` and `notificationclick` handler (see Part 1 pattern above).
|
||||
|
||||
**Fix: `notificationclick` handler added to `sw.js`**
|
||||
Clicking a background notification did nothing. Handler added to focus existing window or open a new one.
|
||||
|
||||
**Fix: CDN URLs removed from `urlsToCache` in `sw.js`**
|
||||
External CDN URLs in `cache.addAll()` can fail on opaque responses, breaking the entire SW install.
|
||||
```javascript
|
||||
// Removed from urlsToCache:
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js',
|
||||
// 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js'
|
||||
```
|
||||
|
||||
### server.js fixes
|
||||
|
||||
**Fix: `icon`/`badge`/`tag` in wrong notification object**
|
||||
These fields are only valid in `webpush.notification`, not the top-level `notification` (which only accepts `title`, `body`, `imageUrl`).
|
||||
```javascript
|
||||
// Wrong:
|
||||
notification: { title, body, icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' }
|
||||
// Fixed:
|
||||
notification: { title, body },
|
||||
webpush: {
|
||||
notification: { icon: '/icon-192.png', badge: '/icon-192.png', tag: 'fcm-test' },
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `saveTokens()` in route handler not crash-safe**
|
||||
```javascript
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (saveError) {
|
||||
console.error('Failed to persist tokens to disk:', saveError);
|
||||
}
|
||||
```
|
||||
|
||||
**Fix: `setInterval(saveTokens)` uncaught exception crashed the server**
|
||||
An unhandled throw inside `setInterval` exits the Node.js process. Docker restarts it with empty state.
|
||||
```javascript
|
||||
setInterval(() => {
|
||||
try { saveTokens(); }
|
||||
catch (error) { console.error('Auto-save tokens failed:', error); }
|
||||
}, 30000);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Part 3 — Docker / Infrastructure Fixes
|
||||
|
||||
### Root cause of "no other users" bug
|
||||
The server was crashing every ~30 seconds, wiping all registered tokens from memory. The crash chain:
|
||||
1. `saveTokens()` threw `EACCES: permission denied` (nodejs user can't write to root-owned `/app`)
|
||||
2. This propagated out of `setInterval` as an uncaught exception
|
||||
3. Node.js exited the process
|
||||
4. Docker restarted the container with empty state
|
||||
5. Tokens were never on disk, so restart = all tokens lost
|
||||
|
||||
### Dockerfile fix
|
||||
```dockerfile
|
||||
# Create non-root user AND a writable data directory (while still root)
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001 && \
|
||||
mkdir -p /app/data && \
|
||||
chown nodejs:nodejs /app/data
|
||||
```
|
||||
`WORKDIR /app` is root-owned — the `nodejs` user can only write to subdirectories explicitly granted to it.
|
||||
|
||||
### docker-compose.yml fix
|
||||
```yaml
|
||||
services:
|
||||
your-app:
|
||||
volumes:
|
||||
- app_data:/app/data # named volume survives container rebuilds
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
```
|
||||
Without this, `tokens.json` lives in the container's ephemeral layer and is deleted on every `docker-compose up --build`.
|
||||
|
||||
### server.js path fix
|
||||
```javascript
|
||||
// Changed from:
|
||||
const TOKENS_FILE = './tokens.json';
|
||||
// To:
|
||||
const TOKENS_FILE = './data/tokens.json';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist for applying to another project
|
||||
|
||||
- [ ] `firebase-messaging-sw.js` contains real FCM logic (not a redirect stub)
|
||||
- [ ] `notificationclick` handler present in service worker
|
||||
- [ ] CDN URLs NOT in `urlsToCache` in any service worker
|
||||
- [ ] `initializeFirebase()` returns a promise; login awaits it before calling `getToken()`
|
||||
- [ ] `getToken()` receives `{ vapidKey, serviceWorkerRegistration }` directly — no deprecated `usePublicVapidKey` / `useServiceWorker`
|
||||
- [ ] `deleteToken()` is NOT called on page load
|
||||
- [ ] Session restore re-registers FCM token if `Notification.permission === 'granted'`
|
||||
- [ ] Null/empty token check before sending to server
|
||||
- [ ] `icon`/`badge`/`tag` are in `webpush.notification`, not top-level `notification`
|
||||
- [ ] `saveTokens()` (or equivalent) wrapped in try-catch everywhere it's called including `setInterval`
|
||||
- [ ] Docker: data directory created with correct user ownership in Dockerfile
|
||||
- [ ] Docker: named volume mounted for data directory in docker-compose.yml
|
||||
- [ ] Duplicate foreground message handler registration is guarded
|
||||
209
fcm-app/README.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# FCM Test PWA
|
||||
|
||||
A Progressive Web App for testing Firebase Cloud Messaging (FCM) notifications across desktop and mobile devices.
|
||||
|
||||
## Features
|
||||
|
||||
- PWA with install capability
|
||||
- Firebase Cloud Messaging integration
|
||||
- Multi-user support (pwau1, pwau2, pwau3)
|
||||
- SSL/HTTPS ready
|
||||
- Docker deployment
|
||||
- Real-time notifications
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Firebase Setup
|
||||
|
||||
1. **Create Firebase Project**
|
||||
- Go to [Firebase Console](https://console.firebase.google.com/)
|
||||
- Click "Add project"
|
||||
- Enter project name (e.g., "fcm-test-pwa")
|
||||
- Enable Google Analytics (optional)
|
||||
- Click "Create project"
|
||||
|
||||
2. **Enable Cloud Messaging**
|
||||
- In your project dashboard, go to "Build" → "Cloud Messaging"
|
||||
- Click "Get started"
|
||||
- Cloud Messaging is now enabled for your project
|
||||
|
||||
3. **Get Firebase Configuration**
|
||||
- Go to Project Settings (⚙️ icon)
|
||||
- Under "Your apps", click "Web app" (</> icon)
|
||||
- Register app with nickname "FCM Test PWA"
|
||||
- Copy the Firebase config object (you'll need this later)
|
||||
|
||||
4. **Generate Service Account Key**
|
||||
- In Project Settings, go to "Service accounts"
|
||||
- Click "Generate new private key"
|
||||
- Save the JSON file (you'll need this for the server)
|
||||
|
||||
5. **Get Web Push Certificate**
|
||||
- In Cloud Messaging settings, click "Web Push certificates"
|
||||
- Generate and save the key pair
|
||||
|
||||
### 2. Server Configuration
|
||||
|
||||
1. **Copy environment template**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
2. **Update .env file** with your Firebase credentials:
|
||||
```env
|
||||
FIREBASE_PROJECT_ID=your-project-id
|
||||
FIREBASE_PRIVATE_KEY_ID=your-private-key-id
|
||||
FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n"
|
||||
FIREBASE_CLIENT_EMAIL=firebase-adminsdk-...@your-project-id.iam.gserviceaccount.com
|
||||
FIREBASE_CLIENT_ID=your-client-id
|
||||
# ... other fields from service account JSON
|
||||
```
|
||||
|
||||
3. **Update Firebase config in client files**:
|
||||
- Edit `public/app.js` - replace Firebase config
|
||||
- Edit `public/sw.js` - replace Firebase config
|
||||
|
||||
### 3. Local Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 in your browser.
|
||||
|
||||
### 4. Docker Deployment
|
||||
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
```
|
||||
|
||||
## User Accounts
|
||||
|
||||
| Username | Password | Purpose |
|
||||
|----------|----------|---------|
|
||||
| pwau1 | test123 | Desktop user |
|
||||
| pwau2 | test123 | Mobile user 1 |
|
||||
| pwau3 | test123 | Mobile user 2 |
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Install as PWA**
|
||||
- Open the app in Chrome/Firefox
|
||||
- Click the install icon in the address bar
|
||||
- Install as a desktop app
|
||||
|
||||
2. **Enable Notifications**
|
||||
- Login with any user account
|
||||
- Grant notification permissions when prompted
|
||||
- FCM token will be automatically registered
|
||||
|
||||
3. **Send Notifications**
|
||||
- Click "Send Notification" button
|
||||
- All other logged-in users will receive the notification
|
||||
- Check both desktop and mobile devices
|
||||
|
||||
## Deployment on Ubuntu LXC + HAProxy
|
||||
|
||||
### Prerequisites
|
||||
|
||||
```bash
|
||||
# Update system
|
||||
sudo apt update && sudo apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
|
||||
# Install Docker Compose
|
||||
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
### SSL Certificate Setup
|
||||
|
||||
```bash
|
||||
# Create SSL directory
|
||||
mkdir -p ssl
|
||||
|
||||
# Generate self-signed certificate (for testing)
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout ssl/key.pem \
|
||||
-out ssl/cert.pem \
|
||||
-subj "/C=US/ST=State/L=City/O=Organization/CN=your-domain.com"
|
||||
|
||||
# OR use Let's Encrypt for production
|
||||
sudo apt install certbot
|
||||
sudo certbot certonly --standalone -d your-domain.com
|
||||
sudo cp /etc/letsencrypt/live/your-domain.com/fullchain.pem ssl/cert.pem
|
||||
sudo cp /etc/letsencrypt/live/your-domain.com/privkey.pem ssl/key.pem
|
||||
```
|
||||
|
||||
### HAProxy Configuration
|
||||
|
||||
Add to your `/etc/haproxy/haproxy.cfg`:
|
||||
|
||||
```haproxy
|
||||
frontend fcm_test_frontend
|
||||
bind *:80
|
||||
bind *:443 ssl crt /etc/ssl/certs/your-cert.pem
|
||||
redirect scheme https if !{ ssl_fc }
|
||||
default_backend fcm_test_backend
|
||||
|
||||
backend fcm_test_backend
|
||||
balance roundrobin
|
||||
server fcm_test 127.0.0.1:3000 check
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
```bash
|
||||
# Clone and setup
|
||||
git clone <your-repo>
|
||||
cd fcm-test-pwa
|
||||
cp .env.example .env
|
||||
# Edit .env with your Firebase config
|
||||
|
||||
# Deploy
|
||||
docker-compose up -d
|
||||
|
||||
# Check status
|
||||
docker-compose ps
|
||||
docker-compose logs
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
1. **Desktop Testing**
|
||||
- Open app in Chrome
|
||||
- Install as PWA
|
||||
- Login as pwau1
|
||||
- Send test notifications
|
||||
|
||||
2. **Mobile Testing**
|
||||
- Open app on mobile browsers
|
||||
- Install as PWA
|
||||
- Login as pwau2 and pwau3 on different devices
|
||||
- Test cross-device notifications
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **Notifications not working**: Check Firebase configuration and service worker
|
||||
- **PWA not installing**: Ensure site is served over HTTPS
|
||||
- **Docker issues**: Check logs with `docker-compose logs`
|
||||
- **HAProxy issues**: Verify SSL certificates and backend connectivity
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Change default passwords in production
|
||||
- Use proper SSL certificates
|
||||
- Implement rate limiting for notifications
|
||||
- Consider using a database for token storage in production
|
||||
22
fcm-app/docker-compose.yml
Normal file
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
fcm-test-app:
|
||||
build: .
|
||||
ports:
|
||||
- "3066:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- TZ=${TZ:-UTC}
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- fcm_data:/app/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
volumes:
|
||||
fcm_data:
|
||||
1013
fcm-app/fcm_details.txt
Normal file
57
fcm-app/nginx.conf
Normal file
@@ -0,0 +1,57 @@
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
|
||||
http {
|
||||
upstream app {
|
||||
server fcm-test-app:3000;
|
||||
}
|
||||
|
||||
# HTTP to HTTPS redirect
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS server
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name your-domain.com;
|
||||
|
||||
# SSL configuration
|
||||
ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_prefer_server_ciphers off;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
# PWA headers
|
||||
add_header Service-Worker-Allowed "/";
|
||||
|
||||
location / {
|
||||
proxy_pass http://app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Serve static files directly
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|json|webmanifest)$ {
|
||||
proxy_pass http://app;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
}
|
||||
23
fcm-app/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "fcm-test-pwa",
|
||||
"version": "1.0.0",
|
||||
"description": "PWA for testing Firebase Cloud Messaging",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"firebase": "^10.7.1",
|
||||
"firebase-admin": "^12.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"keywords": ["pwa", "fcm", "firebase", "notifications"],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
}
|
||||
334
fcm-app/public/app.js
Normal file
@@ -0,0 +1,334 @@
|
||||
// Load Firebase SDK immediately
|
||||
const script1 = document.createElement('script');
|
||||
script1.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js';
|
||||
script1.onload = () => {
|
||||
const script2 = document.createElement('script');
|
||||
script2.src = 'https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js';
|
||||
script2.onload = () => {
|
||||
// Initialize Firebase immediately
|
||||
initializeFirebase();
|
||||
console.log('Firebase SDK and initialization complete');
|
||||
|
||||
// Now that Firebase is ready, set up the app
|
||||
setupApp();
|
||||
};
|
||||
document.head.appendChild(script2);
|
||||
};
|
||||
document.head.appendChild(script1);
|
||||
|
||||
// Global variables
|
||||
let currentUser = null;
|
||||
let fcmToken = null;
|
||||
let messaging = null;
|
||||
let swRegistration = null;
|
||||
let initPromise = null;
|
||||
let foregroundHandlerSetup = false;
|
||||
|
||||
const VAPID_KEY = 'BE6hPKkbf-h0lUQ1tYo249pBOdZFFcWQn9suwg3NDwSE8C_hv8hk1dUY9zxHBQEChO_IAqyFZplF_SUb5c4Ofrw';
|
||||
|
||||
// Simple user authentication
|
||||
const users = {
|
||||
'pwau1': { password: 'test123', name: 'Desktop User' },
|
||||
'pwau2': { password: 'test123', name: 'Mobile User 1' },
|
||||
'pwau3': { password: 'test123', name: 'Mobile User 2' }
|
||||
};
|
||||
|
||||
// Initialize Firebase — returns a promise that resolves when messaging is ready
|
||||
function initializeFirebase() {
|
||||
if (initPromise) return initPromise;
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
|
||||
authDomain: "fcmtest-push.firebaseapp.com",
|
||||
projectId: "fcmtest-push",
|
||||
storageBucket: "fcmtest-push.firebasestorage.app",
|
||||
messagingSenderId: "439263996034",
|
||||
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
|
||||
};
|
||||
|
||||
if (firebase.apps.length === 0) {
|
||||
firebase.initializeApp(firebaseConfig);
|
||||
console.log('Firebase app initialized');
|
||||
}
|
||||
|
||||
initPromise = navigator.serviceWorker.register('/sw.js')
|
||||
.then((registration) => {
|
||||
console.log('Service Worker registered:', registration);
|
||||
swRegistration = registration;
|
||||
messaging = firebase.messaging();
|
||||
console.log('Firebase messaging initialized successfully');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Service Worker registration failed:', error);
|
||||
initPromise = null;
|
||||
throw error;
|
||||
});
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
// Show user info panel and hide login form
|
||||
function showUserInfo() {
|
||||
document.getElementById('loginForm').style.display = 'none';
|
||||
document.getElementById('userInfo').style.display = 'block';
|
||||
document.getElementById('currentUser').textContent = users[currentUser]?.name || currentUser;
|
||||
}
|
||||
|
||||
// Setup app after Firebase is ready
|
||||
function setupApp() {
|
||||
// Set up event listeners
|
||||
document.getElementById('loginForm').addEventListener('submit', login);
|
||||
document.getElementById('sendNotificationBtn').addEventListener('click', sendNotification);
|
||||
document.getElementById('logoutBtn').addEventListener('click', logout);
|
||||
|
||||
// Restore session and re-register FCM token if notifications were already granted
|
||||
const savedUser = localStorage.getItem('currentUser');
|
||||
if (savedUser) {
|
||||
currentUser = savedUser;
|
||||
showUserInfo();
|
||||
if (Notification.permission === 'granted') {
|
||||
initializeFirebase()
|
||||
.then(() => messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration }))
|
||||
.then(token => { if (token) return registerToken(currentUser, token); })
|
||||
.catch(err => console.error('Token refresh on session restore failed:', err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Request notification permission and get FCM token
|
||||
async function requestNotificationPermission() {
|
||||
try {
|
||||
console.log('Requesting notification permission...');
|
||||
const permission = await Notification.requestPermission();
|
||||
console.log('Permission result:', permission);
|
||||
|
||||
if (permission === 'granted') {
|
||||
console.log('Notification permission granted.');
|
||||
showStatus('Getting FCM token...', 'info');
|
||||
|
||||
try {
|
||||
const token = await messaging.getToken({ vapidKey: VAPID_KEY, serviceWorkerRegistration: swRegistration });
|
||||
console.log('FCM Token generated:', token);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('getToken() returned empty — check VAPID key and service worker');
|
||||
}
|
||||
|
||||
fcmToken = token;
|
||||
|
||||
// Send token to server
|
||||
await registerToken(currentUser, token);
|
||||
showStatus('Notifications enabled successfully!', 'success');
|
||||
} catch (tokenError) {
|
||||
console.error('Error getting FCM token:', tokenError);
|
||||
showStatus('Failed to get FCM token: ' + tokenError.message, 'error');
|
||||
}
|
||||
} else {
|
||||
console.log('Notification permission denied.');
|
||||
showStatus('Notification permission denied.', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error requesting notification permission:', error);
|
||||
showStatus('Failed to enable notifications: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Register FCM token with server
|
||||
async function registerToken(username, token) {
|
||||
try {
|
||||
console.log('Attempting to register token:', { username, token: token.substring(0, 20) + '...' });
|
||||
|
||||
const response = await fetch('/register-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, token })
|
||||
});
|
||||
|
||||
console.log('Registration response status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Server returned ${response.status}: ${errorText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Token registered successfully:', result);
|
||||
showStatus(`Token registered for ${username}`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error registering token:', error);
|
||||
showStatus('Failed to register token with server: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle foreground messages (guard against duplicate registration)
|
||||
function handleForegroundMessages() {
|
||||
if (foregroundHandlerSetup) return;
|
||||
foregroundHandlerSetup = true;
|
||||
messaging.onMessage(function(payload) {
|
||||
console.log('Received foreground message: ', payload);
|
||||
|
||||
// Show notification in foreground
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png'
|
||||
};
|
||||
|
||||
new Notification(notificationTitle, notificationOptions);
|
||||
showStatus(`New notification: ${payload.notification.body}`, 'info');
|
||||
});
|
||||
}
|
||||
|
||||
// Login function
|
||||
async function login(event) {
|
||||
if (event) event.preventDefault();
|
||||
|
||||
const username = document.getElementById('username').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
if (!users[username] || users[username].password !== password) {
|
||||
showStatus('Invalid username or password', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
currentUser = username;
|
||||
localStorage.setItem('currentUser', username);
|
||||
showUserInfo();
|
||||
|
||||
showStatus(`Logged in as ${users[username].name}`, 'success');
|
||||
|
||||
// Initialize Firebase and request notifications
|
||||
if (typeof firebase !== 'undefined') {
|
||||
await initializeFirebase();
|
||||
await requestNotificationPermission();
|
||||
handleForegroundMessages();
|
||||
} else {
|
||||
showStatus('Firebase not loaded. Please check your connection.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Logout function
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
fcmToken = null;
|
||||
localStorage.removeItem('currentUser');
|
||||
document.getElementById('loginForm').style.display = 'block';
|
||||
document.getElementById('userInfo').style.display = 'none';
|
||||
document.getElementById('username').value = '';
|
||||
document.getElementById('password').value = '';
|
||||
showStatus('Logged out successfully.', 'info');
|
||||
}
|
||||
|
||||
// Send notification function
|
||||
async function sendNotification() {
|
||||
if (!currentUser) {
|
||||
showStatus('Please login first.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// First check registered users
|
||||
const usersResponse = await fetch('/users');
|
||||
const users = await usersResponse.json();
|
||||
console.log('Registered users:', users);
|
||||
|
||||
const response = await fetch('/send-notification', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fromUser: currentUser,
|
||||
title: 'Test Notification',
|
||||
body: `Notification sent from ${currentUser} at ${new Date().toLocaleTimeString()}`
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to send notification');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Send result:', result);
|
||||
|
||||
if (result.recipients === 0) {
|
||||
showStatus('No other users have registered tokens. Open the app on other devices and enable notifications.', 'error');
|
||||
} else {
|
||||
showStatus(`Notification sent to ${result.recipients} user(s)!`, 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending notification:', error);
|
||||
showStatus('Failed to send notification.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Show status message
|
||||
function showStatus(message, type) {
|
||||
const statusEl = document.getElementById('status');
|
||||
statusEl.textContent = message;
|
||||
statusEl.className = `status ${type}`;
|
||||
statusEl.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
statusEl.style.display = 'none';
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Register service worker and handle PWA installation
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(function(registration) {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
|
||||
// Handle PWA installation
|
||||
let deferredPrompt;
|
||||
|
||||
window.addEventListener('beforeinstallprompt', (e) => {
|
||||
console.log('beforeinstallprompt fired');
|
||||
e.preventDefault();
|
||||
deferredPrompt = e;
|
||||
|
||||
// Show install button or banner
|
||||
showInstallButton();
|
||||
});
|
||||
|
||||
function showInstallButton() {
|
||||
const installBtn = document.createElement('button');
|
||||
installBtn.textContent = 'Install App';
|
||||
installBtn.style.cssText = `
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
background: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
z-index: 1000;
|
||||
font-size: 14px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
`;
|
||||
|
||||
installBtn.addEventListener('click', async () => {
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
console.log(`User response to the install prompt: ${outcome}`);
|
||||
deferredPrompt = null;
|
||||
installBtn.remove();
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(installBtn);
|
||||
}
|
||||
})
|
||||
.catch(function(error) {
|
||||
console.log('ServiceWorker registration failed: ', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
48
fcm-app/public/firebase-messaging-sw.js
Normal file
@@ -0,0 +1,48 @@
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
|
||||
authDomain: "fcmtest-push.firebaseapp.com",
|
||||
projectId: "fcmtest-push",
|
||||
storageBucket: "fcmtest-push.firebasestorage.app",
|
||||
messagingSenderId: "439263996034",
|
||||
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
messaging.onBackgroundMessage(function(payload) {
|
||||
console.log('Received background message:', payload);
|
||||
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-test',
|
||||
data: payload.data
|
||||
};
|
||||
|
||||
self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
console.log('Notification clicked:', event);
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') return;
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
BIN
fcm-app/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
fcm-app/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
111
fcm-app/public/index.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FCM Test PWA</title>
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#2196F3">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
.login-form {
|
||||
display: block;
|
||||
}
|
||||
.user-info {
|
||||
display: none;
|
||||
}
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
button:hover {
|
||||
background-color: #1976D2;
|
||||
}
|
||||
button:disabled {
|
||||
background-color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.status {
|
||||
padding: 10px;
|
||||
margin: 10px 0;
|
||||
border-radius: 5px;
|
||||
text-align: center;
|
||||
}
|
||||
.success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.error {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.info {
|
||||
background-color: #d1ecf1;
|
||||
color: #0c5460;
|
||||
border: 1px solid #bee5eb;
|
||||
}
|
||||
.user-display {
|
||||
background-color: #e3f2fd;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>FCM Test PWA</h1>
|
||||
|
||||
<div id="status" class="status" style="display: none;"></div>
|
||||
|
||||
<div id="loginForm" class="login-form">
|
||||
<h2>Login</h2>
|
||||
<input type="text" id="username" placeholder="Username (pwau1, pwau2, or pwau3)" required>
|
||||
<input type="password" id="password" placeholder="Password" required>
|
||||
<button onclick="login()">Login</button>
|
||||
</div>
|
||||
|
||||
<div id="userInfo" class="user-info">
|
||||
<div class="user-display">
|
||||
Logged in as: <span id="currentUser"></span>
|
||||
</div>
|
||||
<button id="sendNotificationBtn" onclick="sendNotification()">Send Notification</button>
|
||||
<button id="logoutBtn" onclick="logout()">Logout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
fcm-app/public/manifest.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "FCM Test PWA",
|
||||
"short_name": "FCM Test",
|
||||
"description": "PWA for testing Firebase Cloud Messaging",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone"],
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#2196F3",
|
||||
"orientation": "portrait-primary",
|
||||
"scope": "/",
|
||||
"icons": [
|
||||
{
|
||||
"purpose": "any maskable",
|
||||
"src": "/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "any maskable",
|
||||
"src": "/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"categories": ["utilities", "productivity"],
|
||||
"lang": "en-US"
|
||||
}
|
||||
82
fcm-app/public/sw.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const CACHE_NAME = 'fcm-test-pwa-v1';
|
||||
const urlsToCache = [
|
||||
'/',
|
||||
'/index.html',
|
||||
'/app.js',
|
||||
'/manifest.json'
|
||||
];
|
||||
|
||||
// Install event
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(
|
||||
caches.open(CACHE_NAME)
|
||||
.then(function(cache) {
|
||||
return cache.addAll(urlsToCache);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Fetch event
|
||||
self.addEventListener('fetch', function(event) {
|
||||
event.respondWith(
|
||||
caches.match(event.request)
|
||||
.then(function(response) {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Background sync for FCM
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-app-compat.js');
|
||||
importScripts('https://www.gstatic.com/firebasejs/10.7.1/firebase-messaging-compat.js');
|
||||
|
||||
// Initialize Firebase in service worker
|
||||
firebase.initializeApp({
|
||||
apiKey: "AIzaSyAw1v4COZ68Po8CuwVKrQq0ygf7zFd2QCA",
|
||||
authDomain: "fcmtest-push.firebaseapp.com",
|
||||
projectId: "fcmtest-push",
|
||||
storageBucket: "fcmtest-push.firebasestorage.app",
|
||||
messagingSenderId: "439263996034",
|
||||
appId: "1:439263996034:web:9b3d52af2c402e65fdec9b"
|
||||
});
|
||||
|
||||
const messaging = firebase.messaging();
|
||||
|
||||
// Handle notification clicks
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
console.log('Notification clicked:', event);
|
||||
event.notification.close();
|
||||
|
||||
if (event.action === 'close') return;
|
||||
|
||||
event.waitUntil(
|
||||
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(clientList) {
|
||||
for (const client of clientList) {
|
||||
if (client.url === '/' && 'focus' in client) {
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
if (clients.openWindow) {
|
||||
return clients.openWindow('/');
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// Handle background messages
|
||||
messaging.onBackgroundMessage(function(payload) {
|
||||
console.log('Received background message ', payload);
|
||||
|
||||
const notificationTitle = payload.notification.title;
|
||||
const notificationOptions = {
|
||||
body: payload.notification.body,
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-test'
|
||||
};
|
||||
|
||||
return self.registration.showNotification(notificationTitle, notificationOptions);
|
||||
});
|
||||
244
fcm-app/server.js
Normal file
@@ -0,0 +1,244 @@
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const cors = require('cors');
|
||||
const admin = require('firebase-admin');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
app.use(express.static('public'));
|
||||
|
||||
// In-memory storage for FCM tokens (in production, use a database)
|
||||
const userTokens = new Map();
|
||||
|
||||
// Load tokens from file on startup (for persistence)
|
||||
const fs = require('fs');
|
||||
const TOKENS_FILE = './data/tokens.json';
|
||||
|
||||
function loadTokens() {
|
||||
try {
|
||||
if (fs.existsSync(TOKENS_FILE)) {
|
||||
const data = fs.readFileSync(TOKENS_FILE, 'utf8');
|
||||
const tokens = JSON.parse(data);
|
||||
for (const [user, tokenArray] of Object.entries(tokens)) {
|
||||
userTokens.set(user, new Set(tokenArray));
|
||||
}
|
||||
console.log(`Loaded tokens for ${userTokens.size} users from file`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('No existing tokens file found, starting fresh');
|
||||
}
|
||||
}
|
||||
|
||||
function saveTokens() {
|
||||
const tokens = {};
|
||||
for (const [user, tokenSet] of userTokens.entries()) {
|
||||
tokens[user] = Array.from(tokenSet);
|
||||
}
|
||||
fs.writeFileSync(TOKENS_FILE, JSON.stringify(tokens, null, 2));
|
||||
}
|
||||
|
||||
// Load existing tokens on startup
|
||||
loadTokens();
|
||||
|
||||
// Auto-save tokens every 30 seconds
|
||||
setInterval(() => {
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (error) {
|
||||
console.error('Auto-save tokens failed:', error);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Initialize Firebase Admin
|
||||
if (process.env.FIREBASE_PRIVATE_KEY) {
|
||||
const serviceAccount = {
|
||||
projectId: process.env.FIREBASE_PROJECT_ID,
|
||||
privateKeyId: process.env.FIREBASE_PRIVATE_KEY_ID,
|
||||
privateKey: process.env.FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'),
|
||||
clientEmail: process.env.FIREBASE_CLIENT_EMAIL,
|
||||
clientId: process.env.FIREBASE_CLIENT_ID,
|
||||
authUri: process.env.FIREBASE_AUTH_URI,
|
||||
tokenUri: process.env.FIREBASE_TOKEN_URI,
|
||||
authProviderX509CertUrl: process.env.FIREBASE_AUTH_PROVIDER_X509_CERT_URL,
|
||||
clientC509CertUrl: process.env.FIREBASE_CLIENT_X509_CERT_URL
|
||||
};
|
||||
|
||||
admin.initializeApp({
|
||||
credential: admin.credential.cert(serviceAccount)
|
||||
});
|
||||
|
||||
console.log('Firebase Admin initialized successfully');
|
||||
} else {
|
||||
console.log('Firebase Admin not configured. Please set up .env file');
|
||||
}
|
||||
|
||||
// Routes
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Register FCM token
|
||||
app.post('/register-token', (req, res) => {
|
||||
const { username, token } = req.body;
|
||||
|
||||
console.log(`Token registration request:`, { username, token: token?.substring(0, 20) + '...' });
|
||||
|
||||
if (!username || !token) {
|
||||
console.log('Token registration failed: missing username or token');
|
||||
return res.status(400).json({ error: 'Username and token are required' });
|
||||
}
|
||||
|
||||
// Store token for user
|
||||
if (!userTokens.has(username)) {
|
||||
userTokens.set(username, new Set());
|
||||
}
|
||||
|
||||
const userTokenSet = userTokens.get(username);
|
||||
if (userTokenSet.has(token)) {
|
||||
console.log(`Token already registered for user: ${username}`);
|
||||
} else {
|
||||
userTokenSet.add(token);
|
||||
console.log(`New token registered for user: ${username}`);
|
||||
// Save immediately after new registration
|
||||
try {
|
||||
saveTokens();
|
||||
} catch (saveError) {
|
||||
console.error('Failed to persist tokens to disk:', saveError);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total tokens for ${username}: ${userTokenSet.size}`);
|
||||
console.log(`Total registered users: ${userTokens.size}`);
|
||||
|
||||
res.json({ success: true, message: 'Token registered successfully' });
|
||||
});
|
||||
|
||||
// Send notification to all other users
|
||||
app.post('/send-notification', async (req, res) => {
|
||||
const { fromUser, title, body } = req.body;
|
||||
|
||||
if (!fromUser || !title || !body) {
|
||||
return res.status(400).json({ error: 'fromUser, title, and body are required' });
|
||||
}
|
||||
|
||||
if (!admin.apps.length) {
|
||||
return res.status(500).json({ error: 'Firebase Admin not initialized' });
|
||||
}
|
||||
|
||||
try {
|
||||
let totalRecipients = 0;
|
||||
const promises = [];
|
||||
|
||||
// Send to all users except the sender
|
||||
for (const [username, tokens] of userTokens.entries()) {
|
||||
if (username === fromUser) continue; // Skip sender
|
||||
|
||||
for (const token of tokens) {
|
||||
const message = {
|
||||
token: token,
|
||||
notification: {
|
||||
title: title,
|
||||
body: body
|
||||
},
|
||||
webpush: {
|
||||
headers: {
|
||||
'Urgency': 'high'
|
||||
},
|
||||
notification: {
|
||||
icon: '/icon-192.png',
|
||||
badge: '/icon-192.png',
|
||||
tag: 'fcm-test'
|
||||
},
|
||||
fcm_options: {
|
||||
link: '/'
|
||||
}
|
||||
},
|
||||
android: {
|
||||
priority: 'high',
|
||||
notification: {
|
||||
sound: 'default',
|
||||
click_action: '/'
|
||||
}
|
||||
},
|
||||
apns: {
|
||||
payload: {
|
||||
aps: {
|
||||
sound: 'default',
|
||||
badge: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
promises.push(
|
||||
admin.messaging().send(message)
|
||||
.then(() => {
|
||||
console.log(`Notification sent to ${username} successfully`);
|
||||
totalRecipients++;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Error sending notification to ${username}:`, error);
|
||||
// Remove invalid token
|
||||
if (error.code === 'messaging/registration-token-not-registered') {
|
||||
tokens.delete(token);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recipients: totalRecipients,
|
||||
message: `Notification sent to ${totalRecipients} recipient(s)`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending notifications:', error);
|
||||
res.status(500).json({ error: 'Failed to send notifications' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get all registered users (for debugging)
|
||||
app.get('/users', (req, res) => {
|
||||
const users = {};
|
||||
console.log('Current userTokens map:', userTokens);
|
||||
console.log('Number of registered users:', userTokens.size);
|
||||
|
||||
for (const [username, tokens] of userTokens.entries()) {
|
||||
users[username] = {
|
||||
tokenCount: tokens.size,
|
||||
tokens: Array.from(tokens)
|
||||
};
|
||||
}
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// Debug endpoint to check server status
|
||||
app.get('/debug', (req, res) => {
|
||||
res.json({
|
||||
firebaseAdminInitialized: admin.apps.length > 0,
|
||||
registeredUsers: userTokens.size,
|
||||
userTokens: Object.fromEntries(
|
||||
Array.from(userTokens.entries()).map(([user, tokens]) => [user, {
|
||||
count: tokens.size,
|
||||
tokens: Array.from(tokens)
|
||||
}])
|
||||
),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`FCM Test PWA server running on port ${PORT}`);
|
||||
console.log(`Open http://localhost:${PORT} in your browser`);
|
||||
console.log(`Server listening on all interfaces (0.0.0.0:${PORT})`);
|
||||
});
|
||||
@@ -6,6 +6,10 @@
|
||||
<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="RosterChirp - team messaging" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="RosterChirp" />
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||
<title>RosterChirp</title>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "rosterchirp-frontend",
|
||||
"version": "0.11.25",
|
||||
"version": "0.13.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 213 KiB |
@@ -22,16 +22,18 @@
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
"type": "image/png"
|
||||
|
||||
},
|
||||
{
|
||||
"purpose": "any",
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
|
||||
}
|
||||
],
|
||||
"min_width": "320px"
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "jama",
|
||||
"short_name": "jama",
|
||||
"description": "Modern team messaging application",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#1a73e8",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-192-maskable.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"src": "/icons/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
}
|
||||
|
||||
],
|
||||
"min_width": "320px"
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
[
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"sizes": "96x96",
|
||||
"src": "maskable_icon_x96.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"sizes": "192x192",
|
||||
"src": "maskable_icon_x192.png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"purpose": "maskable",
|
||||
"sizes": "512x512",
|
||||
"src": "maskable_icon_x512.png",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
59
frontend/public/sw.gemini.js
Normal file
@@ -0,0 +1,59 @@
|
||||
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
|
||||
|
||||
// event.waitUntil is the "Keep-Alive" signal for mobile OS
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
let payload;
|
||||
|
||||
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
console.warn('[SW] Could not parse JSON, using text fallback');
|
||||
payload = { notification: { body: event.data.text() } };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If the payload is empty, check if Firebase can catch it
|
||||
// (This happens if your server sends "Notification" instead of "Data" messages)
|
||||
if (!payload && messaging) {
|
||||
// This is a last-resort wait for the SDK
|
||||
payload = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null), 2000);
|
||||
messaging.onBackgroundMessage((bgPayload) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(bgPayload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Construct and show the notification
|
||||
if (payload) {
|
||||
const n = payload.notification || {};
|
||||
const d = payload.data || {};
|
||||
|
||||
// Use the specific function you already defined
|
||||
await showRosterChirpNotification({
|
||||
title: n.title || d.title || 'New Message',
|
||||
body: n.body || d.body || '',
|
||||
url: d.url || d.link || '/', // some SDKs use 'link'
|
||||
groupId: d.groupId || '',
|
||||
});
|
||||
} else {
|
||||
// Fallback if we woke up for a "ghost" push with no data
|
||||
await self.registration.showNotification('RosterChirp', {
|
||||
body: 'You have a new update.',
|
||||
tag: 'rosterchirp-fallback'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW] Critical Push Error:', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
82
frontend/public/sw.gemini.js.txt
Normal file
@@ -0,0 +1,82 @@
|
||||
The Consolidated "Bulletproof" Push Listener
|
||||
To fix the "hit or miss" behavior on mobile, we need to move away from relying on the Firebase SDK's internal listener (which is a black box that doesn't always play nice with mobile power management) and instead wrap everything in the native push event using event.waitUntil.
|
||||
|
||||
Replace your current messaging.onBackgroundMessage and self.addEventListener('push') blocks with this unified version:
|
||||
|
||||
JavaScript
|
||||
// ── Unified Push Handler (Optimized for Mobile) ──────────────────────────────
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push event received. Messaging Ready:', !!messaging);
|
||||
|
||||
// event.waitUntil is the "Keep-Alive" signal for mobile OS
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
let payload;
|
||||
|
||||
// 1. Try to parse the data directly from the push event (Fastest/Reliable)
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
console.log('[SW] Raw push data parsed:', JSON.stringify(payload));
|
||||
} catch (e) {
|
||||
console.warn('[SW] Could not parse JSON, using text fallback');
|
||||
payload = { notification: { body: event.data.text() } };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. If the payload is empty, check if Firebase can catch it
|
||||
// (This happens if your server sends "Notification" instead of "Data" messages)
|
||||
if (!payload && messaging) {
|
||||
// This is a last-resort wait for the SDK
|
||||
payload = await new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(null), 2000);
|
||||
messaging.onBackgroundMessage((bgPayload) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(bgPayload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Construct and show the notification
|
||||
if (payload) {
|
||||
const n = payload.notification || {};
|
||||
const d = payload.data || {};
|
||||
|
||||
// Use the specific function you already defined
|
||||
await showRosterChirpNotification({
|
||||
title: n.title || d.title || 'New Message',
|
||||
body: n.body || d.body || '',
|
||||
url: d.url || d.link || '/', // some SDKs use 'link'
|
||||
groupId: d.groupId || '',
|
||||
});
|
||||
} else {
|
||||
// Fallback if we woke up for a "ghost" push with no data
|
||||
await self.registration.showNotification('RosterChirp', {
|
||||
body: 'You have a new update.',
|
||||
tag: 'rosterchirp-fallback'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SW] Critical Push Error:', error);
|
||||
}
|
||||
})()
|
||||
);
|
||||
});
|
||||
Why this fixes your issues:
|
||||
Eliminates the Race Condition: By wrapping the logic in an async function inside event.waitUntil, you tell Android/iOS: "Do not kill this process until the notification is actually rendered on the screen."
|
||||
|
||||
Bypasses SDK "Sleep": Sometimes the Firebase SDK takes too long to initialize. This code tries to parse the event.data directly first. Direct parsing is almost instantaneous, which is critical when a phone is in a deep sleep state.
|
||||
|
||||
Handles "Ghost" Pushes: Sometimes FCM sends a "heartbeat" or an empty push to wake the device. This script ensures that even if data is missing, the worker stays alive long enough to handle it gracefully rather than crashing.
|
||||
|
||||
One Final Server-Side Check
|
||||
Since you are using HAProxy, check your server logs for the HTTP status code when you send a message to a mobile device.
|
||||
|
||||
If you see 200 OK but no notification: The issue was definitely this Service Worker "sleep" issue.
|
||||
|
||||
If you see 401 or 403: HAProxy might be stripping the Authorization header from your backend's outbound request to Google.
|
||||
|
||||
If you see 400: Ensure your backend is sending priority: "high" in the FCM JSON.
|
||||
|
||||
Would you like me to provide a Python or Node.js snippet to test sending a "High Priority" message with the correct v1 API headers?
|
||||
@@ -1,24 +1,9 @@
|
||||
// ── 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();
|
||||
}
|
||||
// ── Service Worker — RosterChirp ───────────────────────────────────────────────
|
||||
// Push notifications are handled via the standard W3C Push API (`push` event).
|
||||
// The Firebase SDK is not initialised here — FCM delivers the payload via the
|
||||
// standard push event and event.data.json() is sufficient to read it.
|
||||
// Firebase SDK initialisation (for getToken) happens in the main thread (Chat.jsx),
|
||||
// where the config is fetched at runtime from /api/push/firebase-config.
|
||||
|
||||
// ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
const CACHE_NAME = 'rosterchirp-v1';
|
||||
@@ -42,9 +27,14 @@ self.addEventListener('activate', (event) => {
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
const url = event.request.url;
|
||||
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only intercept same-origin requests — never intercept cross-origin calls
|
||||
// (Firebase API, Google CDN, socket.io CDN, etc.) or specific local paths.
|
||||
// Intercepting cross-origin requests causes Firebase SDK calls to return
|
||||
// cached HTML, producing "unsupported MIME type" errors and breaking FCM.
|
||||
if (!url.startsWith(self.location.origin)) return;
|
||||
if (url.includes('/api/') || url.includes('/socket.io/') || url.includes('/manifest.json')) return;
|
||||
|
||||
event.respondWith(
|
||||
fetch(event.request).catch(() => caches.match(event.request))
|
||||
);
|
||||
@@ -54,6 +44,7 @@ self.addEventListener('fetch', (event) => {
|
||||
let badgeCount = 0;
|
||||
|
||||
function showRosterChirpNotification(data) {
|
||||
console.log('[SW] showRosterChirpNotification:', JSON.stringify(data));
|
||||
badgeCount++;
|
||||
if (self.navigator?.setAppBadge) self.navigator.setAppBadge(badgeCount).catch(() => {});
|
||||
|
||||
@@ -64,15 +55,54 @@ function showRosterChirpNotification(data) {
|
||||
data: { url: data.url || '/' },
|
||||
tag: data.groupId ? `rosterchirp-group-${data.groupId}` : 'rosterchirp-message',
|
||||
renotify: true,
|
||||
vibrate: [200, 100, 200],
|
||||
});
|
||||
}
|
||||
|
||||
// ── FCM background messages ───────────────────────────────────────────────────
|
||||
if (messaging) {
|
||||
messaging.onBackgroundMessage((payload) => {
|
||||
return showRosterChirpNotification(payload.data || {});
|
||||
});
|
||||
}
|
||||
// ── Push handler ──────────────────────────────────────────────────────────────
|
||||
// Unified handler — always uses event.waitUntil so the mobile OS does not
|
||||
// terminate the SW before the notification is shown. Parses event.data
|
||||
// directly (fast, reliable) rather than delegating to the Firebase SDK's
|
||||
// internal push listener, which can be killed before it finishes on Android.
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[SW] Push received, hasData:', !!event.data);
|
||||
|
||||
event.waitUntil((async () => {
|
||||
try {
|
||||
let payload = null;
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
payload = event.data.json();
|
||||
console.log('[SW] Push data:', JSON.stringify({ notification: payload.notification, data: payload.data }));
|
||||
} catch (e) {
|
||||
console.warn('[SW] Push data not JSON:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload) {
|
||||
const n = payload.notification || {};
|
||||
const d = payload.data || {};
|
||||
await showRosterChirpNotification({
|
||||
title: n.title || d.title || 'New Message',
|
||||
body: n.body || d.body || '',
|
||||
url: d.url || '/',
|
||||
groupId: d.groupId || '',
|
||||
});
|
||||
} else {
|
||||
// Ghost push — keep SW alive and show a generic notification
|
||||
await self.registration.showNotification('RosterChirp', {
|
||||
body: 'You have a new message.',
|
||||
icon: '/icons/icon-192.png',
|
||||
badge: '/icons/icon-192-maskable.png',
|
||||
tag: 'rosterchirp-fallback',
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[SW] Push handler error:', e);
|
||||
}
|
||||
})());
|
||||
});
|
||||
|
||||
// ── Notification click ────────────────────────────────────────────────────────
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
|
||||
import { SocketProvider } from './contexts/SocketContext.jsx';
|
||||
@@ -6,6 +7,50 @@ import Login from './pages/Login.jsx';
|
||||
import Chat from './pages/Chat.jsx';
|
||||
import ChangePassword from './pages/ChangePassword.jsx';
|
||||
|
||||
// ── iOS "Add to Home Screen" banner ───────────────────────────────────────────
|
||||
// iOS Safari does not fire beforeinstallprompt. Push notifications require the
|
||||
// app to be installed as a PWA. This banner is shown to any iOS Safari user who
|
||||
// has not yet added the app to their Home Screen.
|
||||
const IOS_BANNER_KEY = 'rc_ios_install_dismissed';
|
||||
|
||||
function IOSInstallBanner() {
|
||||
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
|
||||
const isStandalone = window.navigator.standalone === true;
|
||||
const [dismissed, setDismissed] = useState(() => localStorage.getItem(IOS_BANNER_KEY) === '1');
|
||||
|
||||
if (!isIOS || isStandalone || dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
localStorage.setItem(IOS_BANNER_KEY, '1');
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0, zIndex: 9999,
|
||||
background: 'var(--primary, #1a73e8)', color: '#fff',
|
||||
padding: '12px 16px', display: 'flex', alignItems: 'center', gap: 12,
|
||||
boxShadow: '0 -2px 12px rgba(0,0,0,0.25)',
|
||||
}}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontWeight: 700, fontSize: 14, marginBottom: 2 }}>Add to Home Screen</div>
|
||||
<div style={{ fontSize: 12, lineHeight: 1.4, opacity: 0.9 }}>
|
||||
To receive push notifications, tap the{' '}
|
||||
<svg style={{ display: 'inline', verticalAlign: 'middle', margin: '0 2px' }} width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/>
|
||||
<polyline points="16 6 12 2 8 6"/>
|
||||
<line x1="12" y1="2" x2="12" y2="15"/>
|
||||
</svg>
|
||||
{' '}Share button, then select <strong>"Add to Home Screen"</strong>.
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={dismiss} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#fff', padding: 4, flexShrink: 0, opacity: 0.9 }}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtectedRoute({ children }) {
|
||||
const { user, loading, mustChangePassword } = useAuth();
|
||||
if (loading) return (
|
||||
@@ -36,6 +81,7 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
<IOSInstallBanner />
|
||||
<Routes>
|
||||
{/* All routes go through jama auth */}
|
||||
<Route path="/*" element={
|
||||
|
||||
@@ -3,12 +3,15 @@ import { api } from '../utils/api.js';
|
||||
|
||||
const CLAUDE_URL = 'https://claude.ai';
|
||||
|
||||
// Render "Built With" value — separator trails its token so it never starts a new line
|
||||
// Render "Built With" value — each token+separator is a nowrap unit; the flex
|
||||
// container wraps between tokens. Using display:flex (not inline) ensures Firefox
|
||||
// and Safari honour the wrap at the flex-item level rather than computing the
|
||||
// min-content width as the full un-broken string (which suppresses wrapping).
|
||||
function BuiltWithValue({ value }) {
|
||||
if (!value) return null;
|
||||
const parts = value.split('·').map(s => s.trim());
|
||||
return (
|
||||
<span style={{ display: 'inline' }}>
|
||||
<span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', width: '100%' }}>
|
||||
{parts.map((part, i) => (
|
||||
<span key={part} style={{ whiteSpace: 'nowrap' }}>
|
||||
{part === 'Claude.ai'
|
||||
|
||||
434
frontend/src/components/AddChildAliasModal.jsx
Normal file
@@ -0,0 +1,434 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function AddChildAliasModal({ features = {}, onClose }) {
|
||||
const toast = useToast();
|
||||
const { user: currentUser } = useAuth();
|
||||
const loginType = features.loginType || 'guardian_only';
|
||||
const isMixedAge = loginType === 'mixed_age';
|
||||
|
||||
// ── Guardian-only state (alias form) ──────────────────────────────────────
|
||||
const [aliases, setAliases] = useState([]);
|
||||
const [editingAlias, setEditingAlias] = useState(null);
|
||||
const [form, setForm] = useState({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
const [avatarFile, setAvatarFile] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// ── Mixed-age state (real minor users) ────────────────────────────────────
|
||||
const [minorPlayers, setMinorPlayers] = useState([]); // available + already-mine
|
||||
const [selectedMinorId, setSelectedMinorId] = useState('');
|
||||
const [childDob, setChildDob] = useState('');
|
||||
const [addingMinor, setAddingMinor] = useState(false);
|
||||
|
||||
// ── Partner state (shared) ────────────────────────────────────────────────
|
||||
const [partner, setPartner] = useState(null);
|
||||
const [selectedPartnerId, setSelectedPartnerId] = useState('');
|
||||
const [respondSeparately, setRespondSeparately] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [savingPartner, setSavingPartner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loads = [api.getPartner(), api.searchUsers('')];
|
||||
if (isMixedAge) {
|
||||
loads.push(api.getMinorPlayers());
|
||||
} else {
|
||||
loads.push(api.getAliases());
|
||||
}
|
||||
Promise.all(loads).then(([partnerRes, usersRes, thirdRes]) => {
|
||||
const p = partnerRes.partner || null;
|
||||
setPartner(p);
|
||||
setSelectedPartnerId(p?.id?.toString() || '');
|
||||
setRespondSeparately(p?.respond_separately || false);
|
||||
setAllUsers((usersRes.users || []).filter(u => u.id !== currentUser?.id && !u.is_default_admin));
|
||||
if (isMixedAge) {
|
||||
setMinorPlayers(thirdRes.users || []);
|
||||
} else {
|
||||
setAliases(thirdRes.aliases || []);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [isMixedAge]);
|
||||
|
||||
// Pre-populate DOB when a minor is selected from the dropdown
|
||||
useEffect(() => {
|
||||
if (!selectedMinorId) { setChildDob(''); return; }
|
||||
const minor = availableMinors.find(u => u.id === parseInt(selectedMinorId));
|
||||
setChildDob(minor?.date_of_birth ? minor.date_of_birth.slice(0, 10) : '');
|
||||
}, [selectedMinorId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
const set = k => e => setForm(p => ({ ...p, [k]: e.target.value }));
|
||||
|
||||
const resetForm = () => {
|
||||
setEditingAlias(null);
|
||||
setForm({ firstName: '', lastName: '', dob: '', phone: '', email: '' });
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const lbl = (text, required) => (
|
||||
<label className="text-sm" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>
|
||||
{text}{required && <span style={{ color: 'var(--error)', marginLeft: 2 }}>*</span>}
|
||||
</label>
|
||||
);
|
||||
|
||||
// ── Partner handlers ──────────────────────────────────────────────────────
|
||||
const handleSavePartner = async () => {
|
||||
setSavingPartner(true);
|
||||
try {
|
||||
if (!selectedPartnerId) {
|
||||
await api.removePartner();
|
||||
setPartner(null);
|
||||
setRespondSeparately(false);
|
||||
if (!isMixedAge) {
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
resetForm();
|
||||
} else {
|
||||
const { users: fresh } = await api.getMinorPlayers();
|
||||
setMinorPlayers(fresh || []);
|
||||
}
|
||||
toast('Spouse/Partner/Co-Parent removed', 'success');
|
||||
} else {
|
||||
const { partner: p } = await api.setPartner(parseInt(selectedPartnerId), respondSeparately);
|
||||
setPartner(p);
|
||||
setRespondSeparately(p?.respond_separately || false);
|
||||
if (!isMixedAge) {
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
}
|
||||
toast('Spouse/Partner/Co-Parent saved', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSavingPartner(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Guardian-only alias handlers ──────────────────────────────────────────
|
||||
const handleSelectAlias = (a) => {
|
||||
if (editingAlias?.id === a.id) { resetForm(); return; }
|
||||
setEditingAlias(a);
|
||||
setForm({
|
||||
firstName: a.first_name || '',
|
||||
lastName: a.last_name || '',
|
||||
dob: a.date_of_birth ? a.date_of_birth.slice(0, 10) : '',
|
||||
phone: a.phone || '',
|
||||
email: a.email || '',
|
||||
});
|
||||
setAvatarFile(null);
|
||||
};
|
||||
|
||||
const handleSaveAlias = async () => {
|
||||
if (!form.firstName.trim() || !form.lastName.trim())
|
||||
return toast('First and last name required', 'error');
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingAlias) {
|
||||
await api.updateAlias(editingAlias.id, {
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
dateOfBirth: form.dob || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
});
|
||||
if (avatarFile) await api.uploadAliasAvatar(editingAlias.id, avatarFile);
|
||||
toast('Child alias updated', 'success');
|
||||
} else {
|
||||
const { alias } = await api.createAlias({
|
||||
firstName: form.firstName.trim(),
|
||||
lastName: form.lastName.trim(),
|
||||
dateOfBirth: form.dob || null,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
});
|
||||
if (avatarFile) await api.uploadAliasAvatar(alias.id, avatarFile);
|
||||
toast('Child alias added', 'success');
|
||||
}
|
||||
const { aliases: fresh } = await api.getAliases();
|
||||
setAliases(fresh || []);
|
||||
resetForm();
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAlias = async (e, aliasId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.deleteAlias(aliasId);
|
||||
setAliases(prev => prev.filter(a => a.id !== aliasId));
|
||||
if (editingAlias?.id === aliasId) resetForm();
|
||||
toast('Child alias removed', 'success');
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
// ── Mixed-age minor handlers ──────────────────────────────────────────────
|
||||
const myMinors = minorPlayers.filter(u => u.guardian_user_id === currentUser?.id);
|
||||
const availableMinors = minorPlayers.filter(u => !u.guardian_user_id);
|
||||
|
||||
const handleAddMinor = async () => {
|
||||
if (!selectedMinorId) return;
|
||||
if (!childDob.trim()) return toast('Date of Birth is required', 'error');
|
||||
setAddingMinor(true);
|
||||
try {
|
||||
await api.addGuardianChild(parseInt(selectedMinorId), childDob.trim());
|
||||
const { users: fresh } = await api.getMinorPlayers();
|
||||
setMinorPlayers(fresh || []);
|
||||
setSelectedMinorId('');
|
||||
setChildDob('');
|
||||
toast('Child added and account activated', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setAddingMinor(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMinor = async (e, minorId) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.removeGuardianChild(minorId);
|
||||
const { users: fresh } = await api.getMinorPlayers();
|
||||
setMinorPlayers(fresh || []);
|
||||
toast('Child removed', 'success');
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Family Manager</h2>
|
||||
<button className="btn-icon" onClick={onClose} aria-label="Close">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Spouse/Partner/Co-Parent section */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{lbl('Spouse/Partner/Co-Parent')}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<select
|
||||
className="input"
|
||||
style={{ flex: 1 }}
|
||||
value={selectedPartnerId}
|
||||
onChange={e => setSelectedPartnerId(e.target.value)}
|
||||
>
|
||||
<option value="">— None —</option>
|
||||
{allUsers.map(u => (
|
||||
<option key={u.id} value={u.id}>{u.display_name || u.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleSavePartner}
|
||||
disabled={savingPartner}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{savingPartner ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 8, cursor: 'pointer', fontSize: 13, color: 'var(--text-secondary)' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={respondSeparately}
|
||||
onChange={e => setRespondSeparately(e.target.checked)}
|
||||
style={{ width: 15, height: 15, cursor: 'pointer', accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
Respond separately to events
|
||||
</label>
|
||||
{partner && (
|
||||
<div className="text-sm" style={{ color: 'var(--text-secondary)', marginTop: 6 }}>
|
||||
Linked with {partner.display_name || partner.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Mixed Age: link real minor users ── */}
|
||||
{isMixedAge && (
|
||||
<>
|
||||
{/* Current children list */}
|
||||
{myMinors.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Your Children
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{myMinors.map((u, i) => (
|
||||
<div
|
||||
key={u.id}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 12px',
|
||||
borderBottom: i < myMinors.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 14 }}>{u.first_name} {u.last_name}</span>
|
||||
{u.date_of_birth && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{u.date_of_birth.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => handleRemoveMinor(e, u.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||
aria-label="Remove"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add minor from players group */}
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Add Child
|
||||
</div>
|
||||
<select
|
||||
className="input"
|
||||
style={{ marginBottom: 8 }}
|
||||
value={selectedMinorId}
|
||||
onChange={e => setSelectedMinorId(e.target.value)}
|
||||
>
|
||||
<option value="">— Select a player —</option>
|
||||
{availableMinors.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.first_name} {u.last_name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
{lbl('Date of Birth', true)}
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={childDob}
|
||||
onChange={e => setChildDob(e.target.value)}
|
||||
autoComplete="off"
|
||||
style={childDob === '' && selectedMinorId ? { borderColor: 'var(--error)' } : {}}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleAddMinor}
|
||||
disabled={addingMinor || !selectedMinorId || !childDob.trim()}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{addingMinor ? 'Adding…' : 'Add'}
|
||||
</button>
|
||||
</div>
|
||||
{availableMinors.length === 0 && myMinors.length === 0 && (
|
||||
<p className="text-sm" style={{ color: 'var(--text-tertiary)', marginTop: 8 }}>
|
||||
No minor players available to link.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Guardian Only: alias form ── */}
|
||||
{!isMixedAge && (
|
||||
<>
|
||||
{/* Existing aliases list */}
|
||||
{aliases.length > 0 && (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
Your Children — click to edit
|
||||
</div>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden' }}>
|
||||
{aliases.map((a, i) => (
|
||||
<div
|
||||
key={a.id}
|
||||
onClick={() => handleSelectAlias(a)}
|
||||
style={{
|
||||
display: 'flex', alignItems: 'center', gap: 10,
|
||||
padding: '9px 12px', cursor: 'pointer',
|
||||
borderBottom: i < aliases.length - 1 ? '1px solid var(--border)' : 'none',
|
||||
background: editingAlias?.id === a.id ? 'var(--primary-light)' : 'transparent',
|
||||
}}
|
||||
>
|
||||
<span style={{ flex: 1, fontSize: 14, fontWeight: editingAlias?.id === a.id ? 600 : 400 }}>
|
||||
{a.first_name} {a.last_name}
|
||||
</span>
|
||||
{a.date_of_birth && (
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
{a.date_of_birth.slice(0, 10)}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={e => handleDeleteAlias(e, a.id)}
|
||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--error)', fontSize: 18, lineHeight: 1, padding: '0 2px' }}
|
||||
aria-label="Remove"
|
||||
>×</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form section label */}
|
||||
<div className="text-sm font-medium" style={{ color: 'var(--text-secondary)', marginBottom: 10 }}>
|
||||
{editingAlias
|
||||
? `Editing: ${editingAlias.first_name} ${editingAlias.last_name}`
|
||||
: 'Add Child'}
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div>
|
||||
{lbl('First Name', true)}
|
||||
<input className="input" value={form.firstName} onChange={set('firstName')}
|
||||
autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Last Name', true)}
|
||||
<input className="input" value={form.lastName} onChange={set('lastName')}
|
||||
autoComplete="off" autoCapitalize="words" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Date of Birth')}
|
||||
<input className="input" placeholder="YYYY-MM-DD" value={form.dob} onChange={set('dob')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Phone')}
|
||||
<input className="input" type="tel" value={form.phone} onChange={set('phone')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Email (optional)')}
|
||||
<input className="input" type="email" value={form.email} onChange={set('email')}
|
||||
autoComplete="off" />
|
||||
</div>
|
||||
<div>
|
||||
{lbl('Avatar (optional)')}
|
||||
<input type="file" accept="image/*"
|
||||
onChange={e => setAvatarFile(e.target.files?.[0] || null)} />
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 4 }}>
|
||||
{editingAlias && (
|
||||
<button className="btn btn-secondary" onClick={resetForm}>Cancel Edit</button>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleSaveAlias} disabled={saving}>
|
||||
{saving ? 'Saving…' : editingAlias ? 'Update Alias' : 'Add Alias'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -235,7 +235,7 @@ function CustomPicker({ initial, onSet, onBack }) {
|
||||
width: 110, background: 'var(--surface)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
placeholder="#000000" autoComplete="new-password" />
|
||||
placeholder="#000000" autoComplete="off" />
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
|
||||
</div>
|
||||
|
||||
@@ -455,7 +455,7 @@ export default function BrandingModal({ onClose }) {
|
||||
className="input flex-1"
|
||||
value={appName}
|
||||
maxLength={16}
|
||||
onChange={e => setAppName(e.target.value)} autoComplete="new-password" onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||
onChange={e => setAppName(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleSaveName()} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>{loading ? '...' : 'Save'}</button>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}>
|
||||
|
||||
@@ -14,6 +14,57 @@ function nameToColor(name) {
|
||||
return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
// Composite avatar layouts for the 40×40 chat header icon
|
||||
const COMPOSITE_LAYOUTS_SM = {
|
||||
1: [{ top: 4, left: 4, size: 32 }],
|
||||
2: [
|
||||
{ top: 10, left: 1, size: 19 },
|
||||
{ top: 10, right: 1, size: 19 },
|
||||
],
|
||||
3: [
|
||||
{ top: 2, left: 2, size: 17 },
|
||||
{ top: 2, right: 2, size: 17 },
|
||||
{ bottom: 2, left: 11, size: 17 },
|
||||
],
|
||||
4: [
|
||||
{ top: 1, left: 1, size: 18 },
|
||||
{ top: 1, right: 1, size: 18 },
|
||||
{ bottom: 1, left: 1, size: 18 },
|
||||
{ bottom: 1, right: 1, size: 18 },
|
||||
],
|
||||
};
|
||||
|
||||
function GroupAvatarCompositeSm({ memberPreviews }) {
|
||||
const members = (memberPreviews || []).slice(0, 4);
|
||||
const positions = COMPOSITE_LAYOUTS_SM[members.length];
|
||||
if (!positions) return null;
|
||||
return (
|
||||
<div className="group-icon-sm" style={{ background: 'transparent', position: 'relative', padding: 0, overflow: 'visible' }}>
|
||||
{members.map((m, i) => {
|
||||
const pos = positions[i];
|
||||
const base = {
|
||||
position: 'absolute',
|
||||
width: pos.size, height: pos.size,
|
||||
borderRadius: '50%',
|
||||
boxSizing: 'border-box',
|
||||
border: '2px solid var(--surface)',
|
||||
...(pos.top !== undefined ? { top: pos.top } : {}),
|
||||
...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}),
|
||||
...(pos.left !== undefined ? { left: pos.left } : {}),
|
||||
...(pos.right !== undefined ? { right: pos.right } : {}),
|
||||
overflow: 'hidden', flexShrink: 0,
|
||||
};
|
||||
if (m.avatar) return <img key={m.id} src={m.avatar} alt={m.name} style={{ ...base, objectFit: 'cover' }} />;
|
||||
return (
|
||||
<div key={m.id} style={{ ...base, background: nameToColor(m.name), display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: Math.round(pos.size * 0.42), fontWeight: 700, color: 'white' }}>
|
||||
{(m.name || '')[0]?.toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { socket } = useSocket();
|
||||
@@ -39,6 +90,22 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||
}, []);
|
||||
|
||||
// On mobile, when the soft keyboard opens the visual viewport shrinks but the
|
||||
// messages-container scroll position stays where it was, leaving the latest
|
||||
// messages hidden behind the keyboard. Scroll to bottom whenever the visual
|
||||
// viewport resizes (keyboard appear/dismiss) so the last message stays visible.
|
||||
useEffect(() => {
|
||||
const vv = window.visualViewport;
|
||||
if (!vv) return;
|
||||
const onVVResize = () => scrollToBottom();
|
||||
vv.addEventListener('resize', onVVResize);
|
||||
return () => vv.removeEventListener('resize', onVVResize);
|
||||
}, [scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setIconGroupInfo(settings.icon_groupinfo || '');
|
||||
@@ -56,10 +123,6 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'auto' });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!group) { setMessages([]); return; }
|
||||
setMessages([]);
|
||||
@@ -255,6 +318,8 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
<div className="group-icon-sm" style={{ background: avatarColors.dm, borderRadius: 8, flexShrink: 0, fontSize: 11, fontWeight: 700 }}>
|
||||
{group.is_multi_group ? 'MG' : 'UG'}
|
||||
</div>
|
||||
) : group.composite_members?.length > 0 ? (
|
||||
<GroupAvatarCompositeSm memberPreviews={group.composite_members} />
|
||||
) : (
|
||||
<div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
@@ -296,19 +361,28 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
</button>
|
||||
)}
|
||||
|
||||
{messages.map((msg, i) => (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={messages[i - 1]}
|
||||
currentUser={currentUser}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onReact={handleReact}
|
||||
onDirectMessage={handleDirectMessage}
|
||||
isDirect={isDirect}
|
||||
onlineUserIds={onlineUserIds} />
|
||||
))}
|
||||
{messages.map((msg, i) => {
|
||||
// Skip deleted entries when looking for the effective previous message.
|
||||
// Deleted messages render null, so they must not affect date separators
|
||||
// or avatar-grouping for the messages that follow them.
|
||||
let effectivePrev = null;
|
||||
for (let j = i - 1; j >= 0; j--) {
|
||||
if (!messages[j].is_deleted) { effectivePrev = messages[j]; break; }
|
||||
}
|
||||
return (
|
||||
<Message
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
prevMessage={effectivePrev}
|
||||
currentUser={currentUser}
|
||||
onReply={handleReply}
|
||||
onDelete={handleDelete}
|
||||
onReact={handleReact}
|
||||
onDirectMessage={handleDirectMessage}
|
||||
isDirect={isDirect}
|
||||
onlineUserIds={onlineUserIds} />
|
||||
);
|
||||
})}
|
||||
|
||||
{typing.length > 0 && (
|
||||
<div className="typing-indicator">
|
||||
@@ -330,7 +404,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
|
||||
This channel is read-only
|
||||
</div>
|
||||
) : (
|
||||
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={() => {}} onTextChange={val => onHasTextChange?.(!!val.trim())} />
|
||||
<MessageInput group={group} currentUser={currentUser} onSend={handleSend} socket={socket} replyTo={replyTo} onCancelReply={() => setReplyTo(null)} onTyping={(isTyping) => { if (socket && group) socket.emit(isTyping ? 'typing:start' : 'typing:stop', { groupId: group.id }); }} onTextChange={val => onHasTextChange?.(!!val.trim())} onInputFocus={() => scrollToBottom()} />
|
||||
)}
|
||||
</div>
|
||||
{showInfo && (
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
|
||||
export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
|
||||
export default function GlobalBar({ isMobile, showSidebar, onBurger, hasUnread = false }) {
|
||||
const { connected } = useSocket();
|
||||
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '' });
|
||||
const [isDark, setIsDark] = useState(() => document.documentElement.getAttribute('data-theme') === 'dark');
|
||||
@@ -41,11 +41,22 @@ export default function GlobalBar({ isMobile, showSidebar, onBurger }) {
|
||||
title="Menu"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<line x1="3" y1="6" x2="21" y2="6"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18"/>
|
||||
</svg>
|
||||
{hasUnread && (
|
||||
<span style={{
|
||||
position: 'absolute', bottom: -1, right: -1,
|
||||
width: 9, height: 9, borderRadius: '50%',
|
||||
background: 'var(--primary)',
|
||||
border: '2px solid var(--surface)',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div className="global-bar-brand">
|
||||
<img src={logoUrl || '/icons/rosterchirp.png'} alt={appName} className="global-bar-logo" />
|
||||
|
||||
@@ -134,7 +134,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{editing ? (
|
||||
<div className="flex gap-2">
|
||||
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} autoComplete="new-password" onKeyDown={e => e.key === 'Enter' && handleRename()} autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} />
|
||||
<input className="input flex-1" value={newName} onChange={e => setNewName(e.target.value)} autoComplete="off" onKeyDown={e => e.key === 'Enter' && handleRename()} autoCorrect="off" autoCapitalize="off" spellCheck={false} />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleRename}>Save</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
||||
<input
|
||||
className="input flex-1"
|
||||
value={customName}
|
||||
onChange={e => setCustomName(e.target.value)} autoComplete="new-password" placeholder={group.owner_name_original || group.name}
|
||||
onChange={e => setCustomName(e.target.value)} autoComplete="off" placeholder={group.owner_name_original || group.name}
|
||||
onKeyDown={e => e.key === 'Enter' && handleCustomName()} />
|
||||
{customName.trim() !== savedCustomName ? (
|
||||
<button className="btn btn-primary btn-sm" onClick={handleCustomName} disabled={savingCustom}>
|
||||
@@ -194,7 +194,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
||||
Members ({members.length})
|
||||
</div>
|
||||
<div style={{ maxHeight: 180, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{members.map(m => (
|
||||
{[...members].sort((a, b) => a.name.localeCompare(b.name)).map(m => (
|
||||
<div key={m.id} className="flex items-center" style={{ gap: 10, padding: '6px 0' }}>
|
||||
<Avatar user={m} size="sm" />
|
||||
<span className="flex-1 text-sm">{m.name}</span>
|
||||
@@ -219,7 +219,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
|
||||
</div>
|
||||
{canManage && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<input className="input" placeholder="Search to add member..." autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={addSearch} onChange={e => setAddSearch(e.target.value)} />
|
||||
<input className="input" placeholder="Search to add member..." autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={addSearch} onChange={e => setAddSearch(e.target.value)} />
|
||||
{addResults.length > 0 && addSearch && (
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto', background: 'var(--surface)' }}>
|
||||
{addResults.filter(u => !members.find(m => m.id === u.id)).map(u => (
|
||||
|
||||
@@ -164,21 +164,28 @@ function ProvisionModal({ api, baseDomain, onClose, onDone, toast }) {
|
||||
|
||||
function EditModal({ api, tenant, onClose, onDone }) {
|
||||
const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' });
|
||||
const [adminPassword, setAdminPassword] = useState('');
|
||||
const [showAdminPass, setShowAdminPass] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const set = k => v => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
const handle = async () => {
|
||||
if (adminPassword && adminPassword.length < 6)
|
||||
return setError('Admin password must be at least 6 characters');
|
||||
setSaving(true); setError('');
|
||||
try {
|
||||
const { tenant: updated } = await api.updateTenant(tenant.slug, {
|
||||
name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null,
|
||||
...(adminPassword ? { adminPassword } : {}),
|
||||
});
|
||||
onDone(updated);
|
||||
} catch (e) { setError(e.message); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const adminEmail = tenant.admin_email || '(uses system default from .env)';
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
@@ -191,6 +198,41 @@ function EditModal({ api, tenant, onClose, onDone }) {
|
||||
<Field label="Display Name" value={form.name} onChange={set('name')} />
|
||||
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} />
|
||||
<Field label="Custom Domain" value={form.customDomain} onChange={set('customDomain')} placeholder="chat.example.com" hint="Leave blank to remove" />
|
||||
<div style={{ borderTop:'1px solid var(--border)', paddingTop:12 }}>
|
||||
<div style={{ fontSize:11, fontWeight:700, color:'var(--text-tertiary)', textTransform:'uppercase', letterSpacing:'0.5px', marginBottom:10 }}>Admin Account</div>
|
||||
<FieldGroup label="Login Email (read-only)">
|
||||
<input type="text" value={adminEmail} readOnly
|
||||
className="input" style={{ fontSize:13, opacity:0.7, cursor:'default' }} />
|
||||
</FieldGroup>
|
||||
<div style={{ marginTop:10 }}>
|
||||
<FieldGroup label="Reset Admin Password" >
|
||||
<div style={{ position:'relative' }}>
|
||||
<input
|
||||
type={showAdminPass ? 'text' : 'password'}
|
||||
value={adminPassword}
|
||||
onChange={e => setAdminPassword(e.target.value)}
|
||||
placeholder="Leave blank to keep current password"
|
||||
autoComplete="new-password"
|
||||
className="input"
|
||||
style={{ fontSize:13, paddingRight:40 }}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdminPass(v => !v)}
|
||||
style={{ position:'absolute', right:10, top:'50%', transform:'translateY(-50%)', background:'none', border:'none', cursor:'pointer', color:'var(--text-tertiary)', padding:0, display:'flex', alignItems:'center' }}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showAdminPass ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<span style={{ fontSize:11, color:'var(--text-tertiary)' }}>Admin will be required to change password on next login</span>
|
||||
</FieldGroup>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
|
||||
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
|
||||
|
||||
@@ -4,15 +4,31 @@ import { createPortal } from 'react-dom';
|
||||
export default function ImageLightbox({ src, onClose }) {
|
||||
const overlayRef = useRef(null);
|
||||
|
||||
// Close on Escape
|
||||
// Close on Escape; enable native pinch-zoom on the image while open
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === 'Escape') onClose(); };
|
||||
window.addEventListener('keydown', handler);
|
||||
// Prevent body scroll while open
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
// Signal the global font-scale pinch handler in main.jsx to stand down
|
||||
document.documentElement.dataset.lightboxOpen = '1';
|
||||
|
||||
// Enable native browser pinch-to-zoom by removing the scale restrictions.
|
||||
// The original content is restored exactly on close.
|
||||
const viewport = document.querySelector('meta[name="viewport"]');
|
||||
const originalContent = viewport?.content ?? '';
|
||||
if (viewport) {
|
||||
viewport.content = originalContent
|
||||
.replace(/,?\s*maximum-scale=[^,]*/g, '')
|
||||
.replace(/,?\s*user-scalable=[^,]*/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handler);
|
||||
document.body.style.overflow = '';
|
||||
delete document.documentElement.dataset.lightboxOpen;
|
||||
if (viewport) viewport.content = originalContent;
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 3px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Reply preview */
|
||||
|
||||
@@ -151,7 +151,7 @@
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 20px;
|
||||
font-size: calc(0.875rem * var(--font-scale));
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
font-family: var(--font);
|
||||
color: var(--text-primary);
|
||||
@@ -247,3 +247,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* iOS keyboard fix: when keyboard is open, env(safe-area-inset-bottom) stays at ~34px
|
||||
instead of dropping to 0 — remove it so there's no empty gap below the input */
|
||||
.keyboard-open .message-input-area {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ function isEmojiOnly(str) {
|
||||
return emojiRegex.test(str.trim());
|
||||
}
|
||||
|
||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onlineUserIds = new Set() }) {
|
||||
export default function MessageInput({ group, replyTo, onCancelReply, onSend, onTyping, onTextChange, onInputFocus, onlineUserIds = new Set() }) {
|
||||
const [text, setText] = useState('');
|
||||
const [imageFile, setImageFile] = useState(null);
|
||||
const [imagePreview, setImagePreview] = useState(null);
|
||||
@@ -380,11 +380,16 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="msg-input"
|
||||
placeholder={`Message ${group?.name || ''}...`}
|
||||
placeholder="Text message"
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={onInputFocus}
|
||||
rows={1}
|
||||
autoComplete="off"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="sentences"
|
||||
spellCheck="true"
|
||||
style={{ resize: 'none' }} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { api } from '../utils/api.js';
|
||||
import ColourPickerSheet from './ColourPickerSheet.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
@@ -67,15 +68,27 @@ function fmt12(val) {
|
||||
return `${h}:${String(mm).padStart(2,'0')} ${ampm}`;
|
||||
}
|
||||
|
||||
// Mobile TimeInput — same behaviour as desktop but styled for mobile inline use
|
||||
// Mobile TimeInput — free-text time entry with smart-positioned scrollable dropdown
|
||||
function TimeInputMobile({ value, onChange }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inputVal, setInputVal] = useState(fmt12(value));
|
||||
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
|
||||
const wrapRef = useRef(null);
|
||||
const listRef = useRef(null);
|
||||
|
||||
useEffect(() => { setInputVal(fmt12(value)); }, [value]);
|
||||
|
||||
// Calculate dropdown position — always above the input.
|
||||
// getBoundingClientRect() is relative to the visual viewport on mobile Chrome,
|
||||
// so this correctly clears the keyboard regardless of its height.
|
||||
useEffect(() => {
|
||||
if (open && wrapRef.current) {
|
||||
const rect = wrapRef.current.getBoundingClientRect();
|
||||
const dropdownHeight = 5 * 40;
|
||||
setDropdownPos({ top: Math.max(0, rect.top - dropdownHeight), left: rect.left });
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !listRef.current) return;
|
||||
const idx = TIME_SLOTS.findIndex(s => s.value === value);
|
||||
@@ -99,22 +112,29 @@ function TimeInputMobile({ value, onChange }) {
|
||||
return (
|
||||
<div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={inputVal}
|
||||
onChange={e => setInputVal(e.target.value)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={e => setTimeout(() => commit(e.target.value), 150)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }}
|
||||
autoComplete="new-password"
|
||||
autoComplete="off"
|
||||
inputMode="text"
|
||||
enterKeyHint="done"
|
||||
style={{ fontSize: 15, color: 'var(--primary)', fontWeight: 600, background: 'transparent', border: 'none', outline: 'none', cursor: 'text', width: 90 }}
|
||||
/>
|
||||
{open && (
|
||||
<div
|
||||
ref={listRef}
|
||||
style={{
|
||||
position: 'fixed', zIndex: 400,
|
||||
position: 'fixed',
|
||||
zIndex: 9999,
|
||||
top: dropdownPos.top,
|
||||
left: dropdownPos.left,
|
||||
background: 'var(--surface)', border: '1px solid var(--border)',
|
||||
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
|
||||
width: 130, maxHeight: 5 * 40, overflowY: 'auto',
|
||||
pointerEvents: 'auto',
|
||||
}}
|
||||
>
|
||||
{TIME_SLOTS.map(s => (
|
||||
@@ -254,7 +274,7 @@ function RecurrenceSheet({ value, onChange, onClose }) {
|
||||
<div style={{ marginBottom:16 }}>
|
||||
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats every</div>
|
||||
<div style={{ display:'flex',gap:10 }}>
|
||||
<input type="number" className="input" min={1} max={99} value={customRule.interval||1} onChange={e => upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{ width:70,textAlign:'center',fontSize:16 }}/>
|
||||
<input type="number" className="input" min={1} max={99} value={customRule.interval||1} onChange={e => upd('interval',Math.max(1,parseInt(e.target.value)||1))} autoComplete="off" style={{ width:70,textAlign:'center',fontSize:16 }}/>
|
||||
<select className="input" value={customRule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{ flex:1,fontSize:14 }}>
|
||||
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(customRule.interval||1)>1?'s':''}</option>)}
|
||||
</select>
|
||||
@@ -281,8 +301,8 @@ function RecurrenceSheet({ value, onChange, onClose }) {
|
||||
{(customRule.ends||'never')===val&&<div style={{ width:10,height:10,borderRadius:'50%',background:'var(--primary)' }}/>}
|
||||
</div>
|
||||
<span style={{ flex:1,fontSize:15 }}>{lbl}</span>
|
||||
{val==='on'&&(customRule.ends||'never')==='on'&&<input type="date" className="input" value={customRule.endDate||''} onChange={e => upd('endDate',e.target.value)} style={{ width:150 }}/>}
|
||||
{val==='after'&&(customRule.ends||'never')==='after'&&<><input type="number" className="input" min={1} max={999} value={customRule.endCount||13} onChange={e => upd('endCount',parseInt(e.target.value)||1)} style={{ width:64,textAlign:'center' }}/><span style={{ fontSize:13,color:'var(--text-tertiary)' }}>occurrences</span></>}
|
||||
{val==='on'&&(customRule.ends||'never')==='on'&&<input type="date" className="input" value={customRule.endDate||''} onChange={e => upd('endDate',e.target.value)} autoComplete="off" style={{ width:150 }}/>}
|
||||
{val==='after'&&(customRule.ends||'never')==='after'&&<><input type="number" className="input" min={1} max={999} value={customRule.endCount||13} onChange={e => upd('endCount',parseInt(e.target.value)||1)} autoComplete="off" style={{ width:64,textAlign:'center' }}/><span style={{ fontSize:13,color:'var(--text-tertiary)' }}>occurrences</span></>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -319,8 +339,33 @@ function MobileRow({ icon, label, children, onPress, border=true }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Recurring choice modal ────────────────────────────────────────────────────
|
||||
function RecurringChoiceModal({ title, onConfirm, onCancel }) {
|
||||
const [choice, setChoice] = useState('this');
|
||||
return ReactDOM.createPortal(
|
||||
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onCancel()}>
|
||||
<div className="modal" style={{maxWidth:360}}>
|
||||
<h3 style={{fontSize:17,fontWeight:700,margin:'0 0 20px'}}>{title}</h3>
|
||||
<div style={{display:'flex',flexDirection:'column',gap:14,marginBottom:24}}>
|
||||
{[['this','This event'],['future','This and following events'],['all','All events']].map(([val,label])=>(
|
||||
<label key={val} style={{display:'flex',alignItems:'center',gap:10,fontSize:14,cursor:'pointer'}}>
|
||||
<input type="radio" name="rec-scope" value={val} checked={choice===val} onChange={()=>setChoice(val)} style={{accentColor:'var(--primary)',width:16,height:16}}/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={()=>onConfirm(choice)}>OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main Mobile Event Form ────────────────────────────────────────────────────
|
||||
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager }) {
|
||||
export default function MobileEventForm({ event, eventTypes, userGroups, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
|
||||
const toast = useToast();
|
||||
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
|
||||
const defDate = selectedDate || new Date();
|
||||
@@ -347,12 +392,13 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
const mountedRef = useRef(false);
|
||||
const [allDay, setAllDay] = useState(!!event?.all_day);
|
||||
const [track, setTrack] = useState(!!event?.track_availability);
|
||||
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : false);
|
||||
const [isPrivate, setIsPrivate] = useState(event ? !event.is_public : !isToolManager);
|
||||
const [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
|
||||
const [location, setLocation] = useState(event?.location||'');
|
||||
const [description, setDescription] = useState(event?.description||'');
|
||||
const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showScopeModal, setShowScopeModal] = useState(false);
|
||||
|
||||
// Overlay state
|
||||
const [showStartDate, setShowStartDate] = useState(false);
|
||||
@@ -410,25 +456,31 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
setEt(toTimeIn(endIso));
|
||||
}, [sd, st, typeId]);
|
||||
|
||||
const handle = async () => {
|
||||
const handle = () => {
|
||||
if(!title.trim()) return toast('Title required','error');
|
||||
// Validation rules
|
||||
if(!isToolManager && groups.size === 0) return toast('Select at least one group','error');
|
||||
const startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
|
||||
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).getTime();
|
||||
if(ed < sd) return toast('End date cannot be before start date','error');
|
||||
if(!allDay && endMs <= startMs && ed === sd) return toast('End time must be after start time, or set a later end date','error');
|
||||
// No past start times for new events
|
||||
if(!event && !allDay && new Date(buildISO(sd,st)) < new Date()) return toast('Start date and time cannot be in the past','error');
|
||||
if(!event && allDay && sd < toDateIn(new Date().toISOString())) return toast('Start date cannot be in the past','error');
|
||||
if(event && event.recurrence_rule?.freq) { setShowScopeModal(true); return; }
|
||||
doSave('this');
|
||||
};
|
||||
const doSave = async (scope) => {
|
||||
setShowScopeModal(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:!isPrivate, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
||||
let scope = 'this';
|
||||
if(event && event.recurrence_rule?.freq) {
|
||||
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only');
|
||||
scope = choice ? 'future' : 'this';
|
||||
const body = { title:title.trim(), eventTypeId:typeId||null, startAt:allDay?buildISO(sd,'00:00'):buildISO(sd,st), endAt:allDay?buildISO(ed,'23:59'):buildISO(ed,et), allDay, location, description, isPublic:isToolManager?!isPrivate:false, trackAvailability:track, userGroupIds:[...groups], recurrenceRule:recRule||null };
|
||||
let r;
|
||||
if (event) {
|
||||
const updateBody = { ...body, recurringScope: scope };
|
||||
if (event._virtual) updateBody.occurrenceStart = event.start_at;
|
||||
r = await api.updateEvent(event.id, updateBody);
|
||||
} else {
|
||||
r = await api.createEvent(body);
|
||||
}
|
||||
const r = event ? await api.updateEvent(event.id, {...body, recurringScope:scope}) : await api.createEvent(body);
|
||||
onSave(r.event);
|
||||
} catch(e) { toast(e.message,'error'); }
|
||||
finally { setSaving(false); }
|
||||
@@ -449,10 +501,12 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
<button onClick={handle} disabled={saving} style={{ background:'var(--primary)',border:'none',cursor:'pointer',color:'white',borderRadius:20,padding:'8px 20px',fontSize:14,fontWeight:700,opacity:saving?0.6:1 }}>{saving?'…':'Save'}</button>
|
||||
</div>
|
||||
|
||||
<div style={{ flex:1,overflowY:'auto' }}>
|
||||
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="off"
|
||||
on individual inputs is ignored by Chrome but respected on the form element */}
|
||||
<form autoComplete="off" onSubmit={e => e.preventDefault()} style={{ flex:1,overflowY:'auto' }}>
|
||||
{/* Title */}
|
||||
<div style={{ padding:'16px 20px',borderBottom:'1px solid var(--border)' }}>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} autoComplete="new-password" placeholder="Add title" autoComplete="new-password" autoCorrect="off" autoCapitalize="sentences" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
|
||||
<input value={title} onChange={e => setTitle(e.target.value)} autoComplete="off" placeholder="Add title" autoCorrect="off" autoCapitalize="sentences" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:22,fontWeight:700,color:'var(--text-primary)',outline:'none' }}/>
|
||||
</div>
|
||||
|
||||
{/* Event Type */}
|
||||
@@ -485,8 +539,8 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
</div>
|
||||
|
||||
{/* End date/time */}
|
||||
<div onClick={()=>setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}>
|
||||
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span>
|
||||
<div style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',borderBottom:'1px solid var(--border)' }}>
|
||||
<span onClick={()=>setShowEndDate(true)} style={{ flex:1,fontSize:15,color:'var(--text-secondary)',cursor:'pointer' }}>{fmtDateDisplay(ed)}</span>
|
||||
{!allDay && (
|
||||
<TimeInputMobile value={et} onChange={newEt => {
|
||||
setEt(newEt);
|
||||
@@ -529,35 +583,39 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Private Event */}
|
||||
{/* Private Event — tool managers can toggle; regular users always private */}
|
||||
<div style={{ display:'flex',alignItems:'center',padding:'14px 20px',borderBottom:'1px solid var(--border)' }}>
|
||||
<span style={{ color:'var(--text-tertiary)',width:20,textAlign:'center',marginRight:16 }}><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg></span>
|
||||
<span style={{ flex:1,fontSize:15 }}>Private Event</span>
|
||||
<Toggle checked={isPrivate} onChange={setIsPrivate}/>
|
||||
{isToolManager
|
||||
? <Toggle checked={isPrivate} onChange={setIsPrivate}/>
|
||||
: <span style={{ fontSize:13,color:'var(--text-tertiary)' }}>Always private</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<MobileRow icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/></svg>}>
|
||||
<input value={location} onChange={e => setLocation(e.target.value)} autoComplete="new-password" placeholder="Add location" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/>
|
||||
<input value={location} onChange={e => setLocation(e.target.value)} autoComplete="off" placeholder="Add location" autoCorrect="off" autoCapitalize="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none' }}/>
|
||||
</MobileRow>
|
||||
|
||||
{/* Description */}
|
||||
<MobileRow icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="21" y1="10" x2="3" y2="10"/><line x1="21" y1="6" x2="3" y2="6"/><line x1="21" y1="14" x2="3" y2="14"/><line x1="21" y1="18" x2="3" y2="18"/></svg>} border={false}>
|
||||
<textarea value={description} onChange={e=>setDescription(e.target.value)} placeholder="Add description" rows={3} autoComplete="new-password" autoCorrect="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none',resize:'none' }}/>
|
||||
<textarea value={description} onChange={e=>setDescription(e.target.value)} placeholder="Add description" rows={3} autoComplete="off" autoCorrect="off" spellCheck={false} style={{ width:'100%',border:'none',background:'transparent',fontSize:15,color:'var(--text-primary)',outline:'none',resize:'none' }}/>
|
||||
</MobileRow>
|
||||
|
||||
{/* Delete */}
|
||||
{event && isToolManager && (
|
||||
{event && (isToolManager || (userId && event.created_by === userId)) && (
|
||||
<div style={{ padding:'16px 20px' }}>
|
||||
<button onClick={()=>onDelete(event)} style={{ width:'100%',padding:'14px',border:'1px solid var(--error)',borderRadius:'var(--radius)',background:'transparent',color:'var(--error)',fontSize:15,fontWeight:600,cursor:'pointer' }}>Delete Event</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Overlays */}
|
||||
{showStartDate && <CalendarPicker value={sd} onChange={v=>{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>}
|
||||
{showEndDate && <CalendarPicker value={ed} onChange={v=>{setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
|
||||
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
|
||||
{showScopeModal && <RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
|
||||
{showTypeColourPicker && (
|
||||
<ColourPickerSheet value={newTypeColour} onChange={setNewTypeColour} onClose={()=>setShowTypeColourPicker(false)} title="Event Type Colour"/>
|
||||
)}
|
||||
@@ -571,8 +629,8 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
|
||||
<input
|
||||
autoFocus
|
||||
value={newTypeName}
|
||||
onChange={e => setNewTypeName(e.target.value)} autoComplete="new-password" onKeyDown={e=>e.key==='Enter'&&createEventType()}
|
||||
placeholder="Type name…" autoComplete="new-password" autoCorrect="off" autoCapitalize="words" spellCheck={false}
|
||||
onChange={e => setNewTypeName(e.target.value)} autoComplete="off" onKeyDown={e=>e.key==='Enter'&&createEventType()}
|
||||
placeholder="Type name…" autoCorrect="off" autoCapitalize="words" spellCheck={false}
|
||||
style={{ width:'100%',padding:'12px 14px',border:'1px solid var(--border)',borderRadius:'var(--radius)',fontSize:16,marginBottom:12,boxSizing:'border-box',background:'var(--background)',color:'var(--text-primary)' }} />
|
||||
<div style={{ display:'flex',alignItems:'center',gap:12,marginBottom:16 }}>
|
||||
<label style={{ fontSize:14,color:'var(--text-tertiary)',flexShrink:0 }}>Colour</label>
|
||||
|
||||
@@ -83,3 +83,12 @@
|
||||
color: var(--primary);
|
||||
}
|
||||
.nav-drawer-item.active:hover { background: var(--primary-light); }
|
||||
|
||||
.nav-drawer-unread-dot {
|
||||
margin-left: auto;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -13,13 +13,14 @@ const NAV_ICON = {
|
||||
settings: <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="3"/><path d="M19.07 4.93l-1.41 1.41M5.34 18.66l-1.41 1.41M12 2v2M12 20v2M4.93 4.93l1.41 1.41M18.66 18.66l1.41 1.41M2 12h2M20 12h2"/></svg>,
|
||||
};
|
||||
|
||||
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, features = {}, currentPage = 'chat', isMobile = false }) {
|
||||
export default function NavDrawer({ open, onClose, onMessages, onGroupMessages, onSchedule, onScheduleManager, onBranding, onSettings, onUsers, onGroupManager, onHostPanel, onAddChild, features = {}, currentPage = 'chat', isMobile = false, unreadMessages = false, unreadGroupMessages = false }) {
|
||||
const { user } = useAuth();
|
||||
const drawerRef = useRef(null);
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const userGroupIds = features.userGroupMemberships || [];
|
||||
const canAccessTools = isAdmin || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
|
||||
const canAccessTools = isAdmin || user?.role === 'manager' || (features.teamToolManagers || []).some(gid => userGroupIds.includes(gid));
|
||||
const hasUserGroups = userGroupIds.length > 0;
|
||||
const showAddChild = (features.loginType === 'guardian_only' || features.loginType === 'mixed_age') && features.inGuardiansGroup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -36,7 +37,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
||||
}, [open, onClose]);
|
||||
|
||||
const item = (icon, label, onClick, opts = {}) => {
|
||||
const { active, disabled, badge } = opts;
|
||||
const { active, disabled, badge, dot } = opts;
|
||||
return (
|
||||
<button
|
||||
className={`nav-drawer-item${active ? ' active' : ''}${disabled ? ' disabled' : ''}`}
|
||||
@@ -46,6 +47,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
{badge && <span className="nav-drawer-badge">{badge}</span>}
|
||||
{dot && <span className="nav-drawer-unread-dot" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -57,15 +59,15 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
||||
|
||||
{/* Close X */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<div className="nav-drawer-section-label" style={{ margin: 0, padding: 0 }}>Menu</div>
|
||||
<span style={{ fontWeight: 700, fontSize: 16, color: 'var(--text-primary)' }}>User Menu</span>
|
||||
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-secondary)', padding: 4, borderRadius: 6, display: 'flex', alignItems: 'center' }} aria-label="Close menu">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User section */}
|
||||
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat' })}
|
||||
{hasUserGroups && item(NAV_ICON.groupmessages, 'Group Messages', onGroupMessages, { active: currentPage === 'groupmessages' })}
|
||||
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat', dot: unreadMessages })}
|
||||
{hasUserGroups && (features.msgGroup ?? true) && item(NAV_ICON.groupmessages, 'Group Messages', onGroupMessages, { active: currentPage === 'groupmessages', dot: unreadGroupMessages })}
|
||||
{features.scheduleManager && item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
|
||||
|
||||
{/* Admin section */}
|
||||
@@ -79,11 +81,16 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
|
||||
)}
|
||||
|
||||
{/* Tools section */}
|
||||
{canAccessTools && (
|
||||
{(canAccessTools || showAddChild) && (
|
||||
<>
|
||||
<div className="nav-drawer-section-label admin">Tools</div>
|
||||
{item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
|
||||
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
||||
{canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
|
||||
{canAccessTools && features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })}
|
||||
{showAddChild && onAddChild && item(
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>,
|
||||
'Family Manager',
|
||||
onAddChild
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,19 +4,29 @@ import { api } from '../utils/api.js';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
export default function NewChatModal({ onClose, onCreated }) {
|
||||
export default function NewChatModal({ onClose, onCreated, features = {} }) {
|
||||
const { user } = useAuth();
|
||||
const toast = useToast();
|
||||
const [tab, setTab] = useState('private'); // 'private' | 'public'
|
||||
|
||||
const msgPublic = features.msgPublic ?? true;
|
||||
const msgU2U = features.msgU2U ?? true;
|
||||
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
||||
const loginType = features.loginType || 'all_ages';
|
||||
|
||||
// Default to private if available, otherwise public
|
||||
const defaultTab = (msgU2U || msgPrivateGroup) ? 'private' : 'public';
|
||||
const [tab, setTab] = useState(defaultTab);
|
||||
const [name, setName] = useState('');
|
||||
const [isReadonly, setIsReadonly] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [users, setUsers] = useState([]);
|
||||
const [selected, setSelected] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
// Pre-confirmation for minor members (shown before creating the chat)
|
||||
const [minorConfirm, setMinorConfirm] = useState(null); // { minorNames: [] } — pending create
|
||||
|
||||
// True when exactly 1 user selected on private tab = direct message
|
||||
const isDirect = tab === 'private' && selected.length === 1;
|
||||
// True when exactly 1 user selected on private tab AND U2U messages are enabled
|
||||
const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
|
||||
|
||||
useEffect(() => {
|
||||
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
|
||||
@@ -30,19 +40,19 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
|
||||
const toggle = (u) => {
|
||||
if (u.id === user.id) return;
|
||||
setSelected(prev => prev.find(p => p.id === u.id) ? prev.filter(p => p.id !== u.id) : [...prev, u]);
|
||||
// If private groups are disabled, cap selection at 1 (DM only)
|
||||
setSelected(prev => {
|
||||
if (prev.find(p => p.id === u.id)) return prev.filter(p => p.id !== u.id);
|
||||
if (!msgPrivateGroup && prev.length >= 1) return prev; // can't add more for DM-only
|
||||
return [...prev, u];
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||
if (tab === 'private' && selected.length > 1 && !name.trim()) return toast('Name required', 'error');
|
||||
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
||||
|
||||
const doCreate = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
let payload;
|
||||
if (isDirect) {
|
||||
// Direct message: no name, isDirect flag
|
||||
payload = {
|
||||
type: 'private',
|
||||
memberIds: selected.map(u => u.id),
|
||||
@@ -57,11 +67,14 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
};
|
||||
}
|
||||
|
||||
const { group, duplicate } = await api.createGroup(payload);
|
||||
const { group, duplicate, guardianAdded } = await api.createGroup(payload);
|
||||
if (duplicate) {
|
||||
toast('A group with these members already exists — opening it now.', 'info');
|
||||
} else {
|
||||
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success');
|
||||
if (guardianAdded) {
|
||||
toast('A guardian has been added to this conversation.', 'info');
|
||||
}
|
||||
}
|
||||
onCreated(group);
|
||||
} catch (e) {
|
||||
@@ -71,6 +84,23 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (tab === 'private' && selected.length === 0) return toast('Add at least one member', 'error');
|
||||
if (tab === 'private' && !isDirect && !name.trim()) return toast('Name required', 'error');
|
||||
if (tab === 'public' && !name.trim()) return toast('Name required', 'error');
|
||||
|
||||
// Mixed Age: warn if any selected member is a minor (and initiator is not a minor)
|
||||
if (loginType === 'mixed_age' && !user.is_minor) {
|
||||
const minors = selected.filter(u => u.is_minor);
|
||||
if (minors.length > 0) {
|
||||
setMinorConfirm({ minorNames: minors.map(u => u.display_name || u.name) });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await doCreate();
|
||||
};
|
||||
|
||||
// Placeholder for the name field
|
||||
const namePlaceholder = isDirect
|
||||
? selected[0]?.name || ''
|
||||
@@ -86,22 +116,22 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{user.role === 'admin' && (
|
||||
{user.role === 'admin' && (msgU2U || msgPrivateGroup || msgPublic) && (
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Direct Message</button>
|
||||
<button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>
|
||||
{(msgU2U || msgPrivateGroup) && <button className={`btn ${tab === 'private' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('private')}>Direct Message</button>}
|
||||
{msgPublic && <button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Name — only shown when needed: public always, private only when 2+ members selected */}
|
||||
{(tab === 'public' || (tab === 'private' && selected.length > 1)) && (
|
||||
{/* Message Name — public always, private when not a DM and at least 1 member selected */}
|
||||
{(tab === 'public' || (tab === 'private' && !isDirect && selected.length > 0)) && (
|
||||
<div className="flex-col gap-2" style={{ marginBottom: 16 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
|
||||
<input
|
||||
className="input"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)} autoComplete="new-password" placeholder={namePlaceholder}
|
||||
autoComplete="new-password" autoCorrect="off" autoCapitalize="words" spellCheck={false} />
|
||||
onChange={e => setName(e.target.value)} placeholder={namePlaceholder}
|
||||
autoComplete="off" autoCorrect="off" autoCapitalize="words" spellCheck={false} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -120,7 +150,7 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
{isDirect ? 'Direct Message with' : 'Add Members'}
|
||||
</label>
|
||||
<input className="input" placeholder="Search users..." autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
<input className="input" placeholder="Search users..." autoComplete="off" autoCorrect="off" autoCapitalize="off" spellCheck={false} value={search} onChange={e => setSearch(e.target.value)} />
|
||||
</div>
|
||||
|
||||
{selected.length > 0 && (
|
||||
@@ -141,7 +171,7 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
)}
|
||||
|
||||
<div style={{ maxHeight: 200, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
|
||||
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).map(u => (
|
||||
{users.filter(u => u.id !== user.id && u.allow_dm !== 0).sort((a, b) => a.name.localeCompare(b.name)).map(u => (
|
||||
<label key={u.id} className="flex items-center gap-10 pointer" style={{ padding: '10px 14px', gap: 12, borderBottom: '1px solid var(--border)', cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={!!selected.find(s => s.id === u.id)} onChange={() => toggle(u)} />
|
||||
<Avatar user={u} size="sm" />
|
||||
@@ -160,6 +190,30 @@ export default function NewChatModal({ onClose, onCreated }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pre-confirmation modal: minor member warning */}
|
||||
{minorConfirm && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal" style={{ maxWidth: 380 }}>
|
||||
<h2 className="modal-title" style={{ marginBottom: 12 }}>Guardian Notice</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 8 }}>
|
||||
The following member{minorConfirm.minorNames.length > 1 ? 's are' : ' is'} a minor:
|
||||
</p>
|
||||
<ul style={{ marginBottom: 16, paddingLeft: 20 }}>
|
||||
{minorConfirm.minorNames.map(n => (
|
||||
<li key={n} className="text-sm" style={{ color: 'var(--text-primary)' }}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 20 }}>
|
||||
Their designated guardian(s) will be automatically added to this conversation. Do you want to proceed?
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button className="btn btn-secondary" onClick={() => setMinorConfirm(null)}>Cancel</button>
|
||||
<button className="btn btn-primary" onClick={() => { setMinorConfirm(null); doCreate(); }}>Proceed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,10 @@ export default function PasswordInput({ className, style, wrapperStyle, ...input
|
||||
return (
|
||||
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', ...wrapperStyle }}>
|
||||
<input
|
||||
autoComplete="new-password"
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck="false"
|
||||
{...inputProps}
|
||||
type={show ? 'text' : 'password'}
|
||||
className={className ?? 'input'}
|
||||
|
||||
@@ -1,30 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
const LS_FONT_KEY = 'rosterchirp_font_scale';
|
||||
const MIN_SCALE = 0.8;
|
||||
const MAX_SCALE = 2.0;
|
||||
|
||||
export default function ProfileModal({ onClose }) {
|
||||
const { user, updateUser } = useAuth();
|
||||
const toast = useToast();
|
||||
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
|
||||
const [displayName, setDisplayName] = useState(user?.display_name || '');
|
||||
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
|
||||
const [displayNameWarning, setDisplayNameWarning] = useState('');
|
||||
const [aboutMe, setAboutMe] = useState(user?.about_me || '');
|
||||
const [dob, setDob] = useState(user?.date_of_birth ? user.date_of_birth.slice(0, 10) : '');
|
||||
const [phone, setPhone] = useState(user?.phone || '');
|
||||
const [currentPw, setCurrentPw] = useState('');
|
||||
const [newPw, setNewPw] = useState('');
|
||||
const [confirmPw, setConfirmPw] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [tab, setTab] = useState('profile'); // 'profile' | 'password'
|
||||
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
|
||||
const [pushTesting, setPushTesting] = useState(false);
|
||||
const [pushResult, setPushResult] = useState(null);
|
||||
const [notifPermission, setNotifPermission] = useState(
|
||||
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'
|
||||
);
|
||||
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
const isDesktop = !isIOS && !isAndroid;
|
||||
const isStandalone = window.navigator.standalone === true;
|
||||
const [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
|
||||
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0);
|
||||
|
||||
// Minor age protection — DOB/phone display + mixed_age forced-DOB gate
|
||||
const [loginType, setLoginType] = useState('all_ages');
|
||||
// True when mixed_age mode and the user still has no DOB on record
|
||||
const needsDob = loginType === 'mixed_age' && !user?.date_of_birth;
|
||||
|
||||
const savedScale = parseFloat(localStorage.getItem(LS_FONT_KEY));
|
||||
const [fontScale, setFontScale] = useState(
|
||||
(savedScale >= MIN_SCALE && savedScale <= MAX_SCALE) ? savedScale : 1.0
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsMobile(window.innerWidth < 768);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Load login type for DOB/phone field visibility
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings: s }) => {
|
||||
setLoginType(s.feature_login_type || 'all_ages');
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const applyFontScale = (val) => {
|
||||
setFontScale(val);
|
||||
document.documentElement.style.setProperty('--font-scale', val);
|
||||
localStorage.setItem(LS_FONT_KEY, val);
|
||||
};
|
||||
|
||||
const handleSaveProfile = async () => {
|
||||
if (displayNameWarning) return toast('Display name is already in use', 'error');
|
||||
setLoading(true);
|
||||
try {
|
||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm });
|
||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob || null, phone: phone || null });
|
||||
updateUser(updated);
|
||||
setSavedDisplayName(displayName);
|
||||
toast('Profile updated', 'success');
|
||||
@@ -62,6 +107,55 @@ export default function ProfileModal({ onClose }) {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Forced DOB gate for mixed_age users ───────────────────────────────────
|
||||
if (needsDob) {
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal" style={{ maxWidth: 380 }}>
|
||||
<h2 className="modal-title" style={{ marginBottom: 8 }}>Date of Birth Required</h2>
|
||||
<p className="text-sm" style={{ color: 'var(--text-secondary)', marginBottom: 16, lineHeight: 1.5 }}>
|
||||
Your organisation requires a date of birth on file. Please enter yours to continue.
|
||||
</p>
|
||||
<div className="flex-col gap-1" style={{ marginBottom: 16 }}>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
|
||||
Date of Birth <span style={{ color: 'var(--error)' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
value={dob}
|
||||
onChange={e => setDob(e.target.value)}
|
||||
autoComplete="off"
|
||||
style={{ borderColor: dob ? undefined : 'var(--error)' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ width: '100%' }}
|
||||
disabled={loading || !dob.trim()}
|
||||
onClick={async () => {
|
||||
if (!dob.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { user: updated } = await api.updateProfile({ displayName, aboutMe, hideAdminTag, allowDm, dateOfBirth: dob.trim(), phone: phone || null });
|
||||
updateUser(updated);
|
||||
toast('Profile updated', 'success');
|
||||
// needsDob will re-evaluate to false now that user.date_of_birth is set
|
||||
} catch (e) {
|
||||
toast(e.message, 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{loading ? 'Saving…' : 'Save & Continue'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal">
|
||||
@@ -97,10 +191,15 @@ export default function ProfileModal({ onClose }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 20 }}>
|
||||
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button>
|
||||
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button>
|
||||
{/* Tab navigation — unified select list on all screen sizes */}
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
|
||||
<select className="input" value={tab} onChange={e => { setTab(e.target.value); setPushResult(null); }}>
|
||||
<option value="profile">Profile</option>
|
||||
<option value="password">Change Password</option>
|
||||
<option value="notifications">Notifications</option>
|
||||
<option value="appearance">Appearance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{tab === 'profile' && (
|
||||
@@ -123,7 +222,7 @@ export default function ProfileModal({ onClose }) {
|
||||
}
|
||||
}}
|
||||
placeholder={user?.name}
|
||||
autoComplete="new-password" autoCorrect="off" autoCapitalize="words" spellCheck={false}
|
||||
autoComplete="off" autoCorrect="off" autoCapitalize="words" spellCheck={false}
|
||||
style={{ borderColor: displayNameWarning ? '#e53935' : undefined }} />
|
||||
{displayName !== savedDisplayName ? null : savedDisplayName ? (
|
||||
<button
|
||||
@@ -141,7 +240,7 @@ export default function ProfileModal({ onClose }) {
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label>
|
||||
<textarea className="input" value={aboutMe} onChange={e => setAboutMe(e.target.value)} placeholder="Tell your team about yourself..." rows={3} autoComplete="new-password" autoCorrect="off" spellCheck={false} style={{ resize: 'vertical' }} />
|
||||
<textarea className="input" value={aboutMe} onChange={e => setAboutMe(e.target.value)} placeholder="Tell your team about yourself..." rows={3} autoComplete="off" autoCorrect="off" spellCheck={false} style={{ resize: 'vertical' }} />
|
||||
</div>
|
||||
{user?.role === 'admin' && (
|
||||
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
|
||||
@@ -161,17 +260,157 @@ export default function ProfileModal({ onClose }) {
|
||||
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
|
||||
Allow others to send me direct messages
|
||||
</label>
|
||||
{/* Date of Birth + Phone — visible in Guardian Only / Mixed Age modes */}
|
||||
{loginType !== 'all_ages' && (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr', gap: 12 }}>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Date of Birth</label>
|
||||
<input className="input" type="text" placeholder="YYYY-MM-DD" value={dob} onChange={e => setDob(e.target.value)} autoComplete="off" />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Phone</label>
|
||||
<input className="input" type="tel" placeholder="+1 555 000 0000" value={phone} onChange={e => setPhone(e.target.value)} autoComplete="tel" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
|
||||
{loading ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'notifications' && (
|
||||
<div className="flex-col gap-3">
|
||||
{isDesktop ? (
|
||||
<div style={{ padding: '12px 14px', borderRadius: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)', fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
In-app notifications are active on this device. Unread message counts and browser tab indicators update in real time — no additional setup needed.
|
||||
</div>
|
||||
) : isIOS && !isStandalone ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, padding: '12px 14px', borderRadius: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)' }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>Home Screen required for notifications</div>
|
||||
<div style={{ fontSize: 13, color: 'var(--text-secondary)', lineHeight: 1.6 }}>
|
||||
Push notifications on iPhone require RosterChirp to be installed as an app. To do this:
|
||||
<ol style={{ margin: '8px 0 0', paddingLeft: 18, display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<li>Tap the <strong>Share</strong> button (<svg style={{ display: 'inline', verticalAlign: 'middle', margin: '0 2px' }} width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"/><polyline points="16 6 12 2 8 6"/><line x1="12" y1="2" x2="12" y2="15"/></svg>) at the bottom of Safari</li>
|
||||
<li>Select <strong>"Add to Home Screen"</strong></li>
|
||||
<li>Tap <strong>Add</strong>, then open RosterChirp from your Home Screen</li>
|
||||
<li>Go to <strong>Profile → Notifications</strong> to enable push notifications</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{notifPermission !== 'granted' && notifPermission !== 'unsupported' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-variant)' }}>
|
||||
<div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
|
||||
{notifPermission === 'denied'
|
||||
? isIOS
|
||||
? 'Notifications are blocked. Enable them in iOS Settings → RosterChirp → Notifications.'
|
||||
: 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.'
|
||||
: 'Push notifications are not yet enabled on this device.'}
|
||||
</div>
|
||||
{notifPermission === 'default' && (
|
||||
<button className="btn btn-primary btn-sm" onClick={async () => {
|
||||
const result = await Notification.requestPermission();
|
||||
setNotifPermission(result);
|
||||
if (result === 'granted') window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
|
||||
}}>Enable Notifications</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{notifPermission === 'granted' && (
|
||||
<div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
|
||||
<p style={{ margin: '0 0 8px' }}>Tap <strong>Send Test Notification</strong> to trigger a push to this device. The notification will arrive shortly if everything is configured correctly.</p>
|
||||
<p style={{ margin: 0 }}>If it doesn't arrive, check:<br/>
|
||||
{isIOS ? (
|
||||
<>• iOS Settings → RosterChirp → Notifications → Allow<br/>
|
||||
• App must be added to the Home Screen (not open in Safari)<br/></>
|
||||
) : (
|
||||
<>• Android Settings → Apps → RosterChirp → Notifications → Enabled<br/></>
|
||||
)}
|
||||
• App is backgrounded when the test fires
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{notifPermission === 'granted' && (<>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={pushTesting}
|
||||
onClick={async () => {
|
||||
setPushTesting(true);
|
||||
setPushResult(null);
|
||||
try {
|
||||
const { results } = await api.testPush('data');
|
||||
setPushResult({ ok: true, results, mode: 'data' });
|
||||
} catch (e) {
|
||||
setPushResult({ ok: false, error: e.message });
|
||||
} finally {
|
||||
setPushTesting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pushTesting ? 'Sending…' : 'Test (via SW)'}
|
||||
</button>
|
||||
{!isIOS && (
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={pushTesting}
|
||||
onClick={async () => {
|
||||
setPushTesting(true);
|
||||
setPushResult(null);
|
||||
try {
|
||||
const { results } = await api.testPush('browser');
|
||||
setPushResult({ ok: true, results, mode: 'browser' });
|
||||
} catch (e) {
|
||||
setPushResult({ ok: false, error: e.message });
|
||||
} finally {
|
||||
setPushTesting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pushTesting ? 'Sending…' : 'Test (via Browser)'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{!isIOS && (
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', lineHeight: 1.4 }}>
|
||||
<strong>Test (via SW)</strong> — normal production path, service worker shows notification.<br/>
|
||||
<strong>Test (via Browser)</strong> — bypasses service worker; Chrome displays directly.
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
{pushResult && (
|
||||
<div style={{
|
||||
padding: '10px 12px',
|
||||
borderRadius: 8,
|
||||
background: pushResult.ok ? 'var(--surface-variant)' : '#fdecea',
|
||||
color: pushResult.ok ? 'var(--text-primary)' : '#c62828',
|
||||
fontSize: 13,
|
||||
}}>
|
||||
{pushResult.ok ? (
|
||||
pushResult.results.map((r, i) => (
|
||||
<div key={i}>
|
||||
<strong>{r.device}</strong>: {r.status === 'sent' ? '✓ Sent — check your device for the notification' : `✗ Failed — ${r.error}`}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div>✗ {pushResult.error}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'password' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
|
||||
<input className="input" type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} autoComplete="new-password" />
|
||||
<input className="input" type="password" value={currentPw} onChange={e => setCurrentPw(e.target.value)} autoComplete="current-password" />
|
||||
</div>
|
||||
<div className="flex-col gap-1">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
|
||||
@@ -186,6 +425,40 @@ export default function ProfileModal({ onClose }) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'appearance' && (
|
||||
<div className="flex-col gap-3">
|
||||
<div className="flex-col gap-2">
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Font Size</label>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', flexShrink: 0 }}>A</span>
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_SCALE}
|
||||
max={MAX_SCALE}
|
||||
step={0.05}
|
||||
value={fontScale}
|
||||
onChange={e => applyFontScale(parseFloat(e.target.value))}
|
||||
style={{ flex: 1, accentColor: 'var(--primary)' }}
|
||||
/>
|
||||
<span style={{ fontSize: 18, color: 'var(--text-tertiary)', flexShrink: 0 }}>A</span>
|
||||
<span style={{ fontSize: 13, color: 'var(--text-secondary)', minWidth: 40, textAlign: 'right', flexShrink: 0 }}>
|
||||
{Math.round(fontScale * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
Pinch to zoom adjusts font size for this session only.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm"
|
||||
style={{ alignSelf: 'flex-start' }}
|
||||
onClick={() => applyFontScale(1.0)}
|
||||
>
|
||||
Reset to Default
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,94 @@ const APP_TYPES = {
|
||||
'RosterChirp-Team': { label: 'RosterChirp-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' },
|
||||
};
|
||||
|
||||
// ── Toggle switch ─────────────────────────────────────────────────────────────
|
||||
function Toggle({ checked, onChange }) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => onChange(!checked)}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
style={{
|
||||
width: 44, height: 24, borderRadius: 12, cursor: 'pointer', flexShrink: 0,
|
||||
background: checked ? 'var(--primary)' : 'var(--border)',
|
||||
position: 'relative', transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: 2, left: checked ? 22 : 2,
|
||||
width: 20, height: 20, borderRadius: '50%',
|
||||
background: 'white', transition: 'left 0.2s',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.3)',
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Messages Tab ──────────────────────────────────────────────────────────────
|
||||
function MessagesTab() {
|
||||
const toast = useToast();
|
||||
const [settings, setSettings] = useState({
|
||||
msgPublic: true,
|
||||
msgGroup: true,
|
||||
msgPrivateGroup: true,
|
||||
msgU2U: true,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings: s }) => {
|
||||
setSettings({
|
||||
msgPublic: s.feature_msg_public !== 'false',
|
||||
msgGroup: s.feature_msg_group !== 'false',
|
||||
msgPrivateGroup: s.feature_msg_private_group !== 'false',
|
||||
msgU2U: s.feature_msg_u2u !== 'false',
|
||||
});
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const toggle = (key) => setSettings(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateMessageSettings(settings);
|
||||
toast('Message settings saved', 'success');
|
||||
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const rows = [
|
||||
{ key: 'msgPublic', label: 'Public Messages', desc: 'Public group channels visible to all members.' },
|
||||
{ key: 'msgGroup', label: 'User Group Messages', desc: 'Private group messages managed by User Groups.' },
|
||||
{ key: 'msgPrivateGroup', label: 'Private Group Messages', desc: 'Private multi-member group conversations.' },
|
||||
{ key: 'msgU2U', label: 'Private Messages (U2U)', desc: 'One-on-one direct messages between users.' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section-label">Message Features</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 16 }}>
|
||||
Disable a feature to hide it from all menus, sidebars, and modals.
|
||||
</p>
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
|
||||
{rows.map((r, i) => (
|
||||
<div key={r.key} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '12px 14px', borderBottom: i < rows.length - 1 ? '1px solid var(--border)' : 'none' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{r.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2 }}>{r.desc}</div>
|
||||
</div>
|
||||
<Toggle checked={settings[r.key]} onChange={() => toggle(r.key)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Team Management Tab ───────────────────────────────────────────────────────
|
||||
function TeamManagementTab() {
|
||||
const toast = useToast();
|
||||
@@ -18,7 +106,7 @@ function TeamManagementTab() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getUserGroups().then(({ groups }) => setUserGroups(groups || [])).catch(() => {});
|
||||
api.getUserGroups().then(({ groups }) => setUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
|
||||
api.getSettings().then(({ settings }) => {
|
||||
// Read from unified key, fall back to legacy key
|
||||
setToolManagers(JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'));
|
||||
@@ -70,6 +158,124 @@ function TeamManagementTab() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Login Type Tab ────────────────────────────────────────────────────────────
|
||||
const LOGIN_TYPE_OPTIONS = [
|
||||
{
|
||||
id: 'all_ages',
|
||||
label: 'Unrestricted (default)',
|
||||
desc: 'No age restrictions. All users interact normally.',
|
||||
},
|
||||
{
|
||||
id: 'guardian_only',
|
||||
label: 'Guardian Only',
|
||||
desc: "Parents/Guardians login one. Parents/Guardians are required to add their child's details in the \"Family Manager\". They will also respond on behalf of the child for events with availability tracking.",
|
||||
},
|
||||
{
|
||||
id: 'mixed_age',
|
||||
label: 'Restricted',
|
||||
desc: "No age restriction for login. Date of Birth is a required field. Parents/Guardians must select their child in the Family Manager to allow them to login. Any private message initiated by any adult to a minor aged user will include the child's designated guardian.",
|
||||
},
|
||||
];
|
||||
|
||||
function LoginTypeTab() {
|
||||
const toast = useToast();
|
||||
const [loginType, setLoginType] = useState('all_ages');
|
||||
const [playersGroupId, setPlayersGroupId] = useState('');
|
||||
const [guardiansGroupId,setGuardiansGroupId] = useState('');
|
||||
const [userGroups, setUserGroups] = useState([]);
|
||||
const [canChange, setCanChange] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([api.getSettings(), api.getUserGroups()]).then(([{ settings: s }, { groups }]) => {
|
||||
setLoginType(s.feature_login_type || 'all_ages');
|
||||
setPlayersGroupId(s.feature_players_group_id || '');
|
||||
setGuardiansGroupId(s.feature_guardians_group_id || '');
|
||||
setUserGroups([...(groups || [])].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}).catch(() => {});
|
||||
// Determine if the user table is empty enough to allow changes
|
||||
api.getUsers().then(({ users }) => {
|
||||
const nonAdmins = (users || []).filter(u => u.role !== 'admin');
|
||||
setCanChange(nonAdmins.length === 0);
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateLoginType({
|
||||
loginType,
|
||||
playersGroupId: playersGroupId ? parseInt(playersGroupId) : null,
|
||||
guardiansGroupId: guardiansGroupId ? parseInt(guardiansGroupId) : null,
|
||||
});
|
||||
toast('Login Type settings saved', 'success');
|
||||
window.dispatchEvent(new Event('rosterchirp:settings-changed'));
|
||||
} catch (e) { toast(e.message, 'error'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const needsGroups = loginType !== 'all_ages';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section-label">Login Type</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 8, background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 14px', marginBottom: 16 }}>
|
||||
<span style={{ fontSize: 16, lineHeight: 1 }}>⚠️</span>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', margin: 0, lineHeight: 1.5 }}>
|
||||
This setting can only be set or changed when the user table is empty (no non-admin users exist).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', overflow: 'hidden', marginBottom: 16 }}>
|
||||
{LOGIN_TYPE_OPTIONS.map((opt, i) => (
|
||||
<label key={opt.id} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '12px 14px', borderBottom: i < LOGIN_TYPE_OPTIONS.length - 1 ? '1px solid var(--border)' : 'none', cursor: canChange ? 'pointer' : 'not-allowed', opacity: canChange ? 1 : 0.6 }}>
|
||||
<input type="radio" name="loginType" value={opt.id} checked={loginType === opt.id} disabled={!canChange}
|
||||
onChange={() => setLoginType(opt.id)} style={{ marginTop: 3, accentColor: 'var(--primary)' }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 14, fontWeight: 500 }}>{opt.label}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2, lineHeight: 1.5 }}>{opt.desc}</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Group selectors — only shown for Guardian Only / Mixed Age */}
|
||||
{needsGroups && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 16 }}>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Players Group</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>Select a group that minor aged users will be put in by default. *</p>
|
||||
<select className="input" value={playersGroupId} disabled={!canChange}
|
||||
onChange={e => setPlayersGroupId(e.target.value)}>
|
||||
<option value="">— Select group —</option>
|
||||
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)', display: 'block', marginBottom: 4 }}>Guardians Group</label>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 6 }}>Members of the selected group will have access to Family Manager. *</p>
|
||||
<select className="input" value={guardiansGroupId} disabled={!canChange}
|
||||
onChange={e => setGuardiansGroupId(e.target.value)}>
|
||||
<option value="">— Select group —</option>
|
||||
{userGroups.map(g => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }}>
|
||||
* Open Group Manager to create a different group, if none are suitable in these lists.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !canChange}>
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Registration Tab ──────────────────────────────────────────────────────────
|
||||
function RegistrationTab({ onFeaturesChanged }) {
|
||||
const toast = useToast();
|
||||
@@ -153,7 +359,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div className="settings-section-label">Serial Number</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}>
|
||||
<input className="input flex-1" value={serialNumber} readOnly style={{ fontFamily: 'monospace', letterSpacing: 1 }} autoComplete="new-password" />
|
||||
<input className="input flex-1" value={serialNumber} readOnly style={{ fontFamily: 'monospace', letterSpacing: 1 }} autoComplete="off" />
|
||||
<button className="btn btn-secondary btn-sm" onClick={handleCopySerial} style={{ flexShrink: 0 }}>
|
||||
{copied ? '✓ Copied' : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
||||
@@ -167,7 +373,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
||||
<div className="settings-section-label">Registration Code</div>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
|
||||
<input className="input flex-1" placeholder="Enter registration code" value={regCode}
|
||||
onChange={e => setRegCode(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRegister()} autoComplete="new-password" />
|
||||
onChange={e => setRegCode(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleRegister()} autoComplete="off" />
|
||||
<button className="btn btn-primary btn-sm" onClick={handleRegister} disabled={regLoading}>
|
||||
{regLoading ? '…' : 'Register'}
|
||||
</button>
|
||||
@@ -185,83 +391,7 @@ function RegistrationTab({ onFeaturesChanged }) {
|
||||
)}
|
||||
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}>
|
||||
Registration codes unlock application features. Contact your RosterChirp provider for a code.<br />
|
||||
<strong>RosterChirp-Brand</strong> — unlocks Branding.
|
||||
<strong>RosterChirp-Team</strong> — unlocks Branding, Group Manager and Schedule Manager.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Web Push Tab ──────────────────────────────────────────────────────────────
|
||||
function WebPushTab() {
|
||||
const toast = useToast();
|
||||
const [vapidPublic, setVapidPublic] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [showRegenWarning, setShowRegenWarning] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(({ settings }) => {
|
||||
setVapidPublic(settings.vapid_public || '');
|
||||
setLoading(false);
|
||||
}).catch(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const doGenerate = async () => {
|
||||
setGenerating(true);
|
||||
setShowRegenWarning(false);
|
||||
try {
|
||||
const { publicKey } = await api.generateVapidKeys();
|
||||
setVapidPublic(publicKey);
|
||||
toast('VAPID keys generated. Push notifications are now active.', 'success');
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed to generate keys', 'error');
|
||||
} finally { setGenerating(false); }
|
||||
};
|
||||
|
||||
if (loading) return <p style={{ fontSize: 13, color: 'var(--text-secondary)' }}>Loading…</p>;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="settings-section-label" style={{ marginBottom: 12 }}>Web Push Notifications (VAPID)</div>
|
||||
|
||||
{vapidPublic ? (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '10px 12px', marginBottom: 10 }}>
|
||||
<div style={{ fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 4, textTransform: 'uppercase', letterSpacing: '0.5px' }}>Public Key</div>
|
||||
<code style={{ fontSize: 11, color: 'var(--text-primary)', wordBreak: 'break-all', lineHeight: 1.5, display: 'block' }}>{vapidPublic}</code>
|
||||
</div>
|
||||
<span style={{ fontSize: 13, color: 'var(--success)', display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>
|
||||
Push notifications active
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-secondary)', marginBottom: 12 }}>
|
||||
No VAPID keys found. Generate keys to enable Web Push notifications.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{showRegenWarning && (
|
||||
<div style={{ background: '#fce8e6', border: '1px solid #f5c6c2', borderRadius: 'var(--radius)', padding: '14px 16px', marginBottom: 16 }}>
|
||||
<p style={{ fontSize: 13, fontWeight: 600, color: 'var(--error)', marginBottom: 8 }}>⚠️ Regenerate VAPID keys?</p>
|
||||
<p style={{ fontSize: 13, color: '#5c2c28', marginBottom: 12, lineHeight: 1.5 }}>
|
||||
Generating new keys will <strong>invalidate all existing push subscriptions</strong>. Users will need to re-enable notifications.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm" style={{ background: 'var(--error)', color: 'white' }} onClick={doGenerate} disabled={generating}>{generating ? 'Generating…' : 'Yes, regenerate keys'}</button>
|
||||
<button className="btn btn-secondary btn-sm" onClick={() => setShowRegenWarning(false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!showRegenWarning && (
|
||||
<button className="btn btn-primary btn-sm" onClick={() => vapidPublic ? setShowRegenWarning(true) : doGenerate()} disabled={generating}>
|
||||
{generating ? 'Generating…' : vapidPublic ? 'Regenerate Keys' : 'Generate Keys'}
|
||||
</button>
|
||||
)}
|
||||
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 12, lineHeight: 1.5 }}>
|
||||
Requires HTTPS. On iOS, the app must be installed to the home screen first.
|
||||
Registration codes unlock application features. Contact your RosterChirp provider for a code.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -269,7 +399,7 @@ function WebPushTab() {
|
||||
|
||||
// ── Main modal ────────────────────────────────────────────────────────────────
|
||||
export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||
const [tab, setTab] = useState('registration');
|
||||
const [tab, setTab] = useState('login-type');
|
||||
const [appType, setAppType] = useState('RosterChirp-Chat');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -283,12 +413,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||
|
||||
const isTeam = appType === 'RosterChirp-Team';
|
||||
|
||||
const tabs = [
|
||||
isTeam && { id: 'team', label: 'Team Management' },
|
||||
{ id: 'registration', label: 'Registration' },
|
||||
{ id: 'webpush', label: 'Web Push' },
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 520 }}>
|
||||
@@ -299,18 +423,21 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab buttons */}
|
||||
<div className="flex gap-2" style={{ marginBottom: 24 }}>
|
||||
{tabs.map(t => (
|
||||
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
{/* Select navigation */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
|
||||
<select className="input" value={tab} onChange={e => setTab(e.target.value)}>
|
||||
<option value="login-type">Login Type</option>
|
||||
<option value="messages">Messages</option>
|
||||
{isTeam && <option value="team">Tools</option>}
|
||||
<option value="registration">Registration</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{tab === 'messages' && <MessagesTab />}
|
||||
{tab === 'team' && <TeamManagementTab />}
|
||||
{tab === 'login-type' && <LoginTypeTab />}
|
||||
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
|
||||
{tab === 'webpush' && <WebPushTab />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
/* Mobile FAB */
|
||||
.newchat-fab {
|
||||
position: absolute;
|
||||
bottom: 80px;
|
||||
bottom: calc(80px + env(safe-area-inset-bottom, 0px));
|
||||
right: 16px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
@@ -181,7 +181,7 @@
|
||||
|
||||
.footer-menu {
|
||||
position: absolute;
|
||||
bottom: 68px;
|
||||
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
background: white;
|
||||
|
||||
@@ -13,6 +13,75 @@ function nameToColor(name) {
|
||||
return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length];
|
||||
}
|
||||
|
||||
// Layouts for composite avatars inside a 44×44 circle (all values in px)
|
||||
const COMPOSITE_LAYOUTS = {
|
||||
1: [{ top: 4, left: 4, size: 36 }],
|
||||
2: [
|
||||
{ top: 11, left: 1, size: 21 },
|
||||
{ top: 11, right: 1, size: 21 },
|
||||
],
|
||||
3: [
|
||||
{ top: 2, left: 3, size: 19 },
|
||||
{ top: 2, right: 3, size: 19 },
|
||||
{ bottom: 2, left: 12, size: 19 },
|
||||
],
|
||||
4: [
|
||||
{ top: 1, left: 1, size: 20 },
|
||||
{ top: 1, right: 1, size: 20 },
|
||||
{ bottom: 1, left: 1, size: 20 },
|
||||
{ bottom: 1, right: 1, size: 20 },
|
||||
],
|
||||
};
|
||||
|
||||
function GroupAvatarComposite({ memberPreviews }) {
|
||||
const members = (memberPreviews || []).slice(0, 4);
|
||||
const n = members.length;
|
||||
const positions = COMPOSITE_LAYOUTS[n];
|
||||
|
||||
if (!positions) {
|
||||
return (
|
||||
<div className="group-icon" style={{ background: '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>
|
||||
?
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="group-icon" style={{ background: 'transparent', position: 'relative', padding: 0, overflow: 'visible' }}>
|
||||
{members.map((m, i) => {
|
||||
const pos = positions[i];
|
||||
const base = {
|
||||
position: 'absolute',
|
||||
width: pos.size,
|
||||
height: pos.size,
|
||||
borderRadius: '50%',
|
||||
boxSizing: 'border-box',
|
||||
border: '2px solid var(--surface)',
|
||||
...(pos.top !== undefined ? { top: pos.top } : {}),
|
||||
...(pos.bottom !== undefined ? { bottom: pos.bottom } : {}),
|
||||
...(pos.left !== undefined ? { left: pos.left } : {}),
|
||||
...(pos.right !== undefined ? { right: pos.right } : {}),
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
};
|
||||
if (m.avatar) {
|
||||
return <img key={m.id} src={m.avatar} alt={m.name} style={{ ...base, objectFit: 'cover' }} />;
|
||||
}
|
||||
return (
|
||||
<div key={m.id} style={{
|
||||
...base,
|
||||
background: nameToColor(m.name),
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: Math.round(pos.size * 0.42), fontWeight: 700, color: 'white',
|
||||
}}>
|
||||
{(m.name || '')[0]?.toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useAppSettings() {
|
||||
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
|
||||
const fetchSettings = () => {
|
||||
@@ -55,6 +124,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
const toast = useToast();
|
||||
const settings = useAppSettings();
|
||||
|
||||
const msgPublic = features.msgPublic ?? true;
|
||||
const msgU2U = features.msgU2U ?? true;
|
||||
const msgPrivateGroup = features.msgPrivateGroup ?? true;
|
||||
const loginType = features.loginType || 'all_ages';
|
||||
const playersGroupId = features.playersGroupId ?? null;
|
||||
|
||||
const allGroups = [
|
||||
...(groups.publicGroups || []),
|
||||
...(groups.privateGroups || [])
|
||||
@@ -62,8 +137,18 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
|
||||
const publicFiltered = allGroups.filter(g => g.type === 'public');
|
||||
|
||||
// In groupMessagesMode show only managed groups; on main Messages hide managed groups
|
||||
const privateFiltered = [...allGroups.filter(g => g.type === 'private' && (groupMessagesMode ? g.is_managed : !g.is_managed))].sort((a, b) => {
|
||||
// In groupMessagesMode show only managed groups; on main Messages hide managed groups.
|
||||
// Also filter individual groups based on message feature flags.
|
||||
const privateFiltered = [...allGroups.filter(g => {
|
||||
if (g.type !== 'private') return false;
|
||||
if (groupMessagesMode) return g.is_managed;
|
||||
if (g.is_managed) return false;
|
||||
if (g.is_direct && !msgU2U) return false;
|
||||
if (!g.is_direct && !msgPrivateGroup) return false;
|
||||
// Guardian Only: hide the managed DM channel for the designated players group
|
||||
if (loginType === 'guardian_only' && g.is_managed && playersGroupId && g.source_user_group_id === playersGroupId) return false;
|
||||
return true;
|
||||
})].sort((a, b) => {
|
||||
if (!a.last_message_at && !b.last_message_at) return 0;
|
||||
if (!a.last_message_at) return 1;
|
||||
if (!b.last_message_at) return -1;
|
||||
@@ -101,6 +186,8 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>MG</div>
|
||||
) : group.is_managed ? (
|
||||
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>UG</div>
|
||||
) : group.composite_members?.length > 0 ? (
|
||||
<GroupAvatarComposite memberPreviews={group.composite_members} />
|
||||
) : (
|
||||
<div className="group-icon" style={{ background: group.type === 'public' ? (settings.color_avatar_public || '#1a73e8') : (settings.color_avatar_dm || '#a142f4') }}>
|
||||
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
|
||||
@@ -150,7 +237,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
</div>
|
||||
|
||||
<div className="groups-list">
|
||||
{!groupMessagesMode && publicFiltered.length > 0 && (
|
||||
{!groupMessagesMode && msgPublic && publicFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">PUBLIC MESSAGES</div>
|
||||
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
@@ -164,7 +251,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
|
||||
)}
|
||||
{groupMessagesMode && privateFiltered.length > 0 && (
|
||||
<div className="group-section">
|
||||
<div className="section-label">PRIVATE GROUP MESSAGES</div>
|
||||
<div className="section-label">USER GROUP MESSAGES</div>
|
||||
{privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useAuth } from '../contexts/AuthContext.jsx';
|
||||
import { useToast } from '../contexts/ToastContext.jsx';
|
||||
import { api } from '../utils/api.js';
|
||||
import Avatar from './Avatar.jsx';
|
||||
|
||||
function useTheme() {
|
||||
@@ -11,12 +13,255 @@ function useTheme() {
|
||||
return [dark, setDark];
|
||||
}
|
||||
|
||||
const PUSH_ENABLED_KEY = 'rc_push_enabled';
|
||||
|
||||
function usePushToggle() {
|
||||
// Show the toggle whenever the Notification API is present, not just when
|
||||
// already granted — so iOS users (where push is still being set up) can still
|
||||
// reach the toggle and trigger the permission request flow.
|
||||
const supported = 'serviceWorker' in navigator && typeof Notification !== 'undefined';
|
||||
const permitted = supported && Notification.permission === 'granted';
|
||||
const [enabled, setEnabled] = useState(() => localStorage.getItem(PUSH_ENABLED_KEY) !== 'false');
|
||||
|
||||
const toggle = async () => {
|
||||
if (enabled) {
|
||||
// Disable: remove the server subscription so no pushes are sent
|
||||
try {
|
||||
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
|
||||
await fetch('/api/push/unsubscribe', { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
} catch (e) { /* best effort */ }
|
||||
localStorage.removeItem('rc_fcm_token');
|
||||
localStorage.removeItem('rc_webpush_endpoint');
|
||||
localStorage.setItem(PUSH_ENABLED_KEY, 'false');
|
||||
setEnabled(false);
|
||||
} else {
|
||||
// Enable: re-run the registration flow
|
||||
localStorage.setItem(PUSH_ENABLED_KEY, 'true');
|
||||
setEnabled(true);
|
||||
window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
|
||||
}
|
||||
};
|
||||
|
||||
return { supported, permitted, enabled, toggle };
|
||||
}
|
||||
|
||||
// ── Debug helpers ─────────────────────────────────────────────────────────────
|
||||
function DebugRow({ label, value, ok, bad }) {
|
||||
const color = ok ? 'var(--success)' : bad ? 'var(--error)' : 'var(--text-secondary)';
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{label}</span>
|
||||
<span style={{ color, fontFamily: 'monospace', fontSize: 12 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Test Notifications Modal ──────────────────────────────────────────────────
|
||||
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
|
||||
const isAndroid = /Android/i.test(navigator.userAgent);
|
||||
const isMobileDevice = isIOS || isAndroid;
|
||||
|
||||
function TestNotificationsModal({ onClose }) {
|
||||
const toast = useToast();
|
||||
const { user } = useAuth();
|
||||
const isAdmin = user?.role === 'admin';
|
||||
const [debugData, setDebugData] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [permission, setPermission] = useState(
|
||||
(typeof Notification !== 'undefined') ? Notification.permission : 'unsupported'
|
||||
);
|
||||
const [cachedToken, setCachedToken] = useState(localStorage.getItem('rc_fcm_token'));
|
||||
const [lastError, setLastError] = useState(localStorage.getItem('rc_fcm_error'));
|
||||
|
||||
const load = async () => {
|
||||
if (!isAdmin) return; // debug endpoint is admin-only
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.pushDebug();
|
||||
setDebugData(data);
|
||||
} catch (e) {
|
||||
toast(e.message || 'Failed to load debug data', 'error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleGrantPermission = async () => {
|
||||
if (typeof Notification === 'undefined') {
|
||||
toast('Notifications not supported on this device/browser', 'error');
|
||||
return;
|
||||
}
|
||||
const result = await Notification.requestPermission();
|
||||
setPermission(result);
|
||||
if (result === 'granted') {
|
||||
window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
|
||||
toast('Permission granted — registering…', 'success');
|
||||
} else {
|
||||
toast('Permission denied', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const doTest = async (mode) => {
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await api.testPush(mode);
|
||||
const sent = result.results?.find(r => r.status === 'sent');
|
||||
const failed = result.results?.find(r => r.status === 'failed');
|
||||
if (sent) toast(`Test sent (mode=${mode}) — check device for notification`, 'success');
|
||||
else if (failed) toast(`Test failed: ${failed.error}`, 'error');
|
||||
else toast('No subscription found — grant permission and reload', 'error');
|
||||
} catch (e) {
|
||||
toast(e.message || 'Test failed', 'error');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const clearToken = () => {
|
||||
localStorage.removeItem('rc_fcm_token');
|
||||
localStorage.removeItem('rc_fcm_error');
|
||||
setCachedToken(null);
|
||||
setLastError(null);
|
||||
toast('Cached token cleared — reload to re-register with server', 'info');
|
||||
};
|
||||
|
||||
const reregister = () => {
|
||||
localStorage.removeItem('rc_fcm_token');
|
||||
localStorage.removeItem('rc_fcm_error');
|
||||
localStorage.removeItem('rc_webpush_endpoint'); // clear iOS webpush cache too
|
||||
setCachedToken(null);
|
||||
setLastError(null);
|
||||
window.dispatchEvent(new CustomEvent('rosterchirp:push-init'));
|
||||
toast('Re-registering push subscription…', 'info');
|
||||
};
|
||||
|
||||
const box = { background: 'var(--surface-variant)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '12px 14px', marginBottom: 14 };
|
||||
const sectionLabel = { fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: 8 };
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 520 }}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>Test Notifications</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* This device */}
|
||||
<div style={box}>
|
||||
<div style={sectionLabel}>This Device</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
||||
<DebugRow label="Permission" value={permission} ok={permission === 'granted'} bad={permission === 'denied'} />
|
||||
{!isIOS && !isAndroid && <DebugRow label="FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />}
|
||||
{isAndroid && (
|
||||
<div style={{ fontSize: 13 }}>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>FCM token</span>
|
||||
<div style={{ color: cachedToken ? 'var(--success)' : 'var(--error)', fontFamily: 'monospace', fontSize: 11, marginTop: 3, wordBreak: 'break-all', lineHeight: 1.5 }}>{cachedToken || 'None'}</div>
|
||||
</div>
|
||||
)}
|
||||
{!isIOS && debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
|
||||
{!isIOS && debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
|
||||
{lastError && <DebugRow label="Last reg. error" value={lastError} bad={true} />}
|
||||
</div>
|
||||
{permission === 'default' && (
|
||||
<button className="btn btn-sm btn-primary" onClick={handleGrantPermission} style={{ marginBottom: 8 }}>
|
||||
Grant Permission
|
||||
</button>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={reregister}>Re-register</button>
|
||||
{!isIOS && <button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear token</button>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test push */}
|
||||
<div style={box}>
|
||||
<div style={sectionLabel}>Send Test Notification to This Device</div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text-secondary)', marginBottom: 10, lineHeight: 1.5 }}>
|
||||
<strong>notification</strong> — same path as real messages (SW <code>onBackgroundMessage</code>)<br/>
|
||||
<strong>browser</strong> — Chrome shows it directly, bypasses the SW (confirm delivery works)
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn btn-sm btn-primary" onClick={() => doTest('notification')} disabled={testing}>
|
||||
{testing ? 'Sending…' : 'Test (notification)'}
|
||||
</button>
|
||||
<button className="btn btn-sm btn-secondary" onClick={() => doTest('browser')} disabled={testing}>
|
||||
{testing ? 'Sending…' : 'Test (browser)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registered devices — desktop only */}
|
||||
{!isMobileDevice && (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<div className="settings-section-label" style={{ margin: 0 }}>Registered Devices</div>
|
||||
<button className="btn btn-sm btn-secondary" onClick={load} disabled={loading}>{loading ? 'Loading…' : 'Refresh'}</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>Loading…</p>
|
||||
) : !debugData?.subscriptions?.length ? (
|
||||
<p style={{ fontSize: 13, color: 'var(--text-tertiary)' }}>No FCM tokens registered.</p>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{debugData.subscriptions.map(sub => (
|
||||
<div key={sub.id} style={box}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600 }}>{sub.name || sub.email}</span>
|
||||
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', background: 'var(--surface)', padding: '2px 7px', borderRadius: 4, border: '1px solid var(--border)' }}>{sub.device}</span>
|
||||
</div>
|
||||
<code style={{ fontSize: 10, color: 'var(--text-secondary)', wordBreak: 'break-all', lineHeight: 1.6, display: 'block' }}>
|
||||
{sub.fcm_token}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Confirm Modal ─────────────────────────────────────────────────────────────
|
||||
function ConfirmToggleModal({ enabling, onConfirm, onCancel }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onCancel()}>
|
||||
<div className="modal" style={{ maxWidth: 360 }}>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 700, margin: '0 0 12px' }}>
|
||||
{enabling ? 'Enable Notifications' : 'Disable Notifications'}
|
||||
</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--text-secondary)', marginBottom: 20, lineHeight: 1.5 }}>
|
||||
{enabling
|
||||
? 'Turn on push notifications for this device?'
|
||||
: 'Turn off push notifications? You will no longer receive alerts on this device.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
|
||||
<button className="btn btn-primary btn-sm" onClick={onConfirm}>
|
||||
{enabling ? 'Turn On' : 'Turn Off'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) {
|
||||
const { user, logout } = useAuth();
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [dark, setDark] = useTheme();
|
||||
const { supported: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle();
|
||||
const menuRef = useRef(null);
|
||||
const btnRef = useRef(null);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [showTestNotif, setShowTestNotif] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showMenu) return;
|
||||
@@ -32,6 +277,12 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
||||
|
||||
const handleLogout = async () => { await logout(); };
|
||||
|
||||
const handleToggleConfirm = () => {
|
||||
togglePush();
|
||||
setShowConfirm(false);
|
||||
setShowMenu(false);
|
||||
};
|
||||
|
||||
if (mobileCompact) return (
|
||||
<div style={{ position:'relative' }}>
|
||||
<button ref={btnRef} onClick={() => setShowMenu(!showMenu)} style={{ background:'none',border:'none',cursor:'pointer',padding:2,display:'flex',alignItems:'center' }}>
|
||||
@@ -44,11 +295,26 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
||||
<button key={label} onClick={action} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button>
|
||||
))}
|
||||
{showPushToggle && (
|
||||
<button onClick={() => { setShowMenu(false); setShowConfirm(true); }} style={{ display:'flex',alignItems:'center',width:'100%',padding:'11px 14px',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>
|
||||
<span style={{ flex:1, textAlign:'left' }}>Notifications</span>
|
||||
<span style={{ fontSize:12,fontWeight:700,color: pushEnabled ? '#22c55e' : '#ef4444' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
)}
|
||||
{showPushToggle && pushEnabled && (
|
||||
<button onClick={() => { setShowMenu(false); setShowTestNotif(true); }} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--primary)' }}
|
||||
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>
|
||||
Test Notifications
|
||||
</button>
|
||||
)}
|
||||
<div style={{ borderTop:'1px solid var(--border)' }}>
|
||||
<button onClick={handleLogout} style={{ display:'block',width:'100%',padding:'11px 14px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--error)' }}>Sign out</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showConfirm && <ConfirmToggleModal enabling={!pushEnabled} onConfirm={handleToggleConfirm} onCancel={() => setShowConfirm(false)} />}
|
||||
{showTestNotif && <TestNotificationsModal onClose={() => setShowTestNotif(false)} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -84,6 +350,12 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
||||
|
||||
{showMenu && (
|
||||
<div ref={menuRef} className="footer-menu">
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 4 }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, color: 'var(--text-primary)', paddingLeft: 4 }}>User Menu</span>
|
||||
<button onClick={() => setShowMenu(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '2px 4px', color: 'var(--text-tertiary)', lineHeight: 1 }} aria-label="Close menu">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); onProfile?.(); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Profile
|
||||
@@ -96,6 +368,23 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
|
||||
About
|
||||
</button>
|
||||
{showPushToggle && (
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); setShowConfirm(true); }}>
|
||||
{pushEnabled ? (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
) : (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
|
||||
)}
|
||||
<span style={{ flex: 1, textAlign: 'left' }}>Notifications</span>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: pushEnabled ? '#22c55e' : '#ef4444' }}>{pushEnabled ? 'ON' : 'OFF'}</span>
|
||||
</button>
|
||||
)}
|
||||
{showPushToggle && pushEnabled && (
|
||||
<button className="footer-menu-item" onClick={() => { setShowMenu(false); setShowTestNotif(true); }}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Test Notifications
|
||||
</button>
|
||||
)}
|
||||
<hr className="divider" style={{ margin: '4px 0' }} />
|
||||
<button className="footer-menu-item danger" onClick={handleLogout}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>
|
||||
@@ -103,6 +392,9 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirm && <ConfirmToggleModal enabling={!pushEnabled} onConfirm={handleToggleConfirm} onCancel={() => setShowConfirm(false)} />}
|
||||
{showTestNotif && <TestNotificationsModal onClose={() => setShowTestNotif(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -290,6 +290,9 @@ export default function UserManagerModal({ onClose }) {
|
||||
return (
|
||||
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="modal" style={{ maxWidth: 600, width: '100%' }}>
|
||||
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="off"
|
||||
on individual inputs is ignored by Chrome but respected on the form element */}
|
||||
<form autoComplete="off" onSubmit={e => e.preventDefault()}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
|
||||
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
|
||||
<button className="btn-icon" onClick={onClose}>
|
||||
@@ -421,6 +424,7 @@ export default function UserManagerModal({ onClose }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,7 @@ export function AuthProvider({ children }) {
|
||||
try { await api.logout(); } catch {}
|
||||
localStorage.removeItem('tc_token');
|
||||
sessionStorage.removeItem('tc_token');
|
||||
localStorage.removeItem('rc_fcm_token');
|
||||
setUser(null);
|
||||
setMustChangePassword(false);
|
||||
};
|
||||
|
||||
@@ -47,12 +47,15 @@ export function SocketProvider({ children }) {
|
||||
window.dispatchEvent(new CustomEvent('rosterchirp:session-displaced'));
|
||||
});
|
||||
|
||||
// Bug B fix: when app returns to foreground, force socket reconnect if disconnected
|
||||
// When app returns to foreground, force a full disconnect+reconnect.
|
||||
// The underlying WebSocket is often silently dead after Android background
|
||||
// suspension while socket.io-client still reports connected (stale state
|
||||
// until the ping/pong timeout fires ~45s later). Always force a fresh
|
||||
// connection so the "offline" indicator clears immediately on focus.
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
if (socketRef.current && !socketRef.current.connected) {
|
||||
socketRef.current.connect();
|
||||
}
|
||||
if (document.visibilityState === 'visible' && socketRef.current) {
|
||||
socketRef.current.disconnect();
|
||||
socketRef.current.connect();
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||