Compare commits

...

125 Commits

Author SHA1 Message Date
f7f7eb9d2f details update 2026-04-10 13:50:22 -04:00
b527e24705 update 2026-04-10 13:18:59 -04:00
1af039ab0a Update README.md 2026-04-09 16:28:17 +00:00
03496884d5 Update README.md 2026-04-09 16:27:45 +00:00
c87b5f0304 Update .env 2026-04-09 16:25:19 +00:00
b1ceb7cccb Delete 2026-04-09 16:22:07 +00:00
3a86385038 Delete 2026-04-09 16:21:56 +00:00
70750890e3 Delete 2026-04-09 16:21:37 +00:00
8f5310754c Update .env 2026-04-09 16:20:43 +00:00
d79839b438 Merge branch 'main' of https://gitea.stretchy.ca/rick/jama 2026-04-07 16:37:15 -04:00
f0a591f474 ok 2026-04-07 16:37:13 -04:00
2874285cc7 file meta-data update 2026-04-07 11:29:21 -04:00
c9d6a4d9d4 v.0.13.1 fixed minor UI issues, updated rules (bumped from v0.12.53) 2026-04-07 11:27:26 -04:00
dbea35abe2 signout bug fix 2026-04-05 11:28:50 -04:00
18c63953cc v0.12.53 Restricted Login type rule changes 2026-04-02 18:40:51 -04:00
4df92752bb v0.12.52 version bump 2026-04-02 18:16:23 -04:00
ae47f66ef5 hint messages update 2026-04-02 18:15:10 -04:00
e4ac7e248c bug fix: Family manager now shows on drawer navigation menu. 2026-04-02 15:34:37 -04:00
18e4a92241 minor bug fixes 2026-04-02 15:22:38 -04:00
97b308e9f0 v0.12.51 updated "Mixed Age" login type. 2026-04-02 12:50:50 -04:00
1d4116d1a3 v0.12.50 Updated to Family Manager and Events modal 2026-04-02 09:58:15 -04:00
6de899112b Family manager bug fixes 2026-04-01 18:47:36 -04:00
3910063ed3 v0.12.49 family rules update 2026-04-01 16:26:58 -04:00
7031979571 v0.12.49 Login Type and Event bug fixes 2026-04-01 09:25:17 -04:00
a3a878854e v0.12.48 Login Type bug fixes 2026-03-31 20:11:40 -04:00
f942bc45b9 bug fixes 2026-03-31 14:12:51 -04:00
9c263e7e8d v0.12.47 Add Child alias update 2026-03-31 13:51:47 -04:00
350bb25ecd v0.12.46 host bug fixes and password reset feature, 2026-03-31 12:21:59 -04:00
d0f10c4d7e v0.12.45 fixed Guardian only feature 2026-03-30 19:07:15 -04:00
1a85d3930e v0.12.44 message notification updates 2026-03-30 16:32:21 -04:00
c82d113adf Merge branch 'main' of https://gitea.stretchy.ca/rick/jama 2026-03-30 16:02:11 -04:00
fe836ae69f v0.12.43 minor protection added 2026-03-30 16:02:09 -04:00
ec6246bd72 virtual update from "jama" 2026-03-30 15:45:40 -04:00
e8e941c436 v0.12.42 new availibilty list download 2026-03-30 08:39:55 -04:00
6a2f4438f9 v0.12.41 New settings options for messages 2026-03-30 08:04:36 -04:00
ff6743c9b1 v0.12.40 iso notificastion bug fix 2026-03-29 23:21:35 -04:00
d03baec163 group manager scrollable bug fix. 2026-03-29 19:24:02 -04:00
b456143d20 v0.12.39 bump 2026-03-29 16:01:36 -04:00
2710a9c111 event form fix 2026-03-29 15:48:56 -04:00
93689d4486 v0.12.38 event form bug fixes 2026-03-29 15:38:16 -04:00
cfb351a251 v0.12.37 bumped build number 2026-03-29 10:41:21 -04:00
2dffeb1fde major recurring event structure changes 2026-03-29 10:40:06 -04:00
4b4ddf0825 fixed the reccurring event delete bug 2026-03-28 22:28:46 -04:00
43ff0f450d recurring event delete bug 2026-03-28 22:02:21 -04:00
f4dfa6eeca user's event save is fixed. 2026-03-28 21:45:32 -04:00
36e1be8f40 event notification update 2026-03-28 21:23:19 -04:00
3bf01cba1f schedules bug fix 2026-03-28 21:03:12 -04:00
12c4a154e5 alignment of lists and user menu text 2026-03-28 20:31:21 -04:00
2037bb1caa new note bug fix on mobile. 2026-03-28 20:11:25 -04:00
1ed9d9d95e add the option for the user to add a note to their availability 2026-03-28 19:54:01 -04:00
a43d067e61 update availability view 2026-03-28 19:29:47 -04:00
a6ac21aed0 updated auto-generated avatars to have transaparent backgrounds 2026-03-28 15:29:52 -04:00
5c5f2b4050 css fix for about tab 2026-03-28 15:19:27 -04:00
252c0e09cb v0.12.26 iOS notification bug fix 2026-03-28 14:51:00 -04:00
f40bb123d2 trivial update 2026-03-28 14:28:31 -04:00
d07d9e3919 v0.12.35 UI fixes 2026-03-28 14:18:20 -04:00
76edd7abd1 v0.12.34 iOS bug fixes 2026-03-28 13:22:02 -04:00
fb9d4dc956 v0.12.33 text cleanup of the app and bug fixes. 2026-03-28 12:55:53 -04:00
eb3e45d88f priviate group avatars update 2026-03-28 12:02:57 -04:00
d7790bb7ef chrome mobile autofill bug fix 2026-03-28 11:27:36 -04:00
a0d7125dd3 iOS message window bugs 2026-03-28 11:09:31 -04:00
459ab27c5b pinch zoom bug fix 2026-03-28 11:06:59 -04:00
abd4574ee3 swipe bug fix 2026-03-28 10:00:52 -04:00
f50f2aaba1 close button on user menu 2026-03-27 23:00:59 -04:00
7476ca5cd1 input fix for chome 2026-03-27 22:51:02 -04:00
f1683e2ff5 iOS button bug fix 2026-03-27 22:41:38 -04:00
407e9ee731 bug fix 2026-03-27 16:57:07 -04:00
8a21ffddb5 iOS bug fixes 2026-03-27 16:34:09 -04:00
2b2b184f04 bug fix 2026-03-27 16:15:33 -04:00
eea7cb91e7 bug fixes 2026-03-27 15:52:43 -04:00
05f7d48bf1 bug fixes 2026-03-27 15:04:52 -04:00
fe55e6481a v0.12.32 bug fixes 2026-03-27 14:38:08 -04:00
97f1dace4f v0.12.31 multiple UI changes 2026-03-27 10:19:52 -04:00
d6a37d5948 v0.12.30 add notifications for iOS 2026-03-26 14:49:17 -04:00
6e5c39607c minor text update on user manager forms 2026-03-26 13:56:39 -04:00
13e5e3a627 v0.12.29 various bug fixes 2026-03-26 09:46:35 -04:00
92dbcf2780 build.sh fix 2026-03-25 13:05:46 -04:00
ba91fce44c v0.12.28 new modal window for event edit/delete 2026-03-25 13:00:43 -04:00
2b2e98fa48 fixed recurring event bug 2026-03-25 12:38:56 -04:00
0b03f15e4a scroll update for schedule list view 2026-03-25 12:07:07 -04:00
6af892c9a6 schedule views update 2026-03-25 09:10:44 -04:00
941d216f38 day view on mobile bug fix 2026-03-25 08:49:14 -04:00
163d71d505 v0.12.27 schedule list view update 2026-03-25 07:56:35 -04:00
8c4650d1bc FCM bug fix 2026-03-24 19:16:36 -04:00
f48ce589ca v0.12.26 FCM feature changes 2026-03-24 19:04:09 -04:00
225dcd718b v0.12.25 FCM bug fixes 2026-03-24 18:13:15 -04:00
7276228a98 v0.12.24 minor user manager buf fixes 2026-03-24 16:10:27 -04:00
e77176841c v0.12.23 group manager display update 2026-03-24 15:55:48 -04:00
72094d7d15 v0.12.22 User Manager updates 2026-03-24 15:19:32 -04:00
65e7cc4007 v0.12.21 adjusted mobile group manager ui 2026-03-24 13:08:30 -04:00
9dd3392e95 v0.12.20 added option to not create user group dm 2026-03-24 12:41:17 -04:00
780020fc46 V0.12.19 Scroll full page issue bug fixes 2026-03-24 11:15:16 -04:00
d0c15287c4 v0.12.18 fixed user manager scroll issue 2026-03-24 10:56:23 -04:00
85177a643f favicon.ico update 2026-03-24 10:41:52 -04:00
ce6e03d66b v0.12.17 schedule view update 2026-03-24 09:40:49 -04:00
b5672eb4a2 v0.12.16 drawer menu update for member user 2026-03-24 09:20:40 -04:00
7c0c3e1132 v0.12.15 PWA bug and schema error for GM 2026-03-24 08:58:09 -04:00
117b5cbe4c v0.12.14 FCM optimization 2026-03-24 08:22:56 -04:00
bb5a3b6813 code cleanup 2026-03-24 08:07:21 -04:00
44799f76cc v0.12.13 user manager permissions + member event calendar 2026-03-24 07:57:03 -04:00
2e3e4100f5 v0.12.12 user manager update 2026-03-24 07:34:03 -04:00
dec24eb842 v0.12.11 new drawer menu notification feature 2026-03-23 22:59:03 -04:00
bcd9f4a060 v0.12.10 ui bug fixes 2026-03-23 22:34:42 -04:00
477b25dfa0 v0.12.9 bug fixes (FCM and list ordering) 2026-03-23 21:43:35 -04:00
01f37e60be v0.12.8 FCM bug fix 2026-03-23 19:34:13 -04:00
eca93aae28 v0.12.7 FCM bug fixes 2026-03-23 19:10:21 -04:00
ad67330d20 update 2026-03-23 17:29:57 -04:00
a0183458eb v0.12.6 FCM updates 2026-03-23 13:11:47 -04:00
10e3df25f9 icons 2026-03-23 12:34:51 -04:00
048abcfbfd v0.12.5 FCM bug fixes 2026-03-23 12:07:52 -04:00
f9024a6f3a icon updates 2026-03-23 11:52:23 -04:00
b3cc9727e4 v0.12.4 windsurf changes 2026-03-23 11:46:22 -04:00
cf9b22feb5 icon test 2026-03-23 11:04:52 -04:00
3c7d3002f1 logo update 2026-03-23 10:38:39 -04:00
de15d28d3a v0.12.3 FCM bug test 2026-03-23 10:11:07 -04:00
15bc1d110e pwa icon update 2026-03-23 10:02:30 -04:00
6e179eb1ec new icon update 2026-03-23 10:01:04 -04:00
edc7885a6b FCM testing 2026-03-23 09:32:25 -04:00
14c80f436a FCM test 2026-03-23 08:14:02 -04:00
2d164958d8 FCM test 2026-03-22 23:31:19 -04:00
64522764cb missing version numbers 2026-03-22 23:29:34 -04:00
2495a2c358 v0.11.28 FCM bug fixes 2026-03-22 23:10:17 -04:00
bfb67261b2 set priority icons 2026-03-22 23:01:41 -04:00
3d7e75a1e6 v0.11.27 message bug fixes 2026-03-22 22:48:46 -04:00
d2ed487079 v0.11.26 FCM bugs fixes 2026-03-22 21:39:09 -04:00
110 changed files with 11976 additions and 1150 deletions

View 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
View File

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

View File

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

@@ -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/ ← 001006 SQL files, auto-applied on startup
│ │ └── migrations/ ← 001008 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,00030,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,00010,000 | Single Node, single Postgres |
| Phase 1 (PgBouncer) | ~20,00040,000 | + connection pooler, no code changes |
| Phase 2 (Redis) | ~200,000500,000 | + Redis, multiple Node instances |
| Phase 3 (Read replicas) | ~500,0001,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, 12 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.

View File

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

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

View 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

File diff suppressed because it is too large Load Diff

View 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.

View File

@@ -0,0 +1,77 @@
Gemini said
Ive reviewed your Service Worker code. Its 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.

View 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/ # 001008 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
```

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1 @@
ALTER TABLE event_availability ADD COLUMN IF NOT EXISTS note VARCHAR(20);

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@
# ─────────────────────────────────────────────────────────────
set -euo pipefail
VERSION="${1:-0.12.1}"
VERSION="${1:-0.13.1}"
ACTION="${2:-}"
REGISTRY="${REGISTRY:-}"
IMAGE_NAME="rosterchirp"

View File

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

View File

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

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

View 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

File diff suppressed because it is too large Load Diff

57
fcm-app/nginx.conf Normal file
View 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
View 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
View 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);
});
});
}

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
fcm-app/public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

111
fcm-app/public/index.html Normal file
View 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>

View 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
View 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
View 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})`);
});

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "rosterchirp-frontend",
"version": "0.11.25",
"version": "0.13.1",
"private": true,
"scripts": {
"dev": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 682 B

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

After

Width:  |  Height:  |  Size: 213 KiB

View File

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

View File

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

View File

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

View 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);
}
})()
);
});

View 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?

View File

@@ -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) => {

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -83,6 +83,9 @@
color: var(--text-secondary);
margin-bottom: 3px;
padding: 0 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Reply preview */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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.&nbsp;
<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>
);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More