Compare commits

...

86 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
72 changed files with 6576 additions and 881 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 #** Required
DB_PASSWORD=C@nuck2024 DB_PASSWORD=r0sterCh!rp2026
JWT_SECRET=changemesupersecretjwtkey JWT_SECRET=changemesupersecretjwtkey
#** App identity #** App identity
PROJECT_NAME=jama PROJECT_NAME=rosterchirp
APP_NAME=RosterChirp APP_NAME=RosterChirp
DEFCHAT_NAME=General Chat DEFCHAT_NAME=General Chat
ADMIN_NAME=Admin User ADMIN_NAME=Admin User
ADMIN_EMAIL=admin@rosterchirp.local ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASS=Admin@1234 ADMIN_PASS=Admin@1234
ADMPW_RESET=false ADMPW_RESET=false
#** Database #** Database
# DB names intentionally kept as 'jama' — matches the existing live database DB_NAME=rosterchirp
DB_NAME=jama DB_USER=rosterchirp
DB_USER=jama
# DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432) # DB_HOST and DB_PORT are set automatically in docker-compose (host=db, port=5432)
#** Tenancy mode #** Tenancy mode
# selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team) # selfhost = single tenant (RosterChirp-Chat / RosterChirp-Brand / RosterChirp-Team)
# host = multi-tenant (RosterChirp-Host only) # host = multi-tenant (RosterChirp-Host only)
APP_TYPE=host APP_TYPE=selfhost
#** RosterChirp-Host only (ignored in selfhost mode) #** RosterChirp-Host only (ignored in selfhost mode)
HOST_DOMAIN=jamahost.stretchy.ca HOST_DOMAIN=yourdomain.com
HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH HOST_ADMIN_KEY=VBGFHETSTTGRDDWAASJKH
#** Optional #** Optional
@@ -32,11 +31,16 @@ TZ=America/Toronto
#** Firebase Cloud Messaging (FCM) — Android background push #** Firebase Cloud Messaging (FCM) — Android background push
# Web app config — from Firebase Console → Project Settings → General → Your apps # Web app config — from Firebase Console → Project Settings → General → Your apps
FIREBASE_API_KEY=AIzaSyDx191unzXFT4WA1OvkdbrIY_c57kgruAU FIREBASE_API_KEY=
FIREBASE_PROJECT_ID=rosterchirp-push FIREBASE_PROJECT_ID=
FIREBASE_MESSAGING_SENDER_ID=126479377334 FIREBASE_MESSAGING_SENDER_ID=
FIREBASE_APP_ID=1:126479377334:web:280abdd135cf7e0c50d717 FIREBASE_APP_ID=
# VAPID key — from Firebase Console → Project Settings → Cloud Messaging → Web Push certificates # 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 # 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 APP_TYPE=selfhost
# ── RosterChirp-Host only (ignored in selfhost mode) ───────────────────────────────── # ── 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 # 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 ────────────────────────────────────────────────────────────────── # ── Optional ──────────────────────────────────────────────────────────────────
PORT=3000 PORT=3000
TZ=UTC 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. # 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_API_KEY=
# FIREBASE_PROJECT_ID= # FIREBASE_PROJECT_ID=
# FIREBASE_MESSAGING_SENDER_ID= # FIREBASE_MESSAGING_SENDER_ID=
# FIREBASE_APP_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= # FIREBASE_VAPID_KEY=
# Get service account JSON from: Firebase Console → Project Settings → Service accounts → Generate new private 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: # -- Paste the entire JSON content as a single-line string (include curlybracket to curlybracket):
# FIREBASE_SERVICE_ACCOUNT={"type":"service_account","project_id":"..."} # 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/

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. **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 │ │ └── auth.js ← JWT auth, teamManagerMiddleware
│ ├── models/ │ ├── models/
│ │ ├── db.js ← Postgres pool, query helpers, migrations, seeding │ │ ├── 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/ │ ├── routes/
│ │ ├── auth.js │ │ ├── auth.js
│ │ ├── groups.js ← receives io │ │ ├── groups.js ← receives io
@@ -106,7 +106,7 @@ rosterchirp/
## Version Bump — Files to Update ## 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" backend/package.json "version": "X.Y.Z"
@@ -116,7 +116,7 @@ build.sh VERSION="${1:-X.Y.Z}"
One-liner: One-liner:
```bash ```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\": \"$OLD\"/\"version\": \"$NEW\"/" backend/package.json frontend/package.json
sed -i "s/VERSION=\"\${1:-$OLD}\"/VERSION=\"\${1:-$NEW}\"/" build.sh 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. **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 ## 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 ## Outstanding / Deferred Work
### Android Background Push (KNOWN_LIMITATIONS.md) ### iOS Push Notifications
**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. **Status:** In progress. Android working (v0.12.26+). iOS PWA push requires additional handling — investigation ongoing.
### WebSocket Reconnect on Focus ### WebSocket Reconnect on Focus
**Status:** Deferred. Socket drops when Android PWA is backgrounded. **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_DOMAIN= # host mode only
HOST_ADMIN_KEY= # host mode only HOST_ADMIN_KEY= # host mode only
JWT_SECRET= JWT_SECRET=
DB_HOST=db DB_HOST=db # set to 'pgbouncer' after Phase 1
DB_NAME=rosterchirp DB_NAME=rosterchirp
DB_USER=rosterchirp DB_USER=rosterchirp
DB_PASSWORD= # avoid ! (shell interpolation issue with docker-compose) 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_APP_ID= # FCM web app config
FIREBASE_VAPID_KEY= # FCM Web Push certificate public key FIREBASE_VAPID_KEY= # FCM Web Push certificate public key
FIREBASE_SERVICE_ACCOUNT= # FCM service account JSON (stringified, backend only) 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 ## 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) # CF_API_TOKEN=your_cloudflare_token (or equivalent)
# #
# 3. Add a wildcard DNS record in your DNS provider: # 3. Add a wildcard DNS record in your DNS provider:
# *.rosterchirp.com → your server IP # *.example.com → your server IP
# rosterchirp.com → your server IP
# #
# Usage: # Usage:
# Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it) # Copy this file to /etc/caddy/Caddyfile (or wherever Caddy reads it)
# Reload: caddy reload # Reload: caddy reload
# ── Wildcard subdomain ──────────────────────────────────────────────────────── # ── Wildcard subdomain ────────────────────────────────────────────────────────
# Handles team1.rosterchirp.com, teamB.rosterchirp.com, etc. # Handles mychat.example.com, teamB.example.com, chathost.example.com, etc.
# Replace rosterchirp.com with your actual HOST_DOMAIN. # Replace example.com with your actual APP_DOMAIN.
*.rosterchirp.com { *.example.com {
tls { tls {
dns cloudflare {env.CF_API_TOKEN} 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 ───────────────────────────────────────────────────── # ── Custom tenant domains ─────────────────────────────────────────────────────
# When a tenant sets up a custom domain (e.g. chat.theircompany.com): # 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. # 2. You add a block here and reload Caddy.
# Caddy will automatically obtain and renew the SSL cert. # Caddy will automatically obtain and renew the SSL cert.
@@ -80,7 +69,7 @@ rosterchirp.com {
# } # }
# } # }
# #
# *.rosterchirp.com, rosterchirp.com { # *.example.com {
# tls { on_demand } # tls { on_demand }
# reverse_proxy localhost:3000 # 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 💬 <<<<<<< HEAD
### *just another messaging app* # 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 - **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 - **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 - **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 ### Users & Profiles
- **Authentication** — Email/password login with optional Remember Me (30-day session) - **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 - **User profiles** — Custom display name, avatar upload, About Me text
- **Profile popup** — Click any user's avatar in chat to view their profile card - **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 - **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 ### Notifications
- **In-app notifications** — Mention alerts with toast notifications - **In-app notifications** — Mention alerts with toast notifications
- **Unread indicators** — Private groups with new unread messages are highlighted and bolded in the sidebar - **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 ### Admin & Settings
- **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles - **User Manager** — Create, suspend, activate, delete users; reset passwords; change roles
- **Bulk CSV import** — Import multiple users at once from a CSV file - **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 - **Reset to defaults** — One-click reset of all branding customizations
- **Version display** — Current app version shown in the Settings panel - **Version display** — Current app version shown in the Settings panel
- **Default user password** — Configurable via `USER_PASS` env var; shown live in User Manager - **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 ### Help & Onboarding
- **Getting Started modal** — Appears automatically on first login; users can dismiss permanently with "Do not show again" - **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 ## Tech Stack
| Layer | Technology | | Layer | Technology |
|---|---| |---|---|
| Backend | Node.js, Express, Socket.io | | Backend | Node.js, Express, Socket.io |
| Database | SQLite (better-sqlite3) | | Database | PostgreSQL 16 (via `pg`) |
| Frontend | React 18, Vite | | Frontend | React 18, Vite |
| Markdown rendering | marked | | Push notifications | Firebase Cloud Messaging (FCM) |
| Emoji picker | emoji-mart |
| Image processing | sharp | | Image processing | sharp |
| Push notifications | web-push (VAPID) | | Containerization | Docker, Docker Compose v2 |
| Containerization | Docker, Docker Compose |
| Reverse proxy / SSL | Caddy (recommended) | | Reverse proxy / SSL | Caddy (recommended) |
--- ---
@@ -86,8 +137,9 @@ A modern, self-hosted team messaging Progressive Web App (PWA) built for small t
## Requirements ## Requirements
- **Docker** and **Docker Compose v2** - **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) - 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.sh
# Build and tag as a specific version # 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 ### 1. Clone the repository
```bash ```bash
git clone https://your-gitea/youruser/jama.git <<<<<<< HEAD
cd jama git clone https://your-git/youruser/rosterchirp.git
=======
git clone https://your-gitea/youruser/rosterchirp.git
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
cd rosterchirp
``` ```
### 2. Build the Docker image ### 2. Build the Docker image
```bash ```bash
./build.sh 1.0.0 ./build.sh 0.13.1
``` ```
### 3. Configure environment ### 3. Configure environment
@@ -129,13 +185,13 @@ cp .env.example .env
nano .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 ```bash
docker compose up -d docker compose up -d
docker compose logs -f jama docker compose logs -f rosterchirp
``` ```
### 5. Log in ### 5. Log in
@@ -146,40 +202,79 @@ Open `http://your-server:3000`, log in with your `ADMIN_EMAIL` and `ADMIN_PASS`,
## HTTPS & SSL ## 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 ### Caddyfile
``` ```
chat.yourdomain.com { chat.yourdomain.com {
reverse_proxy jama:3000 reverse_proxy rosterchirp:3000
} }
``` ```
### docker-compose.yaml (with Caddy) ### docker-compose.yaml (with Caddy)
```yaml ```yaml
version: '3.8'
services: services:
jama: rosterchirp:
image: jama:${JAMA_VERSION:-latest} <<<<<<< HEAD
container_name: jama image: rosterchirp:${ROSTERCHIRP_VERSION:-latest}
=======
image: rosterchirp:${rosterchirp_VERSION:-latest}
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
container_name: rosterchirp
restart: unless-stopped restart: unless-stopped
expose: expose:
- "3000" - "3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- APP_TYPE=${APP_TYPE:-selfhost}
- ADMIN_NAME=${ADMIN_NAME:-Admin User} - 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} - ADMIN_PASS=${ADMIN_PASS:-Admin@1234}
- USER_PASS=${USER_PASS:-user@1234} - USER_PASS=${USER_PASS:-user@1234}
- ADMPW_RESET=${ADMPW_RESET:-false} - ADMPW_RESET=${ADMPW_RESET:-false}
- JWT_SECRET=${JWT_SECRET:-changeme} - JWT_SECRET=${JWT_SECRET:-changeme}
- APP_NAME=${APP_NAME:-jama} <<<<<<< HEAD
- JAMA_VERSION=${JAMA_VERSION:-latest} - 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: volumes:
- jama_db:/app/data - rosterchirp_uploads:/app/uploads
- jama_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: caddy:
image: caddy:alpine image: caddy:alpine
@@ -194,11 +289,11 @@ services:
- caddy_data:/data - caddy_data:/data
- caddy_certs:/config - caddy_certs:/config
depends_on: depends_on:
- jama - rosterchirp
volumes: volumes:
jama_db: rosterchirp_db:
jama_uploads: rosterchirp_uploads:
caddy_data: caddy_data:
caddy_certs: caddy_certs:
``` ```
@@ -209,24 +304,57 @@ volumes:
| Variable | Default | Description | | 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`) | | `TZ` | `UTC` | Container timezone (e.g. `America/Toronto`) |
| `ADMIN_NAME` | `Admin User` | Display name of the default admin account | | `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 | | `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 | | `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.** | | `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) | | `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 | | `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` ### Example `.env`
```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 TZ=America/Toronto
ADMIN_NAME=Your Name ADMIN_NAME=Your Name
@@ -238,9 +366,17 @@ ADMPW_RESET=false
JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars JWT_SECRET=replace-this-with-a-long-random-string-at-least-32-chars
<<<<<<< HEAD
APP_NAME=RosterChirp
=======
PORT=3000 PORT=3000
APP_NAME=jama APP_NAME=rosterchirp
>>>>>>> 1af039ab0a72560aace9b284d541f5201c920e28
DEFCHAT_NAME=General Chat 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 | | Reset password | User is forced to set a new password on next login |
| Suspend | Blocks login; messages are preserved | | Suspend | Blocks login; messages are preserved |
| Activate | Re-enables a suspended account | | 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 | | Change role | Promote member → admin or demote admin → member |
### CSV Import Format ### CSV Import Format
@@ -292,6 +428,7 @@ Jane Smith,jane@example.com,,admin
| Rename | Admin only | Owner only | ❌ Not allowed | | Rename | Admin only | Owner only | ❌ Not allowed |
| Read-only mode | ✅ Optional | ❌ N/A | ❌ N/A | | Read-only mode | ✅ Optional | ❌ N/A | ❌ N/A |
| Duplicate prevention | N/A | ✅ Redirects to existing | ✅ Redirects to existing | | Duplicate prevention | N/A | ✅ Redirects to existing | ✅ Redirects to existing |
| Managed (Group Manager) | ❌ | ✅ Optional | ❌ |
### @Mention Scoping ### @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 ## 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. 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 ```bash
nano data/help.md 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**. 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 | | Volume | Container path | Contents |
|---|---|---| |---|---|---|
| `jama_db` | `/app/data` | SQLite database (`jama.db`), `help.md` | <<<<<<< HEAD
| `jama_uploads` | `/app/uploads` | Avatars, logos, PWA icons, message images | | `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 ### Backup
```bash ```bash
# Backup database # 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 \ docker run --rm \
-v jama_db:/data \ -v rosterchirp_db:/data \
-v $(pwd):/backup alpine \ -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 # Backup uploads
docker run --rm \ docker run --rm \
-v jama_uploads:/data \ -v rosterchirp_uploads:/data \
-v $(pwd):/backup alpine \ -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 ## Upgrades & Rollbacks
Database migrations run automatically on startup. There is no manual migration step.
```bash ```bash
# Upgrade # Upgrade
./build.sh 1.1.0 <<<<<<< HEAD
# Set JAMA_VERSION=1.1.0 in .env ./build.sh 0.13.1
# Set ROSTERCHIRP_VERSION=0.13.1 in .env
docker compose up -d docker compose up -d
# Rollback # 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 docker compose up -d
``` ```
@@ -372,7 +563,7 @@ Data volumes are untouched in both cases.
| File | Purpose | | 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 | | `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 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 ## License
MIT Proprietary — all rights reserved.

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,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,6 +1,6 @@
{ {
"name": "rosterchirp-backend", "name": "rosterchirp-backend",
"version": "0.12.24", "version": "0.13.1",
"description": "RosterChirp backend server", "description": "RosterChirp backend server",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -20,7 +20,8 @@
"sharp": "^0.33.2", "sharp": "^0.33.2",
"socket.io": "^4.6.1", "socket.io": "^4.6.1",
"csv-parse": "^5.5.6", "csv-parse": "^5.5.6",
"pg": "^8.11.3" "pg": "^8.11.3",
"web-push": "^3.6.7"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@@ -224,12 +224,14 @@ io.on('connection', async (socket) => {
message.reactions = []; message.reactions = [];
io.to(R('group', groupId)).emit('message:new', message); 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') { if (group.type === 'private') {
const members = await query(schema, const members = await query(schema,
'SELECT user_id FROM group_members WHERE group_id = $1', [groupId] '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) { for (const m of members) {
if (m.user_id === userId) continue; if (m.user_id === userId) continue;
const memberKey = `${schema}:${m.user_id}`; const memberKey = `${schema}:${m.user_id}`;
@@ -246,7 +248,21 @@ io.on('connection', async (socket) => {
// after the PWA was backgrounded (OS kills WebSocket before ping timeout). // after the PWA was backgrounded (OS kills WebSocket before ping timeout).
sendPushToUser(schema, m.user_id, { sendPushToUser(schema, m.user_id, {
title: senderName, title: senderName,
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100), 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, url: '/', groupId, badge: 1,
}).catch(() => {}); }).catch(() => {});
} }

View File

@@ -14,13 +14,13 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
// APP_TYPE validation — host mode requires HOST_DOMAIN and HOST_ADMIN_KEY. // APP_TYPE validation — host mode requires APP_DOMAIN, HOST_SLUG, and HOST_ADMIN_KEY.
// If either is missing, fall back to selfhost and warn rather than silently // If any are missing, fall back to selfhost and warn rather than silently
// exposing a broken or insecure host control plane. // exposing a broken or insecure host control plane.
let APP_TYPE = (process.env.APP_TYPE || 'selfhost').toLowerCase().trim(); let APP_TYPE = (process.env.APP_TYPE || 'selfhost').toLowerCase().trim();
if (APP_TYPE === 'host') { if (APP_TYPE === 'host') {
if (!process.env.HOST_DOMAIN || !process.env.HOST_ADMIN_KEY) { if (!process.env.APP_DOMAIN || !process.env.HOST_SLUG || !process.env.HOST_ADMIN_KEY) {
console.warn('[DB] WARNING: APP_TYPE=host requires HOST_DOMAIN and HOST_ADMIN_KEY to be set.'); 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.'); console.warn('[DB] WARNING: Falling back to APP_TYPE=selfhost for safety.');
APP_TYPE = 'selfhost'; APP_TYPE = 'selfhost';
} }
@@ -52,12 +52,17 @@ function resolveSchema(req) {
if (APP_TYPE === 'selfhost') return 'public'; if (APP_TYPE === 'selfhost') return 'public';
const host = (req.headers.host || '').toLowerCase().split(':')[0]; 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 // Internal requests (Docker health checks, localhost) → public schema
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return 'public'; 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}`)) { if (host.endsWith(`.${baseDomain}`)) {
const slug = host.slice(0, -(baseDomain.length + 1)); const slug = host.slice(0, -(baseDomain.length + 1));
if (!slug || slug === 'www') throw new Error(`Invalid tenant slug: ${slug}`); 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) // Custom domain lookup (populated from host admin DB)
if (tenantDomainCache.has(host)) return tenantDomainCache.get(host); 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}`); throw new Error(`Unknown tenant for host: ${host}`);
} }
@@ -249,7 +251,21 @@ async function seedUserGroups(schema) {
const existing = await queryOne(schema, const existing = await queryOne(schema,
'SELECT id FROM user_groups WHERE name = $1', [name] '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 // Create the managed DM chat group first
const gr = await queryResult(schema, const gr = await queryResult(schema,
@@ -259,17 +275,31 @@ async function seedUserGroups(schema) {
const dmGroupId = gr.rows[0].id; const dmGroupId = gr.rows[0].id;
// Create the user group linked to the DM group // Create the user group linked to the DM group
await exec(schema, const ugr = await queryResult(schema,
'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING', 'INSERT INTO user_groups (name, dm_group_id) VALUES ($1, $2) ON CONFLICT (name) DO NOTHING RETURNING id',
[name, dmGroupId] [name, dmGroupId]
); );
const ugId = ugr.rows[0]?.id;
console.log(`[DB:${schema}] Default user group created: ${name}`); 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) { async function seedAdmin(schema) {
const strip = s => (s || '').replace(/^['"]+|['"]+$/g, '').trim(); 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 adminName = strip(process.env.ADMIN_NAME) || 'Admin User';
const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234'; const adminPass = strip(process.env.ADMIN_PASS) || 'Admin@1234';
const pwReset = process.env.ADMPW_RESET === 'true'; const pwReset = process.env.ADMPW_RESET === 'true';

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) => { router.post('/login', async (req, res) => {
const { email, password, rememberMe } = req.body; const { email, password, rememberMe } = req.body;
try { 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) return res.status(401).json({ error: 'Invalid credentials' });
if (user.status === 'suspended') { if (user.status === 'suspended') {
@@ -62,6 +62,7 @@ module.exports = function(io) {
router.post('/logout', authMiddleware, async (req, res) => { router.post('/logout', authMiddleware, async (req, res) => {
try { try {
await clearActiveSession(req.schema, req.user.id, req.device); 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 }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } 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 { query, queryOne, queryResult, exec } = require('../models/db');
const { authMiddleware, adminMiddleware } = require('../middleware/auth'); 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) { function deleteImageFile(imageUrl) {
if (!imageUrl) return; if (!imageUrl) return;
try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); } try { const fp = '/app' + imageUrl; if (fs.existsSync(fp)) fs.unlinkSync(fp); }
@@ -13,6 +18,21 @@ function deleteImageFile(imageUrl) {
// Schema-aware room name helper // Schema-aware room name helper
const R = (schema, type, id) => `${schema}:${type}:${id}`; 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) => { module.exports = (io) => {
async function emitGroupNew(schema, io, groupId) { async function emitGroupNew(schema, io, groupId) {
@@ -52,13 +72,22 @@ router.get('/', authMiddleware, async (req, res) => {
`); `);
const privateGroupsRaw = await query(req.schema, ` 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 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.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.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 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 ORDER BY last_message_at DESC NULLS LAST
`, [userId]); `, [userId]);
@@ -160,8 +189,30 @@ router.post('/', authMiddleware, async (req, res) => {
const groupId = r.rows[0].id; 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, userId]);
await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [groupId, otherUserId]); 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); 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 // Check for duplicate private group
@@ -183,6 +234,7 @@ router.post('/', authMiddleware, async (req, res) => {
[name, type||'private', req.user.id, !!isReadonly] [name, type||'private', req.user.id, !!isReadonly]
); );
const groupId = r.rows[0].id; const groupId = r.rows[0].id;
const groupGuardianNames = [];
if (type === 'public') { if (type === 'public') {
const allUsers = await query(req.schema, "SELECT id FROM users WHERE status='active'"); 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]); 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]); 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); 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 }); } } 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' }); 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]); 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' }); 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]); 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 addedUser = await queryOne(req.schema, 'SELECT name,display_name FROM users WHERE id=$1', [userId]);
const addedName = addedUser?.display_name || addedUser?.name || 'Unknown'; const addedName = addedUser?.display_name || addedUser?.name || 'Unknown';
@@ -252,6 +332,18 @@ router.post('/:id/members', authMiddleware, async (req, res) => {
); );
sysMsg.reactions = []; sysMsg.reactions = [];
io.to(R(req.schema,'group',group.id)).emit('message:new', sysMsg); 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.in(R(req.schema,'user',userId)).socketsJoin(R(req.schema,'group',group.id));
io.to(R(req.schema,'user',userId)).emit('group:new', { group }); io.to(R(req.schema,'user',userId)).emit('group:new', { group });
res.json({ success: true }); res.json({ success: true });

View File

@@ -9,6 +9,7 @@
*/ */
const express = require('express'); const express = require('express');
const bcrypt = require('bcryptjs');
const router = express.Router(); const router = express.Router();
const { const {
query, queryOne, queryResult, exec, query, queryOne, queryResult, exec,
@@ -161,7 +162,7 @@ router.post('/tenants', async (req, res) => {
// 7. Reload domain cache // 7. Reload domain cache
await reloadTenantCache(); await reloadTenantCache();
const baseDomain = process.env.HOST_DOMAIN || 'rosterchirp.com'; const baseDomain = process.env.APP_DOMAIN || 'rosterchirp.com';
const tenant = tr.rows[0]; const tenant = tr.rows[0];
tenant.url = `https://${slug}.${baseDomain}`; tenant.url = `https://${slug}.${baseDomain}`;
@@ -186,7 +187,7 @@ router.post('/tenants', async (req, res) => {
// Supports updating: name, plan, customDomain, status // Supports updating: name, plan, customDomain, status
router.patch('/tenants/:slug', async (req, res) => { router.patch('/tenants/:slug', async (req, res) => {
const { name, plan, customDomain, status } = req.body; const { name, plan, customDomain, status, adminPassword } = req.body;
try { try {
const tenant = await queryOne('public', const tenant = await queryOne('public',
'SELECT * FROM tenants WHERE slug = $1', [req.params.slug] '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]); 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(); await reloadTenantCache();
const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]); const updated = await queryOne('public', 'SELECT * FROM tenants WHERE slug=$1', [req.params.slug]);
res.json({ tenant: updated }); res.json({ tenant: updated });
@@ -310,7 +320,7 @@ router.get('/status', async (req, res) => {
try { try {
const tenantCount = await queryOne('public', 'SELECT COUNT(*) AS count FROM tenants'); 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 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({ res.json({
ok: true, ok: true,
appType: process.env.APP_TYPE || 'selfhost', appType: process.env.APP_TYPE || 'selfhost',

View File

@@ -3,6 +3,7 @@ const multer = require('multer');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { query, queryOne, queryResult, exec } = require('../models/db'); const { query, queryOne, queryResult, exec } = require('../models/db');
const { sendPushToUser } = require('./push');
function deleteImageFile(imageUrl) { function deleteImageFile(imageUrl) {
if (!imageUrl) return; if (!imageUrl) return;
@@ -101,6 +102,32 @@ module.exports = function(io) {
`, [r.rows[0].id]); `, [r.rows[0].id]);
message.reactions = []; message.reactions = [];
io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message); 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 }); res.json({ message });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -124,6 +151,31 @@ module.exports = function(io) {
); );
message.reactions = []; message.reactions = [];
io.to(R(req.schema,'group',req.params.groupId)).emit('message:new', message); 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 }); res.json({ message });
} catch (e) { res.status(500).json({ error: e.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 { query, queryOne, exec } = require('../models/db');
const { authMiddleware } = require('../middleware/auth'); const { authMiddleware } = require('../middleware/auth');
// ── Firebase Admin ───────────────────────────────────────────────────────────── // ── Firebase Admin (FCM — Android/Chrome) ──────────────────────────────────────
let firebaseAdmin = null; let firebaseAdmin = null;
let firebaseApp = null; let firebaseApp = null;
@@ -25,60 +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 ──────────────────────────────────────────────────────────────────── // ── 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) { async function sendPushToUser(schema, userId, payload) {
const messaging = getMessaging();
if (!messaging) return;
try { try {
const subs = await query(schema, 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] [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) { for (const sub of subs) {
try { if (sub.fcm_token) {
await messaging.send({ // ── FCM path ──────────────────────────────────────────────────────────
token: sub.fcm_token, if (!messaging) continue;
// Top-level notification ensures FCM/Chrome can display even if the SW try {
// onBackgroundMessage handler has trouble — mirrors the working fcm-app pattern. 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: { notification: {
title: payload.title || 'New Message', title: payload.title || 'New Message',
body: payload.body || '', body: payload.body || '',
}, },
// Extra fields for SW click-routing (url, groupId)
data: { data: {
url: payload.url || '/', url: payload.url || '/',
groupId: payload.groupId ? String(payload.groupId) : '', groupId: payload.groupId ? String(payload.groupId) : '',
}, icon: '/icons/icon-192.png',
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 || '/' },
}, },
}); });
} catch (err) { try {
// Remove stale tokens await wp.sendNotification(subscription, body, { TTL: 86400, urgency: 'high' });
const stale = [ console.log(`[Push] WebPush sent to user ${userId} device=${sub.device} schema=${schema}`);
'messaging/registration-token-not-registered', } catch (err) {
'messaging/invalid-registration-token', // 404/410 = subscription expired or user unsubscribed — remove the stale row
'messaging/invalid-argument', if (err.statusCode === 404 || err.statusCode === 410) {
]; await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]);
if (stale.includes(err.code)) { console.log(`[Push] Removed stale WebPush sub for user ${userId} device=${sub.device}`);
await exec(schema, 'DELETE FROM push_subscriptions WHERE id = $1', [sub.id]); }
} }
} }
} }
@@ -97,13 +159,20 @@ router.get('/firebase-config', (req, res) => {
const appId = process.env.FIREBASE_APP_ID; const appId = process.env.FIREBASE_APP_ID;
const vapidKey = process.env.FIREBASE_VAPID_KEY; 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' }); return res.status(503).json({ error: 'FCM not configured' });
} }
res.json({ apiKey, projectId, messagingSenderId, appId, vapidKey }); 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) => { router.post('/subscribe', authMiddleware, async (req, res) => {
const { fcmToken } = req.body; const { fcmToken } = req.body;
if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' }); if (!fcmToken) return res.status(400).json({ error: 'fcmToken required' });
@@ -121,7 +190,29 @@ router.post('/subscribe', authMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } 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) => { router.post('/unsubscribe', authMiddleware, async (req, res) => {
try { try {
const device = req.device || 'desktop'; const device = req.device || 'desktop';
@@ -133,96 +224,108 @@ router.post('/unsubscribe', authMiddleware, async (req, res) => {
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Send a test push to the requesting user's own device — for diagnosing FCM setup. // Send a test push to the requesting user's own devices.
// mode=notification (default): notification+data message — same path as real messages. // Covers both FCM tokens and Web Push subscriptions in one call.
// mode=browser: webpush.notification only — Chrome shows it directly, SW not involved. // mode query param only applies to FCM test messages (notification vs browser).
// Use mode=browser to verify FCM delivery works independently of the service worker.
router.post('/test', authMiddleware, async (req, res) => { router.post('/test', authMiddleware, async (req, res) => {
try { try {
const subs = await query(req.schema, const subs = await query(req.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)`,
[req.user.id] [req.user.id]
); );
if (subs.length === 0) { if (subs.length === 0) {
return res.status(404).json({ return res.status(404).json({
error: 'No push subscription found for your account. Grant notification permission and reload the app first.', error: 'No push subscription found. Grant notification permission and reload the app first.',
}); });
} }
const messaging = getMessaging(); const messaging = getMessaging();
if (!messaging) { const wp = getWebPush();
return res.status(503).json({ error: 'Firebase Admin not initialised on server — check FIREBASE_SERVICE_ACCOUNT in .env' }); const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
} const results = [];
const mode = req.query.mode === 'browser' ? 'browser' : 'notification';
const results = [];
for (const sub of subs) { for (const sub of subs) {
try { if (sub.fcm_token) {
const message = { if (!messaging) {
token: sub.fcm_token, results.push({ device: sub.device, type: 'fcm', status: 'failed', error: 'Firebase Admin not initialised — check FIREBASE_SERVICE_ACCOUNT in .env' });
android: { continue;
priority: 'high', }
notification: { sound: 'default' }, try {
}, const message = {
apns: { token: sub.fcm_token,
headers: { 'apns-priority': '10' }, android: { priority: 'high', notification: { sound: 'default' } },
payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } }, apns: {
}, headers: { 'apns-priority': '10' },
webpush: { payload: { aps: { sound: 'default', badge: 1, contentAvailable: true } },
headers: { Urgency: 'high' }, },
notification: { webpush: {
icon: '/icons/icon-192.png', headers: { Urgency: 'high' },
badge: '/icons/icon-192-maskable.png', notification: { icon: '/icons/icon-192.png', badge: '/icons/icon-192-maskable.png', tag: 'rosterchirp-test' },
tag: 'rosterchirp-test', },
}, };
}, if (mode === 'browser') {
}; message.webpush.notification.title = 'RosterChirp Test (browser)';
message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.';
if (mode === 'browser') { message.webpush.fcm_options = { link: '/' };
// Chrome displays the notification directly — onBackgroundMessage does NOT fire. } else {
// Use this to verify FCM delivery works independently of the service worker. message.notification = { title: 'RosterChirp Test', body: 'Push notifications are working!' };
message.webpush.notification.title = 'RosterChirp Test (browser)'; message.data = { url: '/', groupId: '' };
message.webpush.notification.body = 'FCM delivery confirmed — Chrome handled this directly.'; message.webpush.fcm_options = { link: '/' };
message.webpush.fcm_options = { link: '/' }; }
} else { await messaging.send(message);
// notification+data — same structure as real messages. results.push({ device: sub.device, type: 'fcm', mode, status: 'sent' });
// SW onBackgroundMessage fires and shows the notification. } catch (err) {
message.notification = { results.push({ device: sub.device, type: 'fcm', mode, status: 'failed', error: err.message, code: err.code });
title: 'RosterChirp Test', }
body: 'Push notifications are working!', } else if (sub.webpush_endpoint) {
}; if (!wp) {
message.data = { url: '/', groupId: '' }; results.push({ device: sub.device, type: 'webpush', status: 'failed', error: 'VAPID keys not configured — check VAPID_PUBLIC/VAPID_PRIVATE in .env' });
message.webpush.fcm_options = { link: '/' }; 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 });
} }
await messaging.send(message);
results.push({ device: sub.device, mode, status: 'sent' });
console.log(`[Push] Test (${mode}) sent to user ${req.user.id} device=${sub.device}`);
} catch (err) {
results.push({ device: sub.device, mode, status: 'failed', error: err.message, code: err.code });
console.error(`[Push] Test (${mode}) failed for user ${req.user.id} device=${sub.device}:`, err.message);
} }
} }
res.json({ results }); res.json({ results });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Debug endpoint (admin-only) — lists all FCM subscriptions for this schema // Debug endpoint (admin-only) — lists all push subscriptions for this schema
router.get('/debug', authMiddleware, async (req, res) => { router.get('/debug', authMiddleware, async (req, res) => {
if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' }); if (req.user.role !== 'admin') return res.status(403).json({ error: 'Admin only' });
try { try {
const subs = await query(req.schema, ` const subs = await query(req.schema, `
SELECT ps.id, ps.user_id, ps.device, ps.fcm_token, SELECT ps.id, ps.user_id, ps.device,
ps.fcm_token,
ps.webpush_endpoint,
u.name, u.email u.name, u.email
FROM push_subscriptions ps FROM push_subscriptions ps
JOIN users u ON u.id = ps.user_id JOIN users u ON u.id = ps.user_id
WHERE ps.fcm_token IS NOT NULL WHERE ps.fcm_token IS NOT NULL OR ps.webpush_endpoint IS NOT NULL
ORDER BY u.name, ps.device ORDER BY u.name, ps.device
`); `);
const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT); const fcmConfigured = !!(process.env.FIREBASE_API_KEY && process.env.FIREBASE_SERVICE_ACCOUNT && process.env.FIREBASE_VAPID_KEY);
const firebaseAdminReady = !!getMessaging(); const firebaseAdminReady = !!getMessaging();
res.json({ subscriptions: subs, fcmConfigured, firebaseAdminReady }); 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 }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });

View File

@@ -1,5 +1,5 @@
const express = require('express'); 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 { authMiddleware, teamManagerMiddleware } = require('../middleware/auth');
const multer = require('multer'); const multer = require('multer');
const { parse: csvParse } = require('csv-parse/sync'); 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 // Posts a plain system message to each assigned user group's DM channel
// when an event is created or updated. // 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 { try {
const event = await queryOne(schema, 'SELECT * FROM events WHERE id=$1', [eventId]); const event = await queryOne(schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
if (!event) return; if (!event) return;
const dateStr = new Date(event.start_at).toLocaleDateString('en-US', { weekday:'short', month:'short', day:'numeric' });
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 groups = await query(schema, ` const groups = await query(schema, `
SELECT ug.dm_group_id SELECT ug.dm_group_id FROM event_user_groups eug
FROM event_user_groups eug
JOIN user_groups ug ON ug.id = eug.user_group_id JOIN user_groups ug ON ug.id = eug.user_group_id
WHERE eug.event_id = $1 AND ug.dm_group_id IS NOT NULL WHERE eug.event_id = $1 AND ug.dm_group_id IS NOT NULL
`, [eventId]); `, [eventId]);
for (const { dm_group_id } of groups)
for (const { dm_group_id } of groups) { await sendEventMessage(schema, dm_group_id, actorId, `📅 Event added: "${event.title}" on ${dateStr}`);
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); }
}
} catch (e) { } catch (e) {
console.error('[Schedule] postEventNotification error:', e.message); console.error('[Schedule] postEventNotification error:', e.message);
} }
@@ -53,6 +48,14 @@ async function postEventNotification(schema, eventId, actorId, isUpdate) {
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── 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) { async function isToolManagerFn(schema, user) {
if (user.role === 'admin' || user.role === 'manager') 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 tm = await queryOne(schema, "SELECT value FROM settings WHERE key='team_tool_managers'");
@@ -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 JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
WHERE eug.event_id=$1 AND ugm.user_id=$2 WHERE eug.event_id=$1 AND ugm.user_id=$2
`, [event.id, userId]); `, [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) { 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 }); } } 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 ──────────────────────────────────────────────────────────────────── // ── Events ────────────────────────────────────────────────────────────────────
router.get('/', authMiddleware, async (req, res) => { 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); 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' }); if (!(await canViewEvent(req.schema, event, req.user.id, itm))) return res.status(403).json({ error: 'Access denied' });
await enrichEvent(req.schema, event); await enrichEvent(req.schema, event);
if (event.track_availability && itm) { const partnerId = await getPartnerId(req.schema, req.user.id);
event.availability = await query(req.schema, ` const isMember = !itm && !!(
SELECT ea.response, ea.updated_at, u.id AS user_id, u.name, u.display_name, u.avatar (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 FROM event_availability ea JOIN users u ON u.id=ea.user_id WHERE ea.event_id=$1
`, [req.params.id]); `, [req.params.id]);
const assignedIds = (await query(req.schema, ` // Alias responses (Guardian Only mode)
SELECT DISTINCT ugm.user_id FROM event_user_groups eug const aliasAvail = await query(req.schema, `
JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id WHERE eug.event_id=$1 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
`, [req.params.id])).map(r => r.user_id); FROM event_alias_availability eaa JOIN guardian_aliases ga ON ga.id=eaa.alias_id WHERE eaa.event_id=$1
const respondedIds = new Set(event.availability.map(r => r.user_id)); `, [req.params.id]);
event.no_response_count = assignedIds.filter(id => !respondedIds.has(id)).length; 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_response = mine?.response || null;
event.my_note = mine?.note || null;
res.json({ event }); res.json({ event });
} catch (e) { res.status(500).json({ error: e.message }); } } 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; 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 (!title?.trim()) return res.status(400).json({ error: 'Title required' });
if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' }); if (!startAt || !endAt) return res.status(400).json({ error: 'Start and end time required' });
try { 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, ` 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) 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 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, `, [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; 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]); 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) if (groupIds.length > 0)
await postEventNotification(req.schema, eventId, req.user.id, false); await postEventNotification(req.schema, eventId, req.user.id);
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]); const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
res.json({ event: await enrichEvent(req.schema, event) }); res.json({ event: await enrichEvent(req.schema, event) });
} catch (e) { res.status(500).json({ error: e.message }); } } 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 { try {
const event = await queryOne(req.schema, 'SELECT * 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' }); 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 itm = await isToolManagerFn(req.schema, req.user);
const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event }; 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;
await applyEventUpdate(req.schema, req.params.id, fields, userGroupIds); if (!itm) {
// Regular users editing their own event: force private, validate group membership
// Recurring future scope — update all future occurrences isPublic = false;
if (recurringScope === 'future' && event.recurrence_rule) { if (Array.isArray(userGroupIds)) {
const futureEvents = await query(req.schema, ` if (!userGroupIds.length) return res.status(400).json({ error: 'Select at least one group' });
SELECT id FROM events WHERE id!=$1 AND created_by=$2 AND recurrence_rule IS NOT NULL for (const ugId of userGroupIds) {
AND start_at >= $3 AND title=$4 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]);
`, [req.params.id, event.created_by, event.start_at, event.title]); if (!member) return res.status(403).json({ error: 'You can only assign groups you belong to' });
for (const fe of futureEvents) }
await applyEventUpdate(req.schema, fe.id, fields, userGroupIds); // 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);
// Clean up availability for users removed from groups const submittedSet = new Set(userGroupIds.map(Number));
if (Array.isArray(userGroupIds)) { for (const gid of existingGroupIds) {
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); if (submittedSet.has(gid)) continue;
const newGroupSet = new Set(userGroupIds.map(Number)); 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]);
const removedGroupIds = prevGroupIds.filter(id => !newGroupSet.has(id)); if (!member) userGroupIds.push(gid);
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 updated = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]); const pad = n => String(n).padStart(2, '0');
const finalGroups = await query(req.schema, 'SELECT user_group_id FROM event_user_groups WHERE event_id=$1', [req.params.id]); const fields = { title, eventTypeId, startAt, endAt, allDay, location, description, isPublic, trackAvailability, recurrenceRule, origEvent: event };
if (finalGroups.length > 0)
await postEventNotification(req.schema, req.params.id, req.user.id, true); // 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) }); res.json({ event: await enrichEvent(req.schema, updated) });
} catch (e) { res.status(500).json({ error: e.message }); } } 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 { try {
if (!(await queryOne(req.schema, 'SELECT id FROM events WHERE id=$1', [req.params.id]))) const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [req.params.id]);
return res.status(404).json({ error: 'Not found' }); if (!event) return res.status(404).json({ error: 'Not found' });
await exec(req.schema, 'DELETE FROM events WHERE id=$1', [req.params.id]); 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 }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } 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]); 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) return res.status(404).json({ error: 'Not found' });
if (!event.track_availability) return res.status(400).json({ error: 'Availability tracking not enabled' }); 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' }); if (!['going','maybe','not_going'].includes(response)) return res.status(400).json({ error: 'Invalid response' });
const itm = await isToolManagerFn(req.schema, req.user); const trimmedNote = note ? String(note).trim().slice(0, 20) : null;
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 if (forPartnerId) {
WHERE eug.event_id=$1 AND ugm.user_id=$2 // Respond on behalf of partner — verify partnership and partner's group membership
`, [event.id, req.user.id]); const isPartner = await queryOne(req.schema,
if (!inGroup && !itm) return res.status(403).json({ error: 'You are not assigned to this event' }); '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)',
await exec(req.schema, ` [req.user.id, forPartnerId]);
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW()) if (!isPartner) return res.status(403).json({ error: 'Not your partner' });
ON CONFLICT (event_id,user_id) DO UPDATE SET response=$3, updated_at=NOW() const partnerInGroup = await queryOne(req.schema, `
`, [event.id, req.user.id, response]); SELECT 1 FROM event_user_groups eug JOIN user_group_members ugm ON ugm.user_group_id=eug.user_group_id
res.json({ success: true, response }); 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 }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
router.delete('/:id/availability', authMiddleware, async (req, res) => { router.delete('/:id/availability', authMiddleware, async (req, res) => {
try { 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 }); res.json({ success: true });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -354,14 +807,15 @@ router.post('/me/bulk-availability', authMiddleware, async (req, res) => {
try { try {
let saved = 0; let saved = 0;
const itm = await isToolManagerFn(req.schema, req.user); const itm = await isToolManagerFn(req.schema, req.user);
const bulkPartnerId = await getPartnerId(req.schema, req.user.id);
for (const { eventId, response } of responses) { for (const { eventId, response } of responses) {
if (!['going','maybe','not_going'].includes(response)) continue; if (!['going','maybe','not_going'].includes(response)) continue;
const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]); const event = await queryOne(req.schema, 'SELECT * FROM events WHERE id=$1', [eventId]);
if (!event || !event.track_availability) continue; if (!event || !event.track_availability) continue;
const inGroup = await queryOne(req.schema, ` 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 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 WHERE eug.event_id=$1 AND (ugm.user_id=$2 OR ugm.user_id=$3)
`, [eventId, req.user.id]); `, [eventId, req.user.id, bulkPartnerId || -1]);
if (!inGroup && !itm) continue; if (!inGroup && !itm) continue;
await exec(req.schema, ` await exec(req.schema, `
INSERT INTO event_availability (event_id,user_id,response,updated_at) VALUES ($1,$2,$3,NOW()) 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; if (admin) obj.admin_email = admin.email;
obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev'; obj.app_version = process.env.ROSTERCHIRP_VERSION || 'dev';
obj.user_pass = process.env.USER_PASS || 'user@1234'; obj.user_pass = process.env.USER_PASS || 'user@1234';
// Tell the frontend whether this request came from the HOST_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 domain. // 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 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 = ( obj.is_host_domain = (
process.env.APP_TYPE === 'host' && process.env.APP_TYPE === 'host' &&
!!hostDomain && !!hostControlDomain &&
(reqHost === hostDomain || reqHost === `www.${hostDomain}` || reqHost === 'localhost') (reqHost === hostControlDomain || reqHost === 'localhost')
) ? 'true' : 'false'; ) ? 'true' : 'false';
res.json({ settings: obj }); res.json({ settings: obj });
} catch (e) { res.status(500).json({ error: e.message }); } } 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 }); } } 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) => { router.patch('/team', authMiddleware, adminMiddleware, async (req, res) => {
const { toolManagers } = req.body; const { toolManagers } = req.body;
try { try {

View File

@@ -211,7 +211,16 @@ router.get('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
FROM user_group_members ugm JOIN users u ON u.id=ugm.user_id 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 WHERE ugm.user_group_id=$1 ORDER BY u.name ASC
`, [req.params.id]); `, [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 }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
@@ -250,7 +259,7 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
// PATCH /:id // PATCH /:id
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { name, memberIds, createDm = false } = req.body; const { name, memberIds, createDm = false, aliasMemberIds } = req.body;
try { try {
let 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 (!ug) return res.status(404).json({ error: 'Not found' });
@@ -301,6 +310,7 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
io.in(R(req.schema,'user',uid)).socketsLeave(R(req.schema,'group',ug.dm_group_id)); 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('group:deleted', { groupId: ug.dm_group_id });
} }
io.to(R(req.schema,'user',uid)).emit('schedule:refresh');
removedUids.push(uid); removedUids.push(uid);
} }
} }
@@ -355,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]); const updated = await queryOne(req.schema, 'SELECT * FROM user_groups WHERE id=$1', [req.params.id]);
res.json({ group: updated }); res.json({ group: updated });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
@@ -418,6 +446,8 @@ router.delete('/:id/members/:userId', authMiddleware, teamManagerMiddleware, asy
await exec(req.schema, 'DELETE FROM user_group_members WHERE user_group_id=$1 AND user_id=$2', [ug.id, 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) { 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]); 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.in(R(req.schema,'user',userId)).socketsLeave(R(req.schema,'group',ug.dm_group_id));

View File

@@ -16,6 +16,17 @@ const uploadAvatar = multer({
fileFilter: (req, file, cb) => file.mimetype.startsWith('image/') ? cb(null, true) : cb(new Error('Images only')), 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) { async function resolveUniqueName(schema, baseName, excludeId = null) {
const existing = await query(schema, const existing = await query(schema,
"SELECT name FROM users WHERE status != 'deleted' AND id != $1 AND (name = $2 OR name LIKE $3)", "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); } 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 // List users
router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => { router.get('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
try { try {
const users = await query(req.schema, const users = await query(req.schema,
"SELECT id,name,first_name,last_name,phone,is_minor,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" "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 }); res.json({ users });
} catch (e) { res.status(500).json({ error: e.message }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Search users // 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) => { router.get('/search', authMiddleware, async (req, res) => {
const { q, groupId } = req.query; const { q, groupId } = req.query;
const isTyped = q && q.length > 0;
try { try {
let users; let users;
if (groupId) { if (groupId) {
const group = await queryOne(req.schema, 'SELECT type, is_direct FROM groups WHERE id = $1', [parseInt(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)) { if (group && (group.type === 'private' || group.is_direct)) {
users = await query(req.schema, 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}%`] [parseInt(groupId), req.user.id, `%${q}%`]
); );
} else { } else {
users = await query(req.schema, 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}%`] [req.user.id, `%${q}%`]
); );
} }
} else { } else {
users = await query(req.schema, 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}%`] [`%${q}%`]
); );
} }
@@ -82,7 +114,7 @@ router.get('/check-display-name', authMiddleware, async (req, res) => {
// Create user // Create user
router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => { router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const { firstName, lastName, email, password, role, phone, isMinor } = req.body; const { firstName, lastName, email, password, role, phone, dateOfBirth } = req.body;
if (!firstName?.trim() || !lastName?.trim() || !email) if (!firstName?.trim() || !lastName?.trim() || !email)
return res.status(400).json({ error: 'First name, last name and email required' }); 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' }); if (!isValidEmail(email.trim())) return res.status(400).json({ error: 'Invalid email address' });
@@ -90,31 +122,40 @@ router.post('/', authMiddleware, teamManagerMiddleware, async (req, res) => {
const assignedRole = validRoles.includes(role) ? role : 'member'; const assignedRole = validRoles.includes(role) ? role : 'member';
const name = `${firstName.trim()} ${lastName.trim()}`; const name = `${firstName.trim()} ${lastName.trim()}`;
try { try {
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()]); 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' }); if (exists) return res.status(400).json({ error: 'Email already in use' });
const resolvedName = await resolveUniqueName(req.schema, name); const resolvedName = await resolveUniqueName(req.schema, name);
const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234'; const pw = (password || '').trim() || process.env.USER_PASS || 'user@1234';
const hash = bcrypt.hashSync(pw, 10); const hash = bcrypt.hashSync(pw, 10);
const r = await queryResult(req.schema, const r = await queryResult(req.schema,
"INSERT INTO users (name,first_name,last_name,email,password,role,phone,is_minor,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'active',TRUE) RETURNING id", "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] [resolvedName, firstName.trim(), lastName.trim(), email.trim().toLowerCase(), hash, assignedRole, phone?.trim() || null, isMinor, dob, initStatus]
); );
const userId = r.rows[0].id; const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId); if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
if (assignedRole === 'admin') { if (assignedRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema); 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]); 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,first_name,last_name,phone,is_minor,email,role,status,must_change_password,created_at FROM users WHERE id=$1', [userId]); const user = await queryOne(req.schema,
res.json({ user }); '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 }); } } catch (e) { res.status(500).json({ error: e.message }); }
}); });
// Update user (general — name components, phone, is_minor, role, optional password reset) // Update user (general — name components, phone, DOB, is_minor, role, optional password reset)
router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => { router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) => {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' }); if (isNaN(id)) return res.status(400).json({ error: 'Invalid user ID' });
const { firstName, lastName, phone, isMinor, role, password } = req.body; const { firstName, lastName, phone, role, password, dateOfBirth, guardianUserId } = req.body;
if (!firstName?.trim() || !lastName?.trim()) if (!firstName?.trim() || !lastName?.trim())
return res.status(400).json({ error: 'First and last name required' }); return res.status(400).json({ error: 'First and last name required' });
const validRoles = ['member', 'admin', 'manager']; const validRoles = ['member', 'admin', 'manager'];
@@ -124,11 +165,24 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
if (!target) return res.status(404).json({ error: 'User not found' }); if (!target) return res.status(404).json({ error: 'User not found' });
if (target.is_default_admin && role !== 'admin') if (target.is_default_admin && role !== 'admin')
return res.status(403).json({ error: 'Cannot change default admin role' }); return res.status(403).json({ error: 'Cannot change default admin role' });
const name = `${firstName.trim()} ${lastName.trim()}`;
const dob = dateOfBirth || null;
const isMinor = isMinorFromDOB(dob);
const name = `${firstName.trim()} ${lastName.trim()}`;
const resolvedName = await resolveUniqueName(req.schema, name, id); 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, await exec(req.schema,
'UPDATE users SET name=$1,first_name=$2,last_name=$3,phone=$4,is_minor=$5,role=$6,updated_at=NOW() WHERE id=$7', '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, role, id] [resolvedName, firstName.trim(), lastName.trim(), phone?.trim() || null, isMinor, dob, guardianId, role, id]
); );
if (password && password.length >= 6) { if (password && password.length >= 6) {
const hash = bcrypt.hashSync(password, 10); const hash = bcrypt.hashSync(password, 10);
@@ -138,8 +192,20 @@ router.patch('/:id', authMiddleware, teamManagerMiddleware, async (req, res) =>
const sgId = await getOrCreateSupportGroup(req.schema); 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]); 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, const user = await queryOne(req.schema,
'SELECT id,name,first_name,last_name,phone,is_minor,email,role,status,must_change_password,last_online,created_at FROM users WHERE id=$1', '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] [id]
); );
res.json({ user }); res.json({ user });
@@ -174,12 +240,16 @@ router.post('/bulk', authMiddleware, teamManagerMiddleware, async (req, res) =>
const newRole = validRoles.includes(u.role) ? u.role : 'member'; const newRole = validRoles.includes(u.role) ? u.role : 'member';
const fn = firstName || name.split(' ')[0] || ''; const fn = firstName || name.split(' ')[0] || '';
const ln = lastName || name.split(' ').slice(1).join(' ') || ''; 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, const r = await queryResult(req.schema,
"INSERT INTO users (name,first_name,last_name,email,password,role,status,must_change_password) VALUES ($1,$2,$3,$4,$5,$6,'active',TRUE) RETURNING id", "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] [resolvedName, fn, ln, email, hash, newRole, dob, isMinor, initStatus]
); );
const userId = r.rows[0].id; const userId = r.rows[0].id;
await addUserToPublicGroups(req.schema, userId); if (initStatus === 'active') await addUserToPublicGroups(req.schema, userId);
if (newRole === 'admin') { if (newRole === 'admin') {
const sgId = await getOrCreateSupportGroup(req.schema); 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]); if (sgId) await exec(req.schema, 'INSERT INTO group_members (group_id,user_id) VALUES ($1,$2) ON CONFLICT DO NOTHING', [sgId, userId]);
@@ -320,7 +390,7 @@ router.delete('/:id', authMiddleware, teamManagerMiddleware, async (req,
// Update own profile // Update own profile
router.patch('/me/profile', authMiddleware, async (req, res) => { 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 { try {
if (displayName) { if (displayName) {
const conflict = await queryOne(req.schema, const conflict = await queryOne(req.schema,
@@ -329,12 +399,14 @@ router.patch('/me/profile', authMiddleware, async (req, res) => {
); );
if (conflict) return res.status(400).json({ error: 'Display name already in use' }); 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, 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', '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, req.user.id] [displayName || null, aboutMe || null, !!hideAdminTag, allowDm !== false, dob, isMinor, phone?.trim() || null, req.user.id]
); );
const user = await queryOne(req.schema, 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] [req.user.id]
); );
res.json({ user }); res.json({ user });
@@ -372,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; module.exports = router;

View File

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

View File

@@ -8,7 +8,8 @@
# #
# Required .env additions for host mode: # Required .env additions for host mode:
# APP_TYPE=host # APP_TYPE=host
# HOST_DOMAIN=rosterchirp.com # APP_DOMAIN=example.com
# HOST_SLUG=chathost
# HOST_ADMIN_KEY=your_secret_host_admin_key # HOST_ADMIN_KEY=your_secret_host_admin_key
# CF_API_TOKEN=your_cloudflare_dns_api_token (or equivalent for your DNS provider) # 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_NAME=${DB_NAME:-rosterchirp}
- DB_USER=${DB_USER:-rosterchirp} - DB_USER=${DB_USER:-rosterchirp}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - 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} - HOST_ADMIN_KEY=${HOST_ADMIN_KEY:?HOST_ADMIN_KEY is required in host mode}
volumes: volumes:
- rosterchirp_uploads:/app/uploads - rosterchirp_uploads:/app/uploads

View File

@@ -21,7 +21,8 @@ services:
- DB_NAME=${DB_NAME:-rosterchirp} - DB_NAME=${DB_NAME:-rosterchirp}
- DB_USER=${DB_USER:-rosterchirp} - DB_USER=${DB_USER:-rosterchirp}
- DB_PASSWORD=${DB_PASSWORD:?DB_PASSWORD is required} - 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:-} - HOST_ADMIN_KEY=${HOST_ADMIN_KEY:-}
- FIREBASE_API_KEY=${FIREBASE_API_KEY:-} - FIREBASE_API_KEY=${FIREBASE_API_KEY:-}
- FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID:-} - FIREBASE_PROJECT_ID=${FIREBASE_PROJECT_ID:-}

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="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="theme-color" content="#1a73e8" />
<meta name="description" content="RosterChirp - team messaging" /> <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="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" /> <link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>RosterChirp</title> <title>RosterChirp</title>

View File

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

View File

@@ -1,25 +1,9 @@
// ── Firebase Messaging (background push for Android PWA) ────────────────────── // ── Service Worker — RosterChirp ───────────────────────────────────────────────
// Config must be hardcoded here — the SW is woken by push events before any // Push notifications are handled via the standard W3C Push API (`push` event).
// async fetch can resolve, so Firebase must be initialised synchronously. // The Firebase SDK is not initialised here — FCM delivers the payload via the
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-app-compat.js'); // standard push event and event.data.json() is sufficient to read it.
importScripts('https://www.gstatic.com/firebasejs/10.14.1/firebase-messaging-compat.js'); // Firebase SDK initialisation (for getToken) happens in the main thread (Chat.jsx),
// where the config is fetched at runtime from /api/push/firebase-config.
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"
};
// Initialise Firebase synchronously so the push listener is ready immediately
let messaging = null;
if (FIREBASE_CONFIG.apiKey !== '__FIREBASE_API_KEY__') {
firebase.initializeApp(FIREBASE_CONFIG);
messaging = firebase.messaging();
console.log('[SW] Firebase initialised');
}
// ── Cache ───────────────────────────────────────────────────────────────────── // ── Cache ─────────────────────────────────────────────────────────────────────
const CACHE_NAME = 'rosterchirp-v1'; const CACHE_NAME = 'rosterchirp-v1';
@@ -81,7 +65,7 @@ function showRosterChirpNotification(data) {
// directly (fast, reliable) rather than delegating to the Firebase SDK's // directly (fast, reliable) rather than delegating to the Firebase SDK's
// internal push listener, which can be killed before it finishes on Android. // internal push listener, which can be killed before it finishes on Android.
self.addEventListener('push', (event) => { self.addEventListener('push', (event) => {
console.log('[SW] Push received, hasData:', !!event.data, 'messaging:', !!messaging); console.log('[SW] Push received, hasData:', !!event.data);
event.waitUntil((async () => { event.waitUntil((async () => {
try { try {

View File

@@ -1,3 +1,4 @@
import { useState } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './contexts/AuthContext.jsx'; import { AuthProvider, useAuth } from './contexts/AuthContext.jsx';
import { SocketProvider } from './contexts/SocketContext.jsx'; import { SocketProvider } from './contexts/SocketContext.jsx';
@@ -6,6 +7,50 @@ import Login from './pages/Login.jsx';
import Chat from './pages/Chat.jsx'; import Chat from './pages/Chat.jsx';
import ChangePassword from './pages/ChangePassword.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 }) { function ProtectedRoute({ children }) {
const { user, loading, mustChangePassword } = useAuth(); const { user, loading, mustChangePassword } = useAuth();
if (loading) return ( if (loading) return (
@@ -36,6 +81,7 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<ToastProvider> <ToastProvider>
<IOSInstallBanner />
<Routes> <Routes>
{/* All routes go through jama auth */} {/* All routes go through jama auth */}
<Route path="/*" element={ <Route path="/*" element={

View File

@@ -3,12 +3,15 @@ import { api } from '../utils/api.js';
const CLAUDE_URL = 'https://claude.ai'; 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 }) { function BuiltWithValue({ value }) {
if (!value) return null; if (!value) return null;
const parts = value.split('·').map(s => s.trim()); const parts = value.split('·').map(s => s.trim());
return ( return (
<span style={{ display: 'inline' }}> <span style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'baseline', width: '100%' }}>
{parts.map((part, i) => ( {parts.map((part, i) => (
<span key={part} style={{ whiteSpace: 'nowrap' }}> <span key={part} style={{ whiteSpace: 'nowrap' }}>
{part === 'Claude.ai' {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)', width: 110, background: 'var(--surface)',
color: 'var(--text-primary)', color: 'var(--text-primary)',
}} }}
placeholder="#000000" autoComplete="new-password" /> placeholder="#000000" autoComplete="off" />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span> <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Chosen colour</span>
</div> </div>
@@ -455,7 +455,7 @@ export default function BrandingModal({ onClose }) {
className="input flex-1" className="input flex-1"
value={appName} value={appName}
maxLength={16} 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> <button className="btn btn-primary btn-sm" onClick={handleSaveName} disabled={loading}>{loading ? '...' : 'Save'}</button>
</div> </div>
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 6 }}> <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]; 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() }) { export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMessage, onMessageDeleted, onHasTextChange, onlineUserIds = new Set() }) {
const { user: currentUser } = useAuth(); const { user: currentUser } = useAuth();
const { socket } = useSocket(); const { socket } = useSocket();
@@ -39,6 +90,22 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
return () => window.removeEventListener('resize', onResize); 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(() => { useEffect(() => {
api.getSettings().then(({ settings }) => { api.getSettings().then(({ settings }) => {
setIconGroupInfo(settings.icon_groupinfo || ''); 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(() => { useEffect(() => {
if (!group) { setMessages([]); return; } if (!group) { setMessages([]); return; }
setMessages([]); 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 }}> <div className="group-icon-sm" style={{ background: avatarColors.dm, borderRadius: 8, flexShrink: 0, fontSize: 11, fontWeight: 700 }}>
{group.is_multi_group ? 'MG' : 'UG'} {group.is_multi_group ? 'MG' : 'UG'}
</div> </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 }}> <div className="group-icon-sm" style={{ background: group.type === 'public' ? avatarColors.public : avatarColors.dm, flexShrink: 0 }}>
{group.type === 'public' ? '#' : group.name[0]?.toUpperCase()} {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
@@ -339,7 +404,7 @@ export default function ChatWindow({ group, onBack, onGroupUpdated, onDirectMess
This channel is read-only This channel is read-only
</div> </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> </div>
{showInfo && ( {showInfo && (

View File

@@ -134,7 +134,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
{editing ? ( {editing ? (
<div className="flex gap-2"> <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-primary btn-sm" onClick={handleRename}>Save</button>
<button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button> <button className="btn btn-secondary btn-sm" onClick={() => setEditing(false)}>Cancel</button>
</div> </div>
@@ -165,7 +165,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
<input <input
className="input flex-1" className="input flex-1"
value={customName} 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()} /> onKeyDown={e => e.key === 'Enter' && handleCustomName()} />
{customName.trim() !== savedCustomName ? ( {customName.trim() !== savedCustomName ? (
<button className="btn btn-primary btn-sm" onClick={handleCustomName} disabled={savingCustom}> <button className="btn btn-primary btn-sm" onClick={handleCustomName} disabled={savingCustom}>
@@ -219,7 +219,7 @@ export default function GroupInfoModal({ group, onClose, onUpdated, onBack }) {
</div> </div>
{canManage && ( {canManage && (
<div style={{ marginTop: 12 }}> <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 && ( {addResults.length > 0 && addSearch && (
<div style={{ border: '1px solid var(--border)', borderRadius: 'var(--radius)', marginTop: 4, maxHeight: 150, overflowY: 'auto', background: 'var(--surface)' }}> <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 => ( {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 }) { function EditModal({ api, tenant, onClose, onDone }) {
const [form, setForm] = useState({ name: tenant.name, plan: tenant.plan, customDomain: tenant.custom_domain || '' }); 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 [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const set = k => v => setForm(f => ({ ...f, [k]: v })); const set = k => v => setForm(f => ({ ...f, [k]: v }));
const handle = async () => { const handle = async () => {
if (adminPassword && adminPassword.length < 6)
return setError('Admin password must be at least 6 characters');
setSaving(true); setError(''); setSaving(true); setError('');
try { try {
const { tenant: updated } = await api.updateTenant(tenant.slug, { const { tenant: updated } = await api.updateTenant(tenant.slug, {
name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null, name: form.name || undefined, plan: form.plan, customDomain: form.customDomain || null,
...(adminPassword ? { adminPassword } : {}),
}); });
onDone(updated); onDone(updated);
} catch (e) { setError(e.message); } } catch (e) { setError(e.message); }
finally { setSaving(false); } finally { setSaving(false); }
}; };
const adminEmail = tenant.admin_email || '(uses system default from .env)';
return ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal"> <div className="modal">
@@ -191,6 +198,41 @@ function EditModal({ api, tenant, onClose, onDone }) {
<Field label="Display Name" value={form.name} onChange={set('name')} /> <Field label="Display Name" value={form.name} onChange={set('name')} />
<FieldSelect label="Plan" value={form.plan} onChange={set('plan')} options={PLANS} /> <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" /> <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 }}> <div style={{ display:'flex', justifyContent:'flex-end', gap:8 }}>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button> <button className="btn btn-secondary" onClick={onClose}>Cancel</button>
<button className="btn btn-primary" onClick={handle} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</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 }) { export default function ImageLightbox({ src, onClose }) {
const overlayRef = useRef(null); const overlayRef = useRef(null);
// Close on Escape // Close on Escape; enable native pinch-zoom on the image while open
useEffect(() => { useEffect(() => {
const handler = (e) => { if (e.key === 'Escape') onClose(); }; const handler = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handler); window.addEventListener('keydown', handler);
// Prevent body scroll while open
document.body.style.overflow = 'hidden'; 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 () => { return () => {
window.removeEventListener('keydown', handler); window.removeEventListener('keydown', handler);
document.body.style.overflow = ''; document.body.style.overflow = '';
delete document.documentElement.dataset.lightboxOpen;
if (viewport) viewport.content = originalContent;
}; };
}, [onClose]); }, [onClose]);

View File

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

View File

@@ -151,7 +151,7 @@
padding: 10px 14px; padding: 10px 14px;
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 20px; border-radius: 20px;
font-size: calc(0.875rem * var(--font-scale)); font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
font-family: var(--font); font-family: var(--font);
color: var(--text-primary); 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()); 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 [text, setText] = useState('');
const [imageFile, setImageFile] = useState(null); const [imageFile, setImageFile] = useState(null);
const [imagePreview, setImagePreview] = useState(null); const [imagePreview, setImagePreview] = useState(null);
@@ -380,11 +380,16 @@ export default function MessageInput({ group, replyTo, onCancelReply, onSend, on
<textarea <textarea
ref={inputRef} ref={inputRef}
className="msg-input" className="msg-input"
placeholder={`Message ${group?.name || ''}...`} placeholder="Text message"
value={text} value={text}
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={onInputFocus}
rows={1} rows={1}
autoComplete="off"
autoCorrect="off"
autoCapitalize="sentences"
spellCheck="true"
style={{ resize: 'none' }} /> style={{ resize: 'none' }} />
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import ColourPickerSheet from './ColourPickerSheet.jsx'; import ColourPickerSheet from './ColourPickerSheet.jsx';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
@@ -67,15 +68,27 @@ function fmt12(val) {
return `${h}:${String(mm).padStart(2,'0')} ${ampm}`; 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 }) { function TimeInputMobile({ value, onChange }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [inputVal, setInputVal] = useState(fmt12(value)); const [inputVal, setInputVal] = useState(fmt12(value));
const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0 });
const wrapRef = useRef(null); const wrapRef = useRef(null);
const listRef = useRef(null); const listRef = useRef(null);
useEffect(() => { setInputVal(fmt12(value)); }, [value]); 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(() => { useEffect(() => {
if (!open || !listRef.current) return; if (!open || !listRef.current) return;
const idx = TIME_SLOTS.findIndex(s => s.value === value); const idx = TIME_SLOTS.findIndex(s => s.value === value);
@@ -99,22 +112,29 @@ function TimeInputMobile({ value, onChange }) {
return ( return (
<div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}> <div ref={wrapRef} style={{ position: 'relative', display: 'inline-block' }}>
<input <input
type="text"
value={inputVal} value={inputVal}
onChange={e => setInputVal(e.target.value)} onChange={e => setInputVal(e.target.value)}
onFocus={() => setOpen(true)} onFocus={() => setOpen(true)}
onBlur={e => setTimeout(() => commit(e.target.value), 150)} 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); } }} 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 }} style={{ fontSize: 15, color: 'var(--primary)', fontWeight: 600, background: 'transparent', border: 'none', outline: 'none', cursor: 'text', width: 90 }}
/> />
{open && ( {open && (
<div <div
ref={listRef} ref={listRef}
style={{ style={{
position: 'fixed', zIndex: 400, position: 'fixed',
zIndex: 9999,
top: dropdownPos.top,
left: dropdownPos.left,
background: 'var(--surface)', border: '1px solid var(--border)', background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.2)', borderRadius: 8, boxShadow: '0 4px 20px rgba(0,0,0,0.2)',
width: 130, maxHeight: 5 * 40, overflowY: 'auto', width: 130, maxHeight: 5 * 40, overflowY: 'auto',
pointerEvents: 'auto',
}} }}
> >
{TIME_SLOTS.map(s => ( {TIME_SLOTS.map(s => (
@@ -254,7 +274,7 @@ function RecurrenceSheet({ value, onChange, onClose }) {
<div style={{ marginBottom:16 }}> <div style={{ marginBottom:16 }}>
<div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats every</div> <div style={{ fontSize:12,color:'var(--text-tertiary)',marginBottom:8 }}>Repeats every</div>
<div style={{ display:'flex',gap:10 }}> <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 }}> <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>)} {['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(customRule.interval||1)>1?'s':''}</option>)}
</select> </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)' }}/>} {(customRule.ends||'never')===val&&<div style={{ width:10,height:10,borderRadius:'50%',background:'var(--primary)' }}/>}
</div> </div>
<span style={{ flex:1,fontSize:15 }}>{lbl}</span> <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==='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)} style={{ width:64,textAlign:'center' }}/><span style={{ fontSize:13,color:'var(--text-tertiary)' }}>occurrences</span></>} {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>
))} ))}
</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 ──────────────────────────────────────────────────── // ── 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(); const toast = useToast();
// Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones) // Use local date for default, not UTC slice (avoids off-by-one for UTC- timezones)
const defDate = selectedDate || new Date(); const defDate = selectedDate || new Date();
@@ -347,12 +392,13 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
const mountedRef = useRef(false); const mountedRef = useRef(false);
const [allDay, setAllDay] = useState(!!event?.all_day); const [allDay, setAllDay] = useState(!!event?.all_day);
const [track, setTrack] = useState(!!event?.track_availability); 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 [groups, setGroups] = useState(new Set((event?.user_groups||[]).map(g=>g.id)));
const [location, setLocation] = useState(event?.location||''); const [location, setLocation] = useState(event?.location||'');
const [description, setDescription] = useState(event?.description||''); const [description, setDescription] = useState(event?.description||'');
const [recRule, setRecRule] = useState(event?.recurrence_rule||null); const [recRule, setRecRule] = useState(event?.recurrence_rule||null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showScopeModal, setShowScopeModal] = useState(false);
// Overlay state // Overlay state
const [showStartDate, setShowStartDate] = useState(false); const [showStartDate, setShowStartDate] = useState(false);
@@ -410,25 +456,31 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
setEt(toTimeIn(endIso)); setEt(toTimeIn(endIso));
}, [sd, st, typeId]); }, [sd, st, typeId]);
const handle = async () => { const handle = () => {
if(!title.trim()) return toast('Title required','error'); 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 startMs = new Date(buildISO(sd, allDay?'00:00':st)).getTime();
const endMs = new Date(buildISO(ed, allDay?'23:59':et)).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(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'); 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 && 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 && 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); setSaving(true);
try { 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 }; 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 scope = 'this'; let r;
if(event && event.recurrence_rule?.freq) { if (event) {
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only'); const updateBody = { ...body, recurringScope: scope };
scope = choice ? 'future' : 'this'; 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); onSave(r.event);
} catch(e) { toast(e.message,'error'); } } catch(e) { toast(e.message,'error'); }
finally { setSaving(false); } 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> <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>
<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 */} {/* Title */}
<div style={{ padding:'16px 20px',borderBottom:'1px solid var(--border)' }}> <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> </div>
{/* Event Type */} {/* Event Type */}
@@ -485,8 +539,8 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
</div> </div>
{/* End date/time */} {/* End date/time */}
<div onClick={()=>setShowEndDate(true)} style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',cursor:'pointer',borderBottom:'1px solid var(--border)' }}> <div style={{ display:'flex',alignItems:'center',padding:'6px 20px 14px 56px',borderBottom:'1px solid var(--border)' }}>
<span style={{ flex:1,fontSize:15,color:'var(--text-secondary)' }}>{fmtDateDisplay(ed)}</span> <span onClick={()=>setShowEndDate(true)} style={{ flex:1,fontSize:15,color:'var(--text-secondary)',cursor:'pointer' }}>{fmtDateDisplay(ed)}</span>
{!allDay && ( {!allDay && (
<TimeInputMobile value={et} onChange={newEt => { <TimeInputMobile value={et} onChange={newEt => {
setEt(newEt); setEt(newEt);
@@ -529,35 +583,39 @@ export default function MobileEventForm({ event, eventTypes, userGroups, selecte
))} ))}
</div> </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)' }}> <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={{ 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> <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> </div>
{/* Location */} {/* 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>}> <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> </MobileRow>
{/* Description */} {/* 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}> <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> </MobileRow>
{/* Delete */} {/* Delete */}
{event && isToolManager && ( {event && (isToolManager || (userId && event.created_by === userId)) && (
<div style={{ padding:'16px 20px' }}> <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> <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>
)} )}
</div> </form>
{/* Overlays */} {/* Overlays */}
{showStartDate && <CalendarPicker value={sd} onChange={v=>{setSd(v);setShowStartDate(false);}} onClose={()=>setShowStartDate(false)}/>} {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)}/>} {showEndDate && <CalendarPicker value={ed} onChange={v=>{setEd(v);setShowEndDate(false);}} onClose={()=>setShowEndDate(false)}/>}
{showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>} {showRecurrence && <RecurrenceSheet value={recRule} onChange={v=>{setRecRule(v);}} onClose={()=>setShowRecurrence(false)}/>}
{showScopeModal && <RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
{showTypeColourPicker && ( {showTypeColourPicker && (
<ColourPickerSheet value={newTypeColour} onChange={setNewTypeColour} onClose={()=>setShowTypeColourPicker(false)} title="Event Type Colour"/> <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 <input
autoFocus autoFocus
value={newTypeName} value={newTypeName}
onChange={e => setNewTypeName(e.target.value)} autoComplete="new-password" onKeyDown={e=>e.key==='Enter'&&createEventType()} onChange={e => setNewTypeName(e.target.value)} autoComplete="off" onKeyDown={e=>e.key==='Enter'&&createEventType()}
placeholder="Type name…" autoComplete="new-password" autoCorrect="off" autoCapitalize="words" spellCheck={false} 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)' }} /> 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 }}> <div style={{ display:'flex',alignItems:'center',gap:12,marginBottom:16 }}>
<label style={{ fontSize:14,color:'var(--text-tertiary)',flexShrink:0 }}>Colour</label> <label style={{ fontSize:14,color:'var(--text-tertiary)',flexShrink:0 }}>Colour</label>

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>, 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, unreadMessages = false, unreadGroupMessages = 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 { user } = useAuth();
const drawerRef = useRef(null); const drawerRef = useRef(null);
const isAdmin = user?.role === 'admin'; const isAdmin = user?.role === 'admin';
const userGroupIds = features.userGroupMemberships || []; const userGroupIds = features.userGroupMemberships || [];
const canAccessTools = isAdmin || user?.role === 'manager' || (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 hasUserGroups = userGroupIds.length > 0;
const showAddChild = (features.loginType === 'guardian_only' || features.loginType === 'mixed_age') && features.inGuardiansGroup;
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
@@ -58,7 +59,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
{/* Close X */} {/* Close X */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}> <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"> <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> <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> </button>
@@ -66,7 +67,7 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
{/* User section */} {/* User section */}
{item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat', dot: unreadMessages })} {item(NAV_ICON.messages, 'Messages', onMessages, { active: currentPage === 'chat', dot: unreadMessages })}
{hasUserGroups && item(NAV_ICON.groupmessages, 'Group Messages', onGroupMessages, { active: currentPage === 'groupmessages', dot: unreadGroupMessages })} {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' })} {features.scheduleManager && item(NAV_ICON.schedules, 'Schedules', onSchedule, { active: currentPage === 'schedule' })}
{/* Admin section */} {/* Admin section */}
@@ -80,11 +81,16 @@ export default function NavDrawer({ open, onClose, onMessages, onGroupMessages,
)} )}
{/* Tools section */} {/* Tools section */}
{canAccessTools && ( {(canAccessTools || showAddChild) && (
<> <>
<div className="nav-drawer-section-label admin">Tools</div> <div className="nav-drawer-section-label admin">Tools</div>
{item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })} {canAccessTools && item(NAV_ICON.users, 'User Manager', onUsers, { active: currentPage === 'users' })}
{features.groupManager && item(NAV_ICON.groups, 'Group Manager', onGroupManager, { active: currentPage === 'groups' })} {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> </div>

View File

@@ -4,19 +4,29 @@ import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
export default function NewChatModal({ onClose, onCreated }) { export default function NewChatModal({ onClose, onCreated, features = {} }) {
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); 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 [name, setName] = useState('');
const [isReadonly, setIsReadonly] = useState(false); const [isReadonly, setIsReadonly] = useState(false);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [selected, setSelected] = useState([]); const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(false); 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 // True when exactly 1 user selected on private tab AND U2U messages are enabled
const isDirect = tab === 'private' && selected.length === 1; const isDirect = tab === 'private' && selected.length === 1 && msgU2U;
useEffect(() => { useEffect(() => {
api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {}); api.searchUsers('').then(({ users }) => setUsers(users)).catch(() => {});
@@ -30,19 +40,19 @@ export default function NewChatModal({ onClose, onCreated }) {
const toggle = (u) => { const toggle = (u) => {
if (u.id === user.id) return; 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 () => { const doCreate = 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');
setLoading(true); setLoading(true);
try { try {
let payload; let payload;
if (isDirect) { if (isDirect) {
// Direct message: no name, isDirect flag
payload = { payload = {
type: 'private', type: 'private',
memberIds: selected.map(u => u.id), 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) { if (duplicate) {
toast('A group with these members already exists — opening it now.', 'info'); toast('A group with these members already exists — opening it now.', 'info');
} else { } else {
toast(isDirect ? 'Direct message started!' : `${tab === 'public' ? 'Public message' : 'Group message'} created!`, 'success'); 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); onCreated(group);
} catch (e) { } 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 // Placeholder for the name field
const namePlaceholder = isDirect const namePlaceholder = isDirect
? selected[0]?.name || '' ? selected[0]?.name || ''
@@ -86,22 +116,22 @@ export default function NewChatModal({ onClose, onCreated }) {
</button> </button>
</div> </div>
{user.role === 'admin' && ( {user.role === 'admin' && (msgU2U || msgPrivateGroup || msgPublic) && (
<div className="flex gap-2" style={{ marginBottom: 20 }}> <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> {(msgU2U || msgPrivateGroup) && <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> {msgPublic && <button className={`btn ${tab === 'public' ? 'btn-primary' : 'btn-secondary'} btn-sm`} onClick={() => setTab('public')}>Public Message</button>}
</div> </div>
)} )}
{/* Message Name — only shown when needed: public always, private only when 2+ members selected */} {/* Message Name — public always, private when not a DM and at least 1 member selected */}
{(tab === 'public' || (tab === 'private' && selected.length > 1)) && ( {(tab === 'public' || (tab === 'private' && !isDirect && selected.length > 0)) && (
<div className="flex-col gap-2" style={{ marginBottom: 16 }}> <div className="flex-col gap-2" style={{ marginBottom: 16 }}>
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Message Name</label>
<input <input
className="input" className="input"
value={name} value={name}
onChange={e => setName(e.target.value)} autoComplete="new-password" placeholder={namePlaceholder} onChange={e => setName(e.target.value)} placeholder={namePlaceholder}
autoComplete="new-password" autoCorrect="off" autoCapitalize="words" spellCheck={false} /> autoComplete="off" autoCorrect="off" autoCapitalize="words" spellCheck={false} />
</div> </div>
)} )}
@@ -120,7 +150,7 @@ export default function NewChatModal({ onClose, onCreated }) {
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>
{isDirect ? 'Direct Message with' : 'Add Members'} {isDirect ? 'Direct Message with' : 'Add Members'}
</label> </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> </div>
{selected.length > 0 && ( {selected.length > 0 && (
@@ -160,6 +190,30 @@ export default function NewChatModal({ onClose, onCreated }) {
</button> </button>
</div> </div>
</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> </div>
); );
} }

View File

@@ -12,6 +12,10 @@ export default function PasswordInput({ className, style, wrapperStyle, ...input
return ( return (
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', ...wrapperStyle }}> <div style={{ position: 'relative', display: 'flex', alignItems: 'center', ...wrapperStyle }}>
<input <input
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
{...inputProps} {...inputProps}
type={show ? 'text' : 'password'} type={show ? 'text' : 'password'}
className={className ?? 'input'} className={className ?? 'input'}

View File

@@ -1,35 +1,75 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import Avatar from './Avatar.jsx'; 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 }) { export default function ProfileModal({ onClose }) {
const { user, updateUser } = useAuth(); const { user, updateUser } = useAuth();
const toast = useToast(); const toast = useToast();
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 768);
const [displayName, setDisplayName] = useState(user?.display_name || ''); const [displayName, setDisplayName] = useState(user?.display_name || '');
const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || ''); const [savedDisplayName, setSavedDisplayName] = useState(user?.display_name || '');
const [displayNameWarning, setDisplayNameWarning] = useState(''); const [displayNameWarning, setDisplayNameWarning] = useState('');
const [aboutMe, setAboutMe] = useState(user?.about_me || ''); 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 [currentPw, setCurrentPw] = useState('');
const [newPw, setNewPw] = useState(''); const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState(''); const [confirmPw, setConfirmPw] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' const [tab, setTab] = useState('profile'); // 'profile' | 'password' | 'notifications' | 'appearance'
const [pushTesting, setPushTesting] = useState(false); const [pushTesting, setPushTesting] = useState(false);
const [pushResult, setPushResult] = useState(null); const [pushResult, setPushResult] = useState(null);
const [notifPermission, setNotifPermission] = useState( const [notifPermission, setNotifPermission] = useState(
typeof Notification !== 'undefined' ? Notification.permission : 'unsupported' 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 [hideAdminTag, setHideAdminTag] = useState(!!user?.hide_admin_tag);
const [allowDm, setAllowDm] = useState(user?.allow_dm !== 0); 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 () => { const handleSaveProfile = async () => {
if (displayNameWarning) return toast('Display name is already in use', 'error'); if (displayNameWarning) return toast('Display name is already in use', 'error');
setLoading(true); setLoading(true);
try { 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); updateUser(updated);
setSavedDisplayName(displayName); setSavedDisplayName(displayName);
toast('Profile updated', 'success'); toast('Profile updated', 'success');
@@ -67,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 ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal"> <div className="modal">
@@ -102,11 +191,15 @@ export default function ProfileModal({ onClose }) {
</div> </div>
</div> </div>
{/* Tabs */} {/* Tab navigation — unified select list on all screen sizes */}
<div className="flex gap-2" style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<button className={`btn btn-sm ${tab === 'profile' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('profile')}>Profile</button> <label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
<button className={`btn btn-sm ${tab === 'password' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab('password')}>Change Password</button> <select className="input" value={tab} onChange={e => { setTab(e.target.value); setPushResult(null); }}>
<button className={`btn btn-sm ${tab === 'notifications' ? 'btn-primary' : 'btn-secondary'}`} onClick={() => { setTab('notifications'); setPushResult(null); }}>Notifications</button> <option value="profile">Profile</option>
<option value="password">Change Password</option>
<option value="notifications">Notifications</option>
<option value="appearance">Appearance</option>
</select>
</div> </div>
{tab === 'profile' && ( {tab === 'profile' && (
@@ -129,7 +222,7 @@ export default function ProfileModal({ onClose }) {
} }
}} }}
placeholder={user?.name} 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 }} /> style={{ borderColor: displayNameWarning ? '#e53935' : undefined }} />
{displayName !== savedDisplayName ? null : savedDisplayName ? ( {displayName !== savedDisplayName ? null : savedDisplayName ? (
<button <button
@@ -147,7 +240,7 @@ export default function ProfileModal({ onClose }) {
</div> </div>
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>About Me</label> <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> </div>
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}> <label className="flex items-center gap-2 text-sm pointer" style={{ color: 'var(--text-secondary)', userSelect: 'none' }}>
@@ -167,6 +260,19 @@ export default function ProfileModal({ onClose }) {
style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} /> style={{ accentColor: 'var(--primary)', width: 16, height: 16 }} />
Allow others to send me direct messages Allow others to send me direct messages
</label> </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}> <button className="btn btn-primary" onClick={handleSaveProfile} disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'} {loading ? 'Saving...' : 'Save Changes'}
</button> </button>
@@ -175,11 +281,32 @@ export default function ProfileModal({ onClose }) {
{tab === 'notifications' && ( {tab === 'notifications' && (
<div className="flex-col gap-3"> <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' && ( {notifPermission !== 'granted' && notifPermission !== 'unsupported' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '10px 12px', borderRadius: 8, background: 'var(--surface-variant)' }}> <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)' }}> <div style={{ fontSize: 14, color: 'var(--text-secondary)' }}>
{notifPermission === 'denied' {notifPermission === 'denied'
? 'Notifications are blocked. Enable them in Android Settings → Apps → RosterChirp → Notifications.' ? 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.'} : 'Push notifications are not yet enabled on this device.'}
</div> </div>
{notifPermission === 'default' && ( {notifPermission === 'default' && (
@@ -195,7 +322,12 @@ export default function ProfileModal({ onClose }) {
<div style={{ fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.5 }}> <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 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/> <p style={{ margin: 0 }}>If it doesn't arrive, check:<br/>
• Android Settings → Apps → RosterChirp → Notifications → Enabled<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 • App is backgrounded when the test fires
</p> </p>
</div> </div>
@@ -221,30 +353,34 @@ export default function ProfileModal({ onClose }) {
> >
{pushTesting ? 'Sending' : 'Test (via SW)'} {pushTesting ? 'Sending' : 'Test (via SW)'}
</button> </button>
<button {!isIOS && (
className="btn btn-secondary" <button
style={{ flex: 1 }} className="btn btn-secondary"
disabled={pushTesting} style={{ flex: 1 }}
onClick={async () => { disabled={pushTesting}
setPushTesting(true); onClick={async () => {
setPushResult(null); setPushTesting(true);
try { setPushResult(null);
const { results } = await api.testPush('browser'); try {
setPushResult({ ok: true, results, mode: 'browser' }); const { results } = await api.testPush('browser');
} catch (e) { setPushResult({ ok: true, results, mode: 'browser' });
setPushResult({ ok: false, error: e.message }); } catch (e) {
} finally { setPushResult({ ok: false, error: e.message });
setPushTesting(false); } finally {
} setPushTesting(false);
}} }
> }}
{pushTesting ? 'Sending' : 'Test (via Browser)'} >
</button> {pushTesting ? 'Sending' : 'Test (via Browser)'}
</div> </button>
<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> </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 && ( {pushResult && (
<div style={{ <div style={{
@@ -265,6 +401,8 @@ export default function ProfileModal({ onClose }) {
)} )}
</div> </div>
)} )}
</>
)}
</div> </div>
)} )}
@@ -272,7 +410,7 @@ export default function ProfileModal({ onClose }) {
<div className="flex-col gap-3"> <div className="flex-col gap-3">
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label> <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>
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>
@@ -287,6 +425,40 @@ export default function ProfileModal({ onClose }) {
</button> </button>
</div> </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>
</div> </div>
); );

View File

@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom';
import { api } from '../utils/api.js'; import { api } from '../utils/api.js';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx';
import { useSocket } from '../contexts/SocketContext.jsx';
import UserFooter from './UserFooter.jsx'; import UserFooter from './UserFooter.jsx';
import MobileEventForm from './MobileEventForm.jsx'; import MobileEventForm from './MobileEventForm.jsx';
import ColourPickerSheet from './ColourPickerSheet.jsx'; import ColourPickerSheet from './ColourPickerSheet.jsx';
@@ -152,7 +153,7 @@ function TimeInput({ value, onChange, style }) {
// Keep display in sync when value changes externally // Keep display in sync when value changes externally
useEffect(() => { setInputVal(fmt12(value)); }, [value]); useEffect(() => { setInputVal(fmt12(value)); }, [value]);
// Scroll the dropdown so the selected slot is near the top // Scroll the dropdown so that selected slot is near the top
useEffect(() => { useEffect(() => {
if (!open || !listRef.current) return; if (!open || !listRef.current) return;
const idx = TIME_SLOTS.findIndex(s => s.value === value); const idx = TIME_SLOTS.findIndex(s => s.value === value);
@@ -184,6 +185,7 @@ function TimeInput({ value, onChange, style }) {
return ( return (
<div ref={wrapRef} style={{ position: 'relative', ...style }}> <div ref={wrapRef} style={{ position: 'relative', ...style }}>
<input <input
type="text"
className="input" className="input"
value={inputVal} value={inputVal}
onChange={e => setInputVal(e.target.value)} onChange={e => setInputVal(e.target.value)}
@@ -194,18 +196,21 @@ function TimeInput({ value, onChange, style }) {
}} }}
onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); commit(inputVal); } if (e.key === 'Escape') { setInputVal(fmt12(value)); setOpen(false); } }}
style={{ width: '100%', cursor: 'text' }} style={{ width: '100%', cursor: 'text' }}
autoComplete="new-password" autoComplete="off"
inputMode="text"
enterKeyHint="done"
placeholder="9:00 AM" placeholder="9:00 AM"
/> />
{open && ( {open && (
<div <div
ref={listRef} ref={listRef}
style={{ style={{
position: 'absolute', top: '100%', left: 0, zIndex: 300, position: 'absolute', top: '100%', left: 0, zIndex: 9999,
background: 'var(--surface)', border: '1px solid var(--border)', background: 'var(--surface)', border: '1px solid var(--border)',
borderRadius: 'var(--radius)', boxShadow: '0 4px 16px rgba(0,0,0,0.15)', borderRadius: 'var(--radius)', boxShadow: '0 4px 16px rgba(0,0,0,0.15)',
width: '100%', minWidth: 120, width: '100%', minWidth: 120,
maxHeight: 5 * 36, overflowY: 'auto', maxHeight: 5 * 36, overflowY: 'auto',
pointerEvents: 'auto',
}} }}
> >
{TIME_SLOTS.map(s => ( {TIME_SLOTS.map(s => (
@@ -350,7 +355,7 @@ function MobileScheduleFilter({ selected, onMonthChange, view, eventTypes, filte
<div style={{position:'relative',marginBottom:8}}> <div style={{position:'relative',marginBottom:8}}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2" style={{position:'absolute',left:9,top:'50%',transform:'translateY(-50%)',pointerEvents:'none'}}><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input value={filterKeyword} onChange={e => onFilterKeyword(e.target.value)} autoComplete="new-password" onFocus={onInputFocus} onBlur={onInputBlur} <input value={filterKeyword} onChange={e => onFilterKeyword(e.target.value)} autoComplete="new-password" onFocus={onInputFocus} onBlur={onInputBlur}
placeholder="Search events…" autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck={false} placeholder="Search events…" autoCorrect="off" autoCapitalize="off" spellCheck={false}
style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}/> style={{width:'100%',padding:'7px 8px 7px 28px',border:'1px solid var(--border)',borderRadius:'var(--radius)',background:'var(--background)',color:'var(--text-primary)',fontSize:13,boxSizing:'border-box'}}/>
</div> </div>
<select value={filterTypeId} onChange={e=>onFilterTypeId(e.target.value)} <select value={filterTypeId} onChange={e=>onFilterTypeId(e.target.value)}
@@ -388,7 +393,7 @@ function EventTypePopup({ userGroups, onSave, onClose, editing=null }) {
}; };
return ( return (
<div style={{position:'absolute',top:'100%',left:0,zIndex:300,background:'var(--surface)',border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:16,width:270,boxShadow:'0 4px 20px rgba(0,0,0,0.2)'}}> <div style={{position:'absolute',top:'100%',left:0,zIndex:300,background:'var(--surface)',border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:16,width:270,boxShadow:'0 4px 20px rgba(0,0,0,0.2)'}}>
<div style={{marginBottom:8}}><label className="settings-section-label">Name</label><input className="input" value={name} onChange={e => setName(e.target.value)} autoComplete="new-password" style={{marginTop:4}} autoFocus/></div> <div style={{marginBottom:8}}><label className="settings-section-label">Name</label><input className="input" value={name} onChange={e => setName(e.target.value)} autoComplete="new-password" autoCorrect="off" style={{marginTop:4}} autoFocus/></div>
<div style={{marginBottom:8}}><label className="settings-section-label">Colour</label><input type="color" value={colour} onChange={e => setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/></div> <div style={{marginBottom:8}}><label className="settings-section-label">Colour</label><input type="color" value={colour} onChange={e => setColour(e.target.value)} style={{marginTop:4,width:'100%',height:32,padding:2,borderRadius:4,border:'1px solid var(--border)'}}/></div>
<div style={{marginBottom:8}}><label className="settings-section-label">Default Group</label><select className="input" value={groupId} onChange={e=>setGroupId(e.target.value)} style={{marginTop:4}}><option value="">None</option>{userGroups.map(g=><option key={g.id} value={g.id}>{g.name}</option>)}</select></div> <div style={{marginBottom:8}}><label className="settings-section-label">Default Group</label><select className="input" value={groupId} onChange={e=>setGroupId(e.target.value)} style={{marginTop:4}}><option value="">None</option>{userGroups.map(g=><option key={g.id} value={g.id}>{g.name}</option>)}</select></div>
<div style={{marginBottom:12}}> <div style={{marginBottom:12}}>
@@ -452,7 +457,7 @@ function CustomRecurrenceFields({ rule, onChange }) {
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}> <div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',padding:12,display:'flex',flexDirection:'column',gap:10}}>
<div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}> <div style={{display:'flex',alignItems:'center',gap:8,fontSize:13}}>
<span style={{color:'var(--text-tertiary)'}}>Every</span> <span style={{color:'var(--text-tertiary)'}}>Every</span>
<input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e => upd('interval',Math.max(1,parseInt(e.target.value)||1))} style={{width:60,textAlign:'center'}}/> <input type="number" className="input" min={1} max={99} value={rule.interval||1} onChange={e => upd('interval',Math.max(1,parseInt(e.target.value)||1))} autoComplete="new-password" style={{width:60,textAlign:'center'}}/>
<select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}> <select className="input" value={rule.unit||'week'} onChange={e=>upd('unit',e.target.value)} style={{flex:1}}>
{['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)} {['day','week','month','year'].map(u=><option key={u} value={u}>{u}{(rule.interval||1)>1?'s':''}</option>)}
</select> </select>
@@ -474,8 +479,8 @@ function CustomRecurrenceFields({ rule, onChange }) {
<label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}> <label key={val} style={{display:'flex',alignItems:'center',gap:10,marginBottom:6,fontSize:13,cursor:'pointer'}}>
<input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/> <input type="radio" name="recur_ends" checked={(rule.ends||'never')===val} onChange={()=>upd('ends',val)}/>
{lbl} {lbl}
{val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e => upd('endDate',e.target.value)} style={{marginLeft:8,flex:1}}/>} {val==='on' && (rule.ends||'never')==='on' && <input type="date" className="input" value={rule.endDate||''} onChange={e => upd('endDate',e.target.value)} autoComplete="new-password" style={{marginLeft:8,flex:1}}/>}
{val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e => upd('endCount',parseInt(e.target.value)||1)} style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>} {val==='after' && (rule.ends||'never')==='after' && <><input type="number" className="input" min={1} max={999} value={rule.endCount||13} onChange={e => upd('endCount',parseInt(e.target.value)||1)} autoComplete="new-password" style={{width:64,textAlign:'center',marginLeft:8}}/><span style={{color:'var(--text-tertiary)'}}>occurrences</span></>}
</label> </label>
))} ))}
</div> </div>
@@ -495,8 +500,50 @@ function FormRow({ label, children, required }) {
); );
} }
// ── 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
);
}
// ── Confirm modal (non-recurring delete) ──────────────────────────────────────
function ConfirmModal({ title, message, confirmLabel='Delete', onConfirm, onCancel }) {
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 12px'}}>{title}</h3>
<p style={{fontSize:14,color:'var(--text-secondary)',margin:'0 0 24px'}}>{message}</p>
<div style={{display:'flex',justifyContent:'flex-end',gap:8}}>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
<button className="btn btn-sm" style={{background:'var(--error)',color:'white'}} onClick={onConfirm}>{confirmLabel}</button>
</div>
</div>
</div>,
document.body
);
}
// ── Event Form ──────────────────────────────────────────────────────────────── // ── Event Form ────────────────────────────────────────────────────────────────
function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager }) { function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCancel, onDelete, isToolManager, userId }) {
const toast=useToast(); const toast=useToast();
const _defD = selectedDate || new Date(); const _defD = selectedDate || new Date();
const _p = n => String(n).padStart(2,'0'); const _p = n => String(n).padStart(2,'0');
@@ -510,13 +557,15 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
const [allDay,setAllDay]=useState(!!event?.all_day); const [allDay,setAllDay]=useState(!!event?.all_day);
const [loc,setLoc]=useState(event?.location||''); const [loc,setLoc]=useState(event?.location||'');
const [desc,setDesc]=useState(event?.description||''); const [desc,setDesc]=useState(event?.description||'');
const [pub,setPub]=useState(event?!!event.is_public:true); const [pub,setPub]=useState(event?!!event.is_public:!!isToolManager);
const [track,setTrack]=useState(!!event?.track_availability); const [track,setTrack]=useState(!!event?.track_availability);
const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id))); const accessibleGroupIds = new Set(userGroups.map(g=>g.id));
const [grps,setGrps]=useState(new Set((event?.user_groups||[]).map(g=>g.id).filter(id=>isToolManager||accessibleGroupIds.has(id))));
const [saving,setSaving]=useState(false); const [saving,setSaving]=useState(false);
const [showTypeForm,setShowTypeForm]=useState(false); const [showTypeForm,setShowTypeForm]=useState(false);
const [localTypes,setLocalTypes]=useState(eventTypes); const [localTypes,setLocalTypes]=useState(eventTypes);
const [recRule,setRecRule]=useState(event?.recurrence_rule||null); const [recRule,setRecRule]=useState(event?.recurrence_rule||null);
const [showScopeModal,setShowScopeModal]=useState(false);
// Sync localTypes when parent provides updated eventTypes (e.g. after async load) // Sync localTypes when parent provides updated eventTypes (e.g. after async load)
// Also initialise typeId to the default event type for new events // Also initialise typeId to the default event type for new events
useEffect(()=>{ useEffect(()=>{
@@ -585,36 +634,46 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
useEffect(()=>{ mountedRef.current = true; },[]); useEffect(()=>{ mountedRef.current = true; },[]);
const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;}); const toggleGrp=id=>setGrps(prev=>{const n=new Set(prev);n.has(id)?n.delete(id):n.add(id);return n;});
const groupsRequired=track; // when tracking, groups are required const groupsRequired = track || !isToolManager; // tracking requires groups; non-managers always require groups
const handle=async()=>{ const handle=()=>{
if(!title.trim()) return toast('Title required','error'); if(!title.trim()) return toast('Title required','error');
if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error'); if(!allDay&&(!sd||!st||!ed||!et)) return toast('Start and end required','error');
if(groupsRequired&&grps.size===0) return toast('Select at least one group for availability tracking','error'); if(groupsRequired&&grps.size===0) return toast('Select at least one group','error');
if(ed<sd) return toast('End date cannot be before start date','error'); if(ed<sd) return toast('End date cannot be before start date','error');
if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use a later end date','error'); if(!allDay&&ed===sd&&buildISO(ed,et)<=buildISO(sd,st)) return toast('End time must be after start time, or use 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 && 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 && 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); setSaving(true);
try{ 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:loc,description:desc,isPublic:pub,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null}; 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:loc,description:desc,isPublic:isToolManager?pub:false,trackAvailability:track,userGroupIds:[...grps],recurrenceRule:recRule||null};
let scope='this'; let r;
if(event && event.recurrence_rule?.freq) { if(event){
const choice = window.confirm('This is a recurring event.\n\nOK = Update this and all future occurrences\nCancel = Update this event only'); const updateBody={...body,recurringScope:scope};
scope = choice ? 'future' : 'this'; 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); onSave(r.event);
}catch(e){toast(e.message,'error');}finally{setSaving(false);} }catch(e){toast(e.message,'error');}finally{setSaving(false);}
}; };
return ( return (
<>
<div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}> <div style={{width:'100%',maxWidth:1024,overflowX:'auto'}}>
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="new-password"
on individual inputs is ignored by Chrome but respected on the form element */}
<form autoComplete="off" onSubmit={e => e.preventDefault()}>
<div style={{minWidth:500}} onKeyDown={e=>{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}> <div style={{minWidth:500}} onKeyDown={e=>{if(e.key==='Enter'&&e.target.tagName!=='TEXTAREA') e.preventDefault();}}>
{/* Title */} {/* Title */}
<div style={{marginBottom:20}}> <div style={{marginBottom:20}}>
<input className="input" placeholder="Add title" value={title} onChange={e => setTitle(e.target.value)} autoComplete="new-password" style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/> <input className="input" placeholder="Add title" value={title} onChange={e => setTitle(e.target.value)} autoComplete="new-password" autoCorrect="off" autoCapitalize="sentences" style={{fontSize:20,fontWeight:700,border:'none',borderBottom:'2px solid var(--border)',borderRadius:0,padding:'4px 0',background:'transparent',width:'100%'}}/>
</div> </div>
{/* Event Type */} {/* Event Type */}
@@ -633,7 +692,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
<FormRow label="Date & Time"> <FormRow label="Date & Time">
<div style={{display:'flex',flexDirection:'column',gap:8}}> <div style={{display:'flex',flexDirection:'column',gap:8}}>
<div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}> <div style={{display:'flex',alignItems:'center',gap:8,flexWrap:'nowrap'}}>
<input type="date" className="input" value={sd} onChange={e => setSd(e.target.value)} style={{width:150,flexShrink:0}}/> <input type="date" className="input" value={sd} onChange={e => setSd(e.target.value)} autoComplete="new-password" style={{width:150,flexShrink:0}}/>
{!allDay&&( {!allDay&&(
<> <>
<TimeInput value={st} onChange={setSt} style={{width:120,flexShrink:0}}/> <TimeInput value={st} onChange={setSt} style={{width:120,flexShrink:0}}/>
@@ -642,7 +701,7 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
setEt(newEt); userSetEndTime.current=true; setEt(newEt); userSetEndTime.current=true;
if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); } if(sd===ed && newEt<=st){ const d=new Date(buildISO(sd,st)); d.setDate(d.getDate()+1); const p=n=>String(n).padStart(2,'0'); setEd(`${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}`); }
}} style={{width:120,flexShrink:0}}/> }} style={{width:120,flexShrink:0}}/>
<input type="date" className="input" value={ed} onChange={e => {setEd(e.target.value);userSetEndTime.current=true;}} style={{width:150,flexShrink:0}}/> <input type="date" className="input" value={ed} onChange={e => {setEd(e.target.value);userSetEndTime.current=true;}} autoComplete="new-password" style={{width:150,flexShrink:0}}/>
</> </>
)} )}
</div> </div>
@@ -682,14 +741,14 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
</div> </div>
<p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}> <p style={{fontSize:11,color:groupsRequired&&grps.size===0?'var(--error)':'var(--text-tertiary)',marginTop:4}}>
{grps.size===0 {grps.size===0
? (groupsRequired?'At least one group required for availability tracking':'No groups — event visible to all (if public)') ? (groupsRequired?'At least one group required':'No groups — event visible to all (if public)')
: `${grps.size} group${grps.size!==1?'s':''} selected`} : `${grps.size} group${grps.size!==1?'s':''} selected`}
</p> </p>
</div> </div>
</FormRow> </FormRow>
{/* Visibility — only shown if groups selected OR tracking */} {/* Visibility — only tool managers can set; regular users always create private events */}
{(grps.size>0||track) && ( {isToolManager && (grps.size>0||track) && (
<FormRow label="Visibility"> <FormRow label="Visibility">
<label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}> <label style={{display:'flex',alignItems:'center',gap:10,fontSize:13,cursor:'pointer',paddingTop:6}}>
<input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/> <input type="checkbox" checked={!pub} onChange={e=>setPub(!e.target.checked)}/>
@@ -700,44 +759,190 @@ function EventForm({ event, userGroups, eventTypes, selectedDate, onSave, onCanc
{/* Location */} {/* Location */}
<FormRow label="Location"> <FormRow label="Location">
<input className="input" placeholder="Add location" value={loc} onChange={e => setLoc(e.target.value)} autoComplete="new-password" /> <input className="input" placeholder="Add location" value={loc} onChange={e => setLoc(e.target.value)} autoComplete="new-password" autoCorrect="off" autoCapitalize="off" />
</FormRow> </FormRow>
{/* Description */} {/* Description */}
<FormRow label="Description"> <FormRow label="Description">
<textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} style={{resize:'vertical'}}/> <textarea className="input" placeholder="Add description" value={desc} onChange={e=>setDesc(e.target.value)} rows={3} autoComplete="new-password" autoCorrect="off" style={{resize:'vertical'}}/>
</FormRow> </FormRow>
<div style={{display:'flex',gap:8,marginTop:8}}> <div style={{display:'flex',gap:8,marginTop:8}}>
<button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button> <button className="btn btn-primary btn-sm" onClick={handle} disabled={saving}>{saving?'Saving…':event?'Save Changes':'Create Event'}</button>
<button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button> <button className="btn btn-secondary btn-sm" onClick={onCancel}>Cancel</button>
{event&&isToolManager&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>} {event&&(isToolManager||(userId&&event.created_by===userId))&&<button className="btn btn-sm" style={{marginLeft:'auto',background:'var(--error)',color:'white'}} onClick={()=>onDelete(event)}>Delete</button>}
</div> </div>
</div> </div>
</form>
</div> </div>
{showScopeModal&&<RecurringChoiceModal title="Edit recurring event" onConfirm={doSave} onCancel={()=>setShowScopeModal(false)}/>}
</>
); );
} }
// ── Event Detail Modal ──────────────────────────────────────────────────────── // ── Event Detail Modal ────────────────────────────────────────────────────────
function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager }) { function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isToolManager, userId }) {
const toast=useToast(); const toast=useToast();
const [myResp,setMyResp]=useState(event.my_response); const [myResp,setMyResp]=useState(event.my_response);
const [myNote,setMyNote]=useState(event.my_note||'');
const [noteInput,setNoteInput]=useState(event.my_note||'');
const [noteSaving,setNoteSaving]=useState(false);
const [avail,setAvail]=useState(event.availability||[]); const [avail,setAvail]=useState(event.availability||[]);
const [expandedNotes,setExpandedNotes]=useState(new Set());
const [responsesExpanded,setResponsesExpanded]=useState(false);
// Guardian Only: responder select ('all' | 'self' | 'alias:<id>' | 'partner:<id>')
const myAliases = event.my_aliases || [];
const myPartner = event.my_partner || null;
const showResponderSelect = !!(event.has_players_group && (myAliases.length > 0 || myPartner)) || !!(myPartner && event.in_guardians_group);
const [responder, setResponder] = useState(event.in_guardians_group ? 'self' : 'all');
// Response that should be highlighted for the currently selected responder
const activeResp = !showResponderSelect || responder === 'all'
? myResp
: responder === 'self'
? myResp
: responder.startsWith('alias:')
? (avail.find(r => r.is_alias && r.alias_id === parseInt(responder.replace('alias:','')))?.response || null)
: (avail.find(r => !r.is_alias && r.user_id === parseInt(responder.replace('partner:','')))?.response || null);
// Sync when parent reloads event after availability change // Sync when parent reloads event after availability change
useEffect(()=>{setMyResp(event.my_response);setAvail(event.availability||[]);},[event]); useEffect(()=>{
setMyResp(event.my_response);
setAvail(event.availability||[]);
setMyNote(event.my_note||'');
setNoteInput(event.my_note||'');
},[event]);
const counts={going:0,maybe:0,not_going:0}; const counts={going:0,maybe:0,not_going:0};
avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;}); avail.forEach(r=>{if(counts[r.response]!==undefined)counts[r.response]++;});
const isPast = !!event.end_at && new Date(event.end_at) < new Date();
const noteChanged = noteInput.trim() !== myNote.trim();
const handleResp=async resp=>{ const handleResp=async resp=>{
// Guardian Only multi-responder logic
if (showResponderSelect) {
const note = noteInput.trim() || null;
// Build list of responders for this action
const targets = responder === 'all'
? [
...(event.in_guardians_group ? [{ type:'self' }] : []),
...myAliases.map(a => ({ type:'alias', aliasId:a.id })),
...(myPartner && !myPartner.respond_separately ? [{ type:'partner', userId:myPartner.id }] : []),
]
: responder === 'self'
? [{ type:'self' }]
: responder.startsWith('alias:')
? [{ type:'alias', aliasId:parseInt(responder.replace('alias:','')) }]
: [{ type:'partner', userId:parseInt(responder.replace('partner:','')) }];
const getCurrentResp = (t) =>
t.type === 'self' ? myResp
: t.type === 'alias' ? (avail.find(r => r.is_alias && r.alias_id === t.aliasId)?.response || null)
: (avail.find(r => !r.is_alias && r.user_id === t.userId)?.response || null);
// For "All": toggle all off only when every target already has this response;
// otherwise set all to this response (avoids partial-toggle confusion)
const allHaveResp = responder === 'all' && targets.every(t => getCurrentResp(t) === resp);
try {
for (const t of targets) {
const prevResp = getCurrentResp(t);
const shouldDelete = responder === 'all' ? allHaveResp : prevResp === resp;
if (shouldDelete) {
await api.deleteAvailability(event.id, t.type === 'alias' ? t.aliasId : undefined, t.type === 'partner' ? t.userId : undefined);
} else {
await api.setAvailability(event.id, resp, note, t.type === 'alias' ? t.aliasId : undefined, t.type === 'partner' ? t.userId : undefined);
}
}
if (targets.some(t => t.type === 'self')) {
setMyResp(responder === 'all' ? (allHaveResp ? null : resp) : (myResp === resp ? null : resp));
}
onAvailabilityChange?.(resp);
} catch(e) { toast(e.message,'error'); }
return;
}
// Normal (non-Guardian-Only) path
const prev=myResp; const prev=myResp;
const next=myResp===resp?null:resp; const next=myResp===resp?null:resp;
setMyResp(next); // optimistic update setMyResp(next); // optimistic update
try{ try{
if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp);} if(prev===resp){await api.deleteAvailability(event.id);}else{await api.setAvailability(event.id,resp,noteInput.trim()||null);}
onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list onAvailabilityChange?.(next); // triggers parent re-fetch to update avail list
}catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error }catch(e){setMyResp(prev);toast(e.message,'error');} // rollback on error
}; };
const handleNoteSave=async()=>{
if(!myResp) return; // no response row to attach note to
setNoteSaving(true);
try{
await api.setAvailabilityNote(event.id,noteInput.trim()||null);
setMyNote(noteInput.trim());
onAvailabilityChange?.(myResp); // re-fetch to update responses list
}catch(e){toast(e.message,'error');}finally{setNoteSaving(false);}
};
const toggleNote=id=>setExpandedNotes(prev=>{const s=new Set(prev);s.has(id)?s.delete(id):s.add(id);return s;});
const handleDownloadAvailability = () => {
// Format as "Lastname, Firstname" using first_name/last_name fields when available
const fmtName = u => {
// Alias entries have first_name/last_name directly
const last = (u.last_name || '').trim();
const first = (u.first_name || '').trim();
if (last && first) return `${last}, ${first}`;
// Fall back to splitting the combined name field
const parts = (u.name || u.display_name || 'Unknown').trim().split(/\s+/);
if (parts.length >= 2) return `${parts[parts.length - 1]}, ${parts.slice(0, -1).join(' ')}`;
return parts[0] || 'Unknown';
};
const sortByLastName = arr => [...arr].sort((a, b) => fmtName(a).localeCompare(fmtName(b)));
const fmtEntry = u => {
const note = (u.note || '').trim();
return note ? `${fmtName(u)} - Note: ${note}` : fmtName(u);
};
const going = sortByLastName(avail.filter(r => r.response === 'going'));
const maybe = sortByLastName(avail.filter(r => r.response === 'maybe'));
const notGoing = sortByLastName(avail.filter(r => r.response === 'not_going'));
const noResp = sortByLastName(event.no_response_users || []);
const sections = [
{ heading: 'Going', rows: going },
{ heading: 'Maybe', rows: maybe },
{ heading: 'Not Going', rows: notGoing },
{ heading: 'No Response', rows: noResp },
];
const eventDate = event.start_at ? fmtDate(new Date(event.start_at)) : '';
const lines = [`${event.title}${eventDate ? ' — ' + eventDate : ''}`, ''];
for (const sec of sections) {
lines.push(`#### ${sec.heading}`);
if (sec.rows.length === 0) {
lines.push('(none)');
} else {
sec.rows.forEach(r => lines.push(fmtEntry(r)));
}
lines.push('');
}
const safeName = (event.title || 'event').replace(/[^a-z0-9]+/gi, '_').toLowerCase();
const fileName = `availability_${safeName}.txt`;
const blob = new Blob([lines.join('\n')], { type: 'text/plain' });
// On mobile use the native share sheet (lets the user choose Save to Files, etc.)
// On desktop fall back to a standard download link.
const file = new File([blob], fileName, { type: 'text/plain' });
if (navigator.canShare && navigator.canShare({ files: [file] })) {
navigator.share({ files: [file], title: fileName }).catch(() => {});
} else {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}
};
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}> <div className="modal-overlay" onClick={e=>e.target===e.currentTarget&&onClose()}>
<div className="modal" style={{maxWidth:520,maxHeight:'88vh',overflowY:'auto'}}> <div className="modal" style={{maxWidth:520,maxHeight:'88vh',overflowY:'auto'}}>
@@ -755,7 +960,7 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
</div> </div>
</div> </div>
<div style={{display:'flex',gap:6,flexShrink:0}}> <div style={{display:'flex',gap:6,flexShrink:0}}>
{isToolManager&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>} {(isToolManager||(!isPast&&userId&&event.created_by===userId))&&<button className="btn btn-secondary btn-sm" onClick={()=>{onClose();onEdit();}}>Edit</button>}
<button className="btn-icon" onClick={onClose}><svg width="18" height="18" 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> <button className="btn-icon" onClick={onClose}><svg width="18" height="18" 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> </div>
</div> </div>
@@ -776,32 +981,118 @@ function EventDetailModal({ event, onClose, onEdit, onAvailabilityChange, isTool
{!!event.track_availability&&( {!!event.track_availability&&(
<div style={{borderTop:'1px solid var(--border)',paddingTop:16,marginTop:4}}> <div style={{borderTop:'1px solid var(--border)',paddingTop:16,marginTop:4}}>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:10}}>Your Availability</div> <div style={{display:'flex',alignItems:'center',marginBottom:10}}>
<div style={{display:'flex',gap:8,marginBottom:16}}> <div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',flex:1}}>Your Availability</div>
{Object.entries(RESP_LABEL).map(([key,label])=>( {isToolManager&&(
<button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:myResp===key?RESP_COLOR[key]:'transparent',color:myResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}> <button
{myResp===key?'✓ ':''}{label} onClick={handleDownloadAvailability}
title="Download Availability List"
style={{background:'none',border:'none',padding:2,cursor:'pointer',color:'var(--text-secondary)',display:'flex',alignItems:'center',borderRadius:4}}
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" width="18" height="18">
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
</svg>
</button> </button>
))} )}
</div> </div>
{isToolManager&&( {isPast ? (
<p style={{fontSize:13,color:'var(--text-tertiary)',marginBottom:16}}>Past event availability is read-only.</p>
) : (
<> <>
<div style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px',marginBottom:8}}>Responses</div> <div style={{display:'flex',gap:8,marginBottom:12}}>
<div style={{display:'flex',gap:20,marginBottom:10,fontSize:13}}> {Object.entries(RESP_LABEL).map(([key,label])=>(
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)} <button key={key} onClick={()=>handleResp(key)} style={{flex:1,padding:'9px 4px',borderRadius:'var(--radius)',border:`2px solid ${RESP_COLOR[key]}`,background:activeResp===key?RESP_COLOR[key]:'transparent',color:activeResp===key?'white':RESP_COLOR[key],fontSize:13,fontWeight:600,cursor:'pointer',transition:'all 0.15s'}}>
<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span> {activeResp===key?'✓ ':''}{label}
</button>
))}
</div> </div>
{avail.length>0&&( {/* Guardian Only: responder select — shown when event targets the players group and user has aliases */}
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden'}}> {showResponderSelect && (
{avail.map(r=>( <div style={{marginBottom:10}}>
<div key={r.user_id} style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',borderBottom:'1px solid var(--border)',fontSize:13}}> <label style={{fontSize:11,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.5px',display:'block',marginBottom:4}}>Responding for</label>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/> <select value={responder} onChange={e=>setResponder(e.target.value)}
<span style={{flex:1}}>{r.display_name||r.name}</span> style={{width:'100%',padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13}}>
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span> {event.in_guardians_group && <option value="self">Myself</option>}
</div> <option value="all">Entire Family</option>
))} {myPartner && !myPartner.respond_separately && <option value={`partner:${myPartner.id}`}>{myPartner.display_name || myPartner.name}</option>}
{myAliases.map(a=><option key={a.id} value={`alias:${a.id}`}>{a.first_name} {a.last_name}</option>)}
</select>
</div> </div>
)} )}
<div style={{display:'flex',gap:8,alignItems:'center',marginBottom:16}}>
<input
type="text"
value={noteInput}
onChange={e=>setNoteInput(e.target.value.slice(0,20))}
placeholder="Add a note (optional)"
maxLength={20}
style={{flex:1,minWidth:0,padding:'7px 10px',borderRadius:'var(--radius)',border:'1px solid var(--border)',background:'var(--surface)',color:'var(--text-primary)',fontSize:13,outline:'none'}}
/>
<span style={{fontSize:11,color:'var(--text-tertiary)',flexShrink:0,minWidth:32,textAlign:'right'}}>{noteInput.length}/20</span>
{myResp&&noteChanged&&(
<button onClick={handleNoteSave} disabled={noteSaving} className="btn btn-primary btn-sm" style={{flexShrink:0}}>
{noteSaving?'…':'Save'}
</button>
)}
</div>
</>
)}
{(isToolManager||avail.length>0)&&(
<>
<div
onClick={()=>setResponsesExpanded(e=>!e)}
style={{display:'flex',alignItems:'center',justifyContent:'space-between',cursor:'pointer',userSelect:'none',marginBottom:responsesExpanded?8:0}}
>
<span style={{fontSize:12,fontWeight:700,color:'var(--text-tertiary)',textTransform:'uppercase',letterSpacing:'0.6px'}}>Responses</span>
<div style={{display:'flex',alignItems:'center',gap:10}}>
<div style={{display:'flex',gap:12,fontSize:12}}>
{Object.entries(counts).map(([k,n])=><span key={k}><span style={{color:RESP_COLOR[k],fontWeight:700}}>{n}</span> {RESP_LABEL[k]}</span>)}
{isToolManager&&<span><span style={{fontWeight:700}}>{event.no_response_count||0}</span> No response</span>}
</div>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:responsesExpanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
</div>
</div>
{responsesExpanded&&avail.length>0&&(()=>{
const RESP_ORDER={going:0,maybe:1,not_going:2};
const sortedAvail=[...avail].sort((a,b)=>{
const od=(RESP_ORDER[a.response]??99)-(RESP_ORDER[b.response]??99);
if(od!==0)return od;
const na=a.is_alias?`${a.first_name} ${a.last_name}`:(a.display_name||a.name||'');
const nb=b.is_alias?`${b.first_name} ${b.last_name}`:(b.display_name||b.name||'');
return na.localeCompare(nb);
});
return(
<div style={{border:'1px solid var(--border)',borderRadius:'var(--radius)',overflow:'hidden',maxHeight:avail.length>4?'140px':undefined,overflowY:avail.length>4?'auto':undefined}}>
{sortedAvail.map(r=>{
const rowKey=r.is_alias?`alias:${r.alias_id}`:`user:${r.user_id}`;
const displayName=r.is_alias?`${r.first_name} ${r.last_name}`:(r.display_name||r.name);
const hasNote=!!(r.note&&r.note.trim());
const expanded=expandedNotes.has(rowKey);
return(
<div key={rowKey} style={{borderBottom:'1px solid var(--border)'}}>
<div
style={{display:'flex',alignItems:'center',gap:10,padding:'8px 12px',fontSize:13,cursor:hasNote?'pointer':'default'}}
onClick={hasNote?()=>toggleNote(rowKey):undefined}
>
<span style={{width:9,height:9,borderRadius:'50%',background:RESP_COLOR[r.response],flexShrink:0,display:'inline-block'}}/>
<span style={{flex:1}}>{displayName}</span>
{r.is_alias&&<span style={{fontSize:11,color:'var(--text-tertiary)',fontStyle:'italic'}}>child</span>}
{hasNote&&(
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="2.5" style={{flexShrink:0,transition:'transform 0.15s',transform:expanded?'rotate(180deg)':'rotate(0deg)'}}><polyline points="6 9 12 15 18 9"/></svg>
)}
<span style={{color:RESP_COLOR[r.response],fontSize:12,fontWeight:600}}>{RESP_LABEL[r.response]}</span>
</div>
{hasNote&&expanded&&(
<div style={{padding:'0 12px 10px 31px',fontSize:12,color:'var(--text-secondary)',fontStyle:'italic'}}>
{r.note}
</div>
)}
</div>
);
})}
</div>
);
})()}
</> </>
)} )}
</div> </div>
@@ -873,7 +1164,7 @@ function EventTypesPanel({ eventTypes, userGroups, onUpdated, isMobile=false })
<span style={{fontWeight:700,fontSize:16}}>{sheetMode==='create'?'New Event Type':'Edit Event Type'}</span> <span style={{fontWeight:700,fontSize:16}}>{sheetMode==='create'?'New Event Type':'Edit Event Type'}</span>
<button onClick={closeSheet} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:20,lineHeight:1}}></button> <button onClick={closeSheet} style={{background:'none',border:'none',cursor:'pointer',color:'var(--text-secondary)',fontSize:20,lineHeight:1}}></button>
</div> </div>
<input autoFocus value={sheetName} onChange={e => setSheetName(e.target.value)} autoComplete="new-password" onKeyDown={e=>e.key==='Enter'&&saveSheet()} placeholder="Type name…" <input autoFocus value={sheetName} onChange={e => setSheetName(e.target.value)} autoComplete="new-password" autoCorrect="off" onKeyDown={e=>e.key==='Enter'&&saveSheet()} placeholder="Type name…"
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)'}}/> 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}}> <div style={{display:'flex',alignItems:'center',gap:12,marginBottom:16}}>
<label style={{fontSize:14,color:'var(--text-tertiary)',flexShrink:0}}>Colour</label> <label style={{fontSize:14,color:'var(--text-tertiary)',flexShrink:0}}>Colour</label>
@@ -951,12 +1242,19 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) {
// Determine end condition // Determine end condition
const endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null; const endDate = rule.ends === 'on' && rule.endDate ? new Date(rule.endDate + 'T23:59:59') : null;
const endCount = rule.ends === 'after' ? (rule.endCount || 13) : null; const endCount = rule.ends === 'after' ? (rule.endCount || 13) : null;
const exceptions = new Set(rule.exceptions || []);
const _pad = n => String(n).padStart(2, '0');
const _toDateStr = d => `${d.getFullYear()}-${_pad(d.getMonth()+1)}-${_pad(d.getDate())}`;
// totalOcc counts ALL occurrences from origStart regardless of range,
// so endCount is respected even when rangeStart is after the event's start.
let totalOcc = 0;
// Start from original and step forward // Start from original and step forward
while (count < maxOccurrences) { while (count < maxOccurrences) {
// Check end conditions // Check end conditions
if (endDate && cur > endDate) break; if (endDate && cur > endDate) break;
if (endCount && occurrences.length >= endCount) break; if (endCount && totalOcc >= endCount) break;
if (cur > rangeEnd) break; if (cur > rangeEnd) break;
if (byDay && (rule.freq === 'weekly' || freq === 'week')) { if (byDay && (rule.freq === 'weekly' || freq === 'week')) {
@@ -964,22 +1262,29 @@ function expandRecurringEvent(ev, rangeStart, rangeEnd) {
const weekStart = new Date(cur); const weekStart = new Date(cur);
weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week weekStart.setDate(cur.getDate() - cur.getDay()); // Sunday of this week
for (const dayKey of byDay) { for (const dayKey of byDay) {
if (endCount && totalOcc >= endCount) break;
const dayNum = DAY_MAP[dayKey]; const dayNum = DAY_MAP[dayKey];
const occ = new Date(weekStart); const occ = new Date(weekStart);
occ.setDate(weekStart.getDate() + dayNum); occ.setDate(weekStart.getDate() + dayNum);
occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds()); occ.setHours(origStart.getHours(), origStart.getMinutes(), origStart.getSeconds());
if (occ >= rangeStart && occ <= rangeEnd) { if (!endDate || occ <= endDate) {
if (!endDate || occ <= endDate) { if (!exceptions.has(_toDateStr(occ))) {
const occEnd = new Date(occ.getTime() + durMs); totalOcc++;
occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true}); if (occ >= rangeStart && occ <= rangeEnd) {
const occEnd = new Date(occ.getTime() + durMs);
occurrences.push({...ev, start_at: occ.toISOString(), end_at: occEnd.toISOString(), _virtual: true});
}
} }
} }
} }
cur = step(cur); cur = step(cur);
} else { } else {
if (cur >= rangeStart && cur <= rangeEnd) { if (!exceptions.has(_toDateStr(cur))) {
const occEnd = new Date(cur.getTime() + durMs); totalOcc++;
occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at}); if (cur >= rangeStart && cur <= rangeEnd) {
const occEnd = new Date(cur.getTime() + durMs);
occurrences.push({...ev, start_at: cur.toISOString(), end_at: occEnd.toISOString(), _virtual: cur.toISOString() !== ev.start_at});
}
} }
cur = step(cur); cur = step(cur);
} }
@@ -1029,6 +1334,10 @@ function parseKeywords(raw) {
function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) { function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filterTypeId='', filterAvailability=false, filterFromDate=null, isMobile=false }) {
const y=selectedDate.getFullYear(), m=selectedDate.getMonth(); const y=selectedDate.getFullYear(), m=selectedDate.getMonth();
const today=new Date(); today.setHours(0,0,0,0); const today=new Date(); today.setHours(0,0,0,0);
const todayRef = useRef(null);
useEffect(()=>{
if(todayRef.current) todayRef.current.scrollIntoView({ block:'start', behavior:'instant' });
},[selectedDate.getFullYear(), selectedDate.getMonth()]);
const terms=parseKeywords(filterKeyword); const terms=parseKeywords(filterKeyword);
const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability; const hasFilters = terms.length > 0 || !!filterTypeId || filterAvailability;
// Only keyword/availability filters should shift the date window to today-onwards. // Only keyword/availability filters should shift the date window to today-onwards.
@@ -1083,9 +1392,13 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
? `No events — ${MONTHS[m]} ${y} is in the past` ? `No events — ${MONTHS[m]} ${y} is in the past`
: `No events in ${MONTHS[m]} ${y}`; : `No events in ${MONTHS[m]} ${y}`;
if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>; if(!filtered.length) return <div style={{textAlign:'center',padding:'60px 20px',color:'var(--text-tertiary)',fontSize:14}}>{emptyMsg}</div>;
let todayMarked = false;
return <>{filtered.map(e=>{ return <>{filtered.map(e=>{
const s=new Date(e.start_at); const s=new Date(e.start_at);
const end=new Date(e.end_at); const end=new Date(e.end_at);
const sDay=new Date(s); sDay.setHours(0,0,0,0);
const isFirstTodayOrFuture = !todayMarked && sDay >= today;
if(isFirstTodayOrFuture) todayMarked = true;
const isPast = !e.all_day && end < now; // event fully ended const isPast = !e.all_day && end < now; // event fully ended
const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af'); const col = isPast ? '#9ca3af' : (e.event_type?.colour||'#9ca3af');
const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)'; const textColor = isPast ? 'var(--text-tertiary)' : 'var(--text-primary)';
@@ -1106,11 +1419,12 @@ function ScheduleView({ events, selectedDate, onSelect, filterKeyword='', filter
: BELL_ICON : BELL_ICON
); );
return( return(
<div key={e.id} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}> <div key={`${e.id}-${e.start_at}`} ref={isFirstTodayOrFuture ? todayRef : null} onClick={()=>onSelect(e)} style={{display:'flex',alignItems:'center',gap:rowGap,padding:rowPad,borderBottom:'1px solid var(--border)',cursor:'pointer',opacity:isPast?0.7:1}} onMouseEnter={el=>el.currentTarget.style.background='var(--background)'} onMouseLeave={el=>el.currentTarget.style.background=''}>
{/* Date column */} {/* Date column */}
<div style={{width:datW,textAlign:'center',flexShrink:0}}> <div style={{width:datW,textAlign:'center',flexShrink:0}}>
<div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div> <div style={{fontSize:datFs,fontWeight:700,lineHeight:1,color:textColor}}>{s.getDate()}</div>
<div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase'}}>{SHORT_MONTHS[s.getMonth()]}, {DAYS[s.getDay()]}</div> <div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase',lineHeight:1.5}}>{SHORT_MONTHS[s.getMonth()]}</div>
<div style={{fontSize:datSFs,color:'var(--text-tertiary)',textTransform:'uppercase',lineHeight:1.5}}>{DAYS[s.getDay()]}</div>
</div> </div>
{/* Time + dot column */} {/* Time + dot column */}
<div style={{width:timeW,flexShrink:0,display:'flex',alignItems:'flex-start',gap:timeGap,fontSize:timeFs,color:subColor}}> <div style={{width:timeW,flexShrink:0,display:'flex',alignItems:'flex-start',gap:timeGap,fontSize:timeFs,color:subColor}}>
@@ -1195,9 +1509,18 @@ function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
const events = expandEvents(rawEvents, dayStart, dayEnd); const events = expandEvents(rawEvents, dayStart, dayEnd);
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START); const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START);
const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate)); const day=events.filter(e=>sameDay(new Date(e.start_at),selectedDate));
const allDayEvs=day.filter(e=>e.all_day);
const timedEvs=day.filter(e=>!e.all_day);
const tzOff=-new Date().getTimezoneOffset();
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null); const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 }); const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]); useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`; const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; }; const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => { const handleTouchEnd = e => {
@@ -1214,15 +1537,25 @@ function DayView({ events: rawEvents, selectedDate, onSelect, onSwipe }) {
<div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)',flexShrink:0}}> <div style={{display:'flex',borderBottom:'1px solid var(--border)',padding:'8px 0 8px 60px',fontSize:13,fontWeight:600,color:'var(--primary)',flexShrink:0}}>
<div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div> <div style={{textAlign:'center'}}><div>{DAYS[selectedDate.getDay()]}</div><div style={{fontSize:28,fontWeight:700}}>{selectedDate.getDate()}</div></div>
</div> </div>
<div style={{display:'flex',borderBottom:'1px solid var(--border)',flexShrink:0,minHeight:28}}>
<div style={{width:60,flexShrink:0,fontSize:10,color:'var(--text-tertiary)',padding:'4px 8px',textAlign:'right',alignSelf:'center'}}>{tzLabel}</div>
<div style={{flex:1,padding:'2px 4px',display:'flex',flexDirection:'column',gap:2}}>
{allDayEvs.map(e=>(
<div key={e.id} onClick={()=>onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 6px',fontSize:12,fontWeight:600,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>
{e.title}
</div>
))}
</div>
</div>
<div ref={scrollRef} style={{flex:1,overflowY:'auto',position:'relative',touchAction:'pan-y'}}> <div ref={scrollRef} style={{flex:1,overflowY:'auto',position:'relative',touchAction:'pan-y'}}>
<div style={{position:'relative'}}> <div style={{position:'relative',paddingBottom:onSwipe?80:0}}>
{hours.map(h=>( {hours.map(h=>(
<div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',height:HOUR_H}}> <div key={h} style={{display:'flex',borderBottom:'1px solid var(--border)',height:HOUR_H}}>
<div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{fmtHour(h)}</div> <div style={{width:60,flexShrink:0,fontSize:11,color:'var(--text-tertiary)',padding:'3px 10px 0',textAlign:'right'}}>{fmtHour(h)}</div>
<div style={{flex:1}}/> <div style={{flex:1}}/>
</div> </div>
))} ))}
{layoutEvents(day).map(({event:e,col,totalCols})=>{ {layoutEvents(timedEvs).map(({event:e,col,totalCols})=>{
const s=new Date(e.start_at), en=new Date(e.end_at); const s=new Date(e.start_at), en=new Date(e.end_at);
const top=eventTopOffset(s), height=eventHeightPx(s,en); const top=eventTopOffset(s), height=eventHeightPx(s,en);
return( return(
@@ -1254,9 +1587,16 @@ function WeekView({ events: rawEvents, selectedDate, onSelect }) {
const events = expandEvents(rawEvents, _ws, _we); const events = expandEvents(rawEvents, _ws, _we);
const ws=weekStart(selectedDate), days=Array.from({length:7},(_,i)=>{const d=new Date(ws);d.setDate(d.getDate()+i);return d;}); const ws=weekStart(selectedDate), days=Array.from({length:7},(_,i)=>{const d=new Date(ws);d.setDate(d.getDate()+i);return d;});
const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date(); const hours=Array.from({length:DAY_END - DAY_START},(_,i)=>i+DAY_START), today=new Date();
const tzOff=-new Date().getTimezoneOffset();
const tzLabel=`GMT${tzOff>=0?'+':'-'}${String(Math.floor(Math.abs(tzOff)/60)).padStart(2,'0')}`;
const scrollRef = useRef(null); const scrollRef = useRef(null);
const touchRef = useRef({ x:0, y:0 }); const touchRef = useRef({ x:0, y:0 });
useEffect(()=>{ if(scrollRef.current) scrollRef.current.scrollTop = 7 * HOUR_H; },[selectedDate]); useEffect(()=>{
if(!scrollRef.current) return;
const now = new Date();
const topPx = Math.max(0, now.getHours() * HOUR_H + (now.getMinutes() / 60) * HOUR_H - 2 * HOUR_H);
scrollRef.current.scrollTop = topPx;
},[selectedDate]);
const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`; const fmtHour = h => h===0?'12 AM':h<12?`${h} AM`:h===12?'12 PM':`${h-12} PM`;
const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; }; const handleTouchStart = e => { touchRef.current = { x:e.touches[0].clientX, y:e.touches[0].clientY }; };
const handleTouchEnd = e => { const handleTouchEnd = e => {
@@ -1275,6 +1615,22 @@ function WeekView({ events: rawEvents, selectedDate, onSelect }) {
<div/> <div/>
{days.map((d,i)=><div key={i} style={{textAlign:'center',padding:'6px 4px',fontSize:12,fontWeight:600,color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)'}}>{DAYS[d.getDay()]} {d.getDate()}</div>)} {days.map((d,i)=><div key={i} style={{textAlign:'center',padding:'6px 4px',fontSize:12,fontWeight:600,color:sameDay(d,today)?'var(--primary)':'var(--text-secondary)'}}>{DAYS[d.getDay()]} {d.getDate()}</div>)}
</div> </div>
{/* All-day row */}
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',borderBottom:'1px solid var(--border)',flexShrink:0,minHeight:28}}>
<div style={{fontSize:10,color:'var(--text-tertiary)',padding:'4px 8px',textAlign:'right',alignSelf:'center'}}>{tzLabel}</div>
{days.map((d,di)=>{
const adEvs=events.filter(e=>e.all_day&&sameDay(new Date(e.start_at),d));
return(
<div key={di} style={{borderLeft:'1px solid var(--border)',padding:'2px 2px',display:'flex',flexDirection:'column',gap:1}}>
{adEvs.map(e=>(
<div key={e.id} onClick={()=>onSelect(e)} style={{background:e.event_type?.colour||'#6366f1',color:'white',borderRadius:3,padding:'2px 4px',fontSize:10,fontWeight:600,cursor:'pointer',whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis'}}>
{e.title}
</div>
))}
</div>
);
})}
</div>
{/* Scrollable time grid */} {/* Scrollable time grid */}
<div ref={scrollRef} style={{flex:1,overflowY:'auto',touchAction:'pan-y'}}> <div ref={scrollRef} style={{flex:1,overflowY:'auto',touchAction:'pan-y'}}>
<div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',position:'relative'}}> <div style={{display:'grid',gridTemplateColumns:'60px repeat(7,1fr)',position:'relative'}}>
@@ -1286,7 +1642,7 @@ function WeekView({ events: rawEvents, selectedDate, onSelect }) {
</div> </div>
{/* Day columns */} {/* Day columns */}
{days.map((d,di)=>{ {days.map((d,di)=>{
const dayEvs=events.filter(e=>sameDay(new Date(e.start_at),d)); const dayEvs=events.filter(e=>!e.all_day&&sameDay(new Date(e.start_at),d));
return( return(
<div key={di} style={{position:'relative',borderLeft:'1px solid var(--border)'}}> <div key={di} style={{position:'relative',borderLeft:'1px solid var(--border)'}}>
{hours.map(h=><div key={h} style={{height:HOUR_H,borderBottom:'1px solid var(--border)'}}/>)} {hours.map(h=><div key={h} style={{height:HOUR_H,borderBottom:'1px solid var(--border)'}}/>)}
@@ -1350,7 +1706,7 @@ function MonthView({ events: rawEvents, selectedDate, onSelect, onSelectDay }) {
borderRadius:3,padding:'1px 4px',fontSize:11,marginBottom:1,cursor:'pointer', borderRadius:3,padding:'1px 4px',fontSize:11,marginBottom:1,cursor:'pointer',
whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis',flexShrink:0, whiteSpace:'nowrap',overflow:'hidden',textOverflow:'ellipsis',flexShrink:0,
}}> }}>
{!e.all_day&&<span style={{marginRight:3,opacity:0.85}}>{fmtTime(e.start_at)}</span>}{e.title} {e.all_day?<span style={{marginRight:3,opacity:0.85}}>All Day:</span>:<span style={{marginRight:3,opacity:0.85}}>{fmtTime(e.start_at)}</span>}{e.title}
</div> </div>
))} ))}
{dayEvs.length>2&&<div style={{fontSize:10,color:'var(--text-tertiary)',flexShrink:0}}>+{dayEvs.length-2} more</div>} {dayEvs.length>2&&<div style={{fontSize:10,color:'var(--text-tertiary)',flexShrink:0}}>+{dayEvs.length-2} more</div>}
@@ -1368,6 +1724,7 @@ function MonthView({ events: rawEvents, selectedDate, onSelect, onSelectDay }) {
export default function SchedulePage({ isToolManager, isMobile, onProfile, onHelp, onAbout }) { export default function SchedulePage({ isToolManager, isMobile, onProfile, onHelp, onAbout }) {
const { user } = useAuth(); const { user } = useAuth();
const toast = useToast(); const toast = useToast();
const { socket } = useSocket();
// Mobile: only day + schedule views // Mobile: only day + schedule views
const allowedViews = isMobile ? ['schedule','day'] : ['schedule','day','week','month']; const allowedViews = isMobile ? ['schedule','day'] : ['schedule','day','week','month'];
@@ -1388,9 +1745,10 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager' const [mobilePanel, setMobilePanel] = useState(null); // null | 'eventForm' | 'groupManager'
const createRef = useRef(null); const createRef = useRef(null);
const contentRef = useRef(null);
const load = useCallback(() => { const load = useCallback(() => {
const ugPromise = isToolManager ? api.getUserGroups() : Promise.resolve({ groups: [] }); const ugPromise = isToolManager ? api.getUserGroups() : api.getMyScheduleGroups();
Promise.all([api.getEvents(), api.getEventTypes(), ugPromise]) Promise.all([api.getEvents(), api.getEventTypes(), ugPromise])
.then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); }) .then(([ev,et,ug]) => { setEvents(ev.events||[]); setEventTypes(et.eventTypes||[]); setUserGroups(ug.groups||[]); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
@@ -1398,6 +1756,16 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
useEffect(() => { load(); }, [load]); useEffect(() => { load(); }, [load]);
// Re-fetch when removed from a user group (private event visibility may change)
useEffect(() => {
if (!socket) return;
socket.on('schedule:refresh', load);
return () => socket.off('schedule:refresh', load);
}, [socket, load]);
// Reset scroll to top on date/view change; schedule view scrolls to today via ScheduleView's own effect
useEffect(() => { if (contentRef.current && view !== 'schedule') contentRef.current.scrollTop = 0; }, [selDate, view]);
useEffect(() => { useEffect(() => {
if (!createOpen) return; if (!createOpen) return;
const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateOpen(false); }; const h = e => { if (createRef.current && !createRef.current.contains(e.target)) setCreateOpen(false); };
@@ -1417,6 +1785,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
if (view==='day') d.setDate(d.getDate()+dir); if (view==='day') d.setDate(d.getDate()+dir);
else if (view==='week') d.setDate(d.getDate()+dir*7); else if (view==='week') d.setDate(d.getDate()+dir*7);
else { else {
d.setDate(1); // prevent overflow (e.g. Jan 31 + 1 month = Mar 3 without this)
d.setMonth(d.getMonth()+dir); d.setMonth(d.getMonth()+dir);
// Month nav: clear mini-calendar filter and show full month // Month nav: clear mini-calendar filter and show full month
setFilterFromDate(null); setFilterFromDate(null);
@@ -1434,19 +1803,29 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
}; };
const openDetail = async e => { const openDetail = async e => {
try { const { event } = await api.getEvent(e.id); setDetailEvent(event); } catch { toast('Failed to load event','error'); } try {
const { event } = await api.getEvent(e.id);
// Virtual recurring occurrences carry their own start/end dates — overlay them so
// the modal shows the correct occurrence time and isPast evaluates against the
// occurrence's end_at, not the base event's first-occurrence end_at.
if (e._virtual) { event.start_at = e.start_at; event.end_at = e.end_at; event._virtual = true; }
setDetailEvent(event);
} catch { toast('Failed to load event','error'); }
}; };
const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); }; const handleSaved = () => { load(); setPanel('calendar'); setEditingEvent(null); };
const handleDelete = async e => { const [deleteTarget, setDeleteTarget] = useState(null);
if (!confirm(`Delete "${e.title}"?`)) return; const handleDelete = (e) => setDeleteTarget(e);
const doDelete = async (scope = 'this') => {
const e = deleteTarget;
setDeleteTarget(null);
try { try {
await api.deleteEvent(e.id); await api.deleteEvent(e.id, scope, e._virtual ? e.start_at : null);
toast('Deleted','success'); toast('Deleted','success');
setPanel('calendar'); setPanel('calendar');
setEditingEvent(null); setEditingEvent(null);
setDetailEvent(null); setDetailEvent(null);
load(); // reload list so deleted event disappears immediately load();
} catch(err) { toast(err.message,'error'); } } catch(err) { toast(err.message,'error'); }
}; };
@@ -1463,26 +1842,27 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<div style={{ padding:'16px 16px 0' }}> <div style={{ padding:'16px 16px 0' }}>
<div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div> <div style={{ fontSize:16, fontWeight:700, marginBottom:12, color:'var(--text-primary)' }}>Team Schedule</div>
{/* Create button — styled like new-chat-btn */} {/* Create button — visible to all users */}
{isToolManager && ( <div style={{ position:'relative', marginBottom:12 }} ref={createRef}>
<div style={{ position:'relative', marginBottom:12 }} ref={createRef}> <button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}>
<button className="newchat-btn" onClick={() => setCreateOpen(v=>!v)} style={{ width:'100%', justifyContent:'center', gap:8 }}> Create Event
Create Event {isToolManager && <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg>}
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="6 9 12 15 18 9"/></svg> </button>
</button> {createOpen && (
{createOpen && ( <div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}>
<div style={{ position:'absolute', top:'100%', left:0, right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', marginTop:4, boxShadow:'0 4px 16px rgba(0,0,0,0.18)' }}> {[
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], ['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
...(isToolManager ? [
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], ['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}] ['Bulk Event Import', ()=>{setPanel('bulkImport');setCreateOpen(false);}],
].map(([label,action])=>( ] : []),
<button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',textAlign:'left',fontSize:14,background:'none',border:'none',cursor:'pointer',color:'var(--text-primary)'}} ].map(([label,action])=>(
onMouseEnter={e=>e.currentTarget.style.background='var(--background)'} onMouseLeave={e=>e.currentTarget.style.background=''}>{label}</button> <button key={label} onClick={action} style={{display:'block',width:'100%',padding:'9px 16px',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>
</div> ))}
)} </div>
</div> )}
)} </div>
</div> </div>
{/* Mini calendar */} {/* Mini calendar */}
@@ -1587,21 +1967,21 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
eventDates={eventDates} eventDates={eventDates}
onMonthChange={(dir, exactDate) => { onMonthChange={(dir, exactDate) => {
if(exactDate) { setSelDate(exactDate); } if(exactDate) { setSelDate(exactDate); }
else { const d=new Date(selDate); d.setMonth(d.getMonth()+dir); d.setDate(1); setSelDate(d); } else { const d=new Date(selDate); d.setDate(1); d.setMonth(d.getMonth()+dir); setFilterFromDate(null); setSelDate(d); }
}} /> }} />
)} )}
{/* Calendar or panel content */} {/* Calendar or panel content */}
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow: view==='month' && panel==='calendar' ? 'hidden' : (panel==='eventForm'?'auto':'auto'), overflowX: panel==='eventForm'?'auto':'hidden' }}> <div ref={contentRef} style={{ flex:1, display:'flex', flexDirection:'column', overflow: view==='month' && panel==='calendar' ? 'hidden' : (panel==='eventForm'?'auto':'auto'), overflowX: panel==='eventForm'?'auto':'hidden' }}>
{panel === 'calendar' && view === 'schedule' && <div style={{paddingBottom: isMobile ? 80 : 0}}><ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId} filterAvailability={filterAvailability} filterFromDate={filterFromDate} isMobile={isMobile}/></div>} {panel === 'calendar' && view === 'schedule' && <div style={{paddingBottom: isMobile ? 80 : 0}}><ScheduleView events={events} selectedDate={selDate} onSelect={openDetail} filterKeyword={filterKeyword} filterTypeId={filterTypeId} filterAvailability={filterAvailability} filterFromDate={filterFromDate} isMobile={isMobile}/></div>}
{panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail} onSwipe={isMobile ? dir => { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>} {panel === 'calendar' && view === 'day' && <DayView events={events} selectedDate={selDate} onSelect={openDetail} onSwipe={isMobile ? dir => { const d=new Date(selDate); d.setDate(d.getDate()+dir); setSelDate(d); } : undefined}/>}
{panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>} {panel === 'calendar' && view === 'week' && <WeekView events={events} selectedDate={selDate} onSelect={openDetail}/>}
{panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>} {panel === 'calendar' && view === 'month' && <MonthView events={events} selectedDate={selDate} onSelect={openDetail} onSelectDay={d=>{setSelDate(d);setView('day');}}/>}
{panel === 'eventForm' && isToolManager && !isMobile && ( {panel === 'eventForm' && !isMobile && (
<div style={{ padding:28, maxWidth:1024 }}> <div style={{ padding:28, maxWidth:1024 }}>
<h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2> <h2 style={{ fontSize:17, fontWeight:700, marginBottom:24 }}>{editingEvent?'Edit Event':'New Event'}</h2>
<EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager} <EventForm event={editingEvent} userGroups={userGroups} eventTypes={eventTypes} selectedDate={selDate} isToolManager={isToolManager} userId={user.id}
onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/> onSave={handleSaved} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onDelete={handleDelete}/>
</div> </div>
)} )}
@@ -1634,13 +2014,19 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
)} )}
</div> </div>
{/* Delete confirmation modals */}
{deleteTarget && deleteTarget.recurrence_rule?.freq
? <RecurringChoiceModal title="Delete recurring event" onConfirm={doDelete} onCancel={()=>setDeleteTarget(null)}/>
: deleteTarget && <ConfirmModal title="Delete event" message={`Delete "${deleteTarget.title}"?`} onConfirm={()=>doDelete('this')} onCancel={()=>setDeleteTarget(null)}/>
}
{/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */} {/* Fixed overlays — position:fixed so they escape layout, can live anywhere in tree */}
{isMobile && mobilePanel === 'groupManager' && ( {isMobile && mobilePanel === 'groupManager' && (
<div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}> <div style={{ position:'fixed',inset:0,zIndex:50,background:'var(--background)' }}>
<MobileGroupManager onClose={() => setMobilePanel(null)}/> <MobileGroupManager onClose={() => setMobilePanel(null)}/>
</div> </div>
)} )}
{panel === 'eventForm' && isToolManager && isMobile && ( {panel === 'eventForm' && isMobile && (
<div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}> <div style={{ position:'fixed', top:0, left:0, right:0, bottom:0, zIndex:40, background:'var(--background)', display:'flex', flexDirection:'column' }}>
<MobileEventForm <MobileEventForm
event={editingEvent} event={editingEvent}
@@ -1648,6 +2034,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
eventTypes={eventTypes} eventTypes={eventTypes}
selectedDate={selDate} selectedDate={selDate}
isToolManager={isToolManager} isToolManager={isToolManager}
userId={user.id}
onSave={handleSaved} onSave={handleSaved}
onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}} onCancel={()=>{setPanel('calendar');setEditingEvent(null);setFilterKeyword('');setFilterTypeId('');}}
onDelete={handleDelete} /> onDelete={handleDelete} />
@@ -1655,14 +2042,17 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
)} )}
{/* Mobile FAB — same position as Messages newchat-fab */} {/* Mobile FAB — same position as Messages newchat-fab */}
{isMobile && isToolManager && panel === 'calendar' && ( {isMobile && panel === 'calendar' && (
<div ref={createRef} style={{ position:'fixed', bottom:80, right:16, zIndex:30 }}> <div ref={createRef} style={{ position:'fixed', bottom:'calc(80px + env(safe-area-inset-bottom, 0px))', right:16, zIndex:30 }}>
<button className="newchat-fab" style={{ position:'static' }} onClick={() => setCreateOpen(v=>!v)}> <button className="newchat-fab" style={{ position:'static' }} onClick={() => {
if (isToolManager) { setCreateOpen(v=>!v); }
else { setPanel('eventForm'); setEditingEvent(null); setFilterKeyword(''); setFilterTypeId(''); }
}}>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" width="24" height="24">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" /> <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg> </svg>
</button> </button>
{createOpen && ( {isToolManager && createOpen && (
<div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}> <div style={{ position:'absolute', bottom:'calc(100% + 8px)', right:0, zIndex:100, background:'var(--surface-variant)', border:'1px solid var(--border)', borderRadius:'var(--radius)', boxShadow:'0 -4px 16px rgba(0,0,0,0.15)', minWidth:180 }}>
{[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], {[['Event', ()=>{setPanel('eventForm');setEditingEvent(null);setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}], ['Event Type', ()=>{setPanel('eventTypes');setCreateOpen(false);setFilterKeyword('');setFilterTypeId('');}],
@@ -1680,6 +2070,7 @@ export default function SchedulePage({ isToolManager, isMobile, onProfile, onHel
<EventDetailModal <EventDetailModal
event={detailEvent} event={detailEvent}
isToolManager={isToolManager} isToolManager={isToolManager}
userId={user.id}
onClose={() => setDetailEvent(null)} onClose={() => setDetailEvent(null)}
onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }} onEdit={() => { setEditingEvent(detailEvent); setPanel('eventForm'); setDetailEvent(null); }}
onAvailabilityChange={(resp) => { onAvailabilityChange={(resp) => {

View File

@@ -10,6 +10,94 @@ const APP_TYPES = {
'RosterChirp-Team': { label: 'RosterChirp-Team', desc: 'Chat, Branding, Group Manager and Schedule Manager.' }, '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 ─────────────────────────────────────────────────────── // ── Team Management Tab ───────────────────────────────────────────────────────
function TeamManagementTab() { function TeamManagementTab() {
const toast = useToast(); const toast = useToast();
@@ -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 ────────────────────────────────────────────────────────── // ── Registration Tab ──────────────────────────────────────────────────────────
function RegistrationTab({ onFeaturesChanged }) { function RegistrationTab({ onFeaturesChanged }) {
const toast = useToast(); const toast = useToast();
@@ -153,7 +359,7 @@ function RegistrationTab({ onFeaturesChanged }) {
<div style={{ marginBottom: 16 }}> <div style={{ marginBottom: 16 }}>
<div className="settings-section-label">Serial Number</div> <div className="settings-section-label">Serial Number</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 6 }}> <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 }}> <button className="btn btn-secondary btn-sm" onClick={handleCopySerial} style={{ flexShrink: 0 }}>
{copied ? '✓ Copied' : ( {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> <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 className="settings-section-label">Registration Code</div>
<div style={{ display: 'flex', gap: 8, marginTop: 6 }}> <div style={{ display: 'flex', gap: 8, marginTop: 6 }}>
<input className="input flex-1" placeholder="Enter registration code" value={regCode} <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}> <button className="btn btn-primary btn-sm" onClick={handleRegister} disabled={regLoading}>
{regLoading ? '…' : 'Register'} {regLoading ? '…' : 'Register'}
</button> </button>
@@ -185,137 +391,15 @@ function RegistrationTab({ onFeaturesChanged }) {
)} )}
<p style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 16, lineHeight: 1.5 }}> <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 /> Registration codes unlock application features. Contact your RosterChirp provider for a code.
<strong>RosterChirp-Brand</strong> unlocks Branding.&nbsp;
<strong>RosterChirp-Team</strong> unlocks Branding, Group Manager and Schedule Manager.
</p> </p>
</div> </div>
); );
} }
// ── Push Debug Tab ────────────────────────────────────────────────────────────
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>
);
}
function PushDebugTab() {
const toast = useToast();
const [debugData, setDebugData] = useState(null);
const [loading, setLoading] = useState(true);
const [testing, setTesting] = useState(false);
const permission = (typeof Notification !== 'undefined') ? Notification.permission : 'unsupported';
const cachedToken = localStorage.getItem('rc_fcm_token');
const load = async () => {
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(); }, []);
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');
toast('Cached token cleared — reload to re-register with server', '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>
<div className="settings-section-label" style={{ marginBottom: 14 }}>Push Notification Debug</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'} />
<DebugRow label="Cached FCM token" value={cachedToken ? cachedToken.slice(0, 36) + '…' : 'None'} ok={!!cachedToken} bad={!cachedToken} />
{debugData && <DebugRow label="FCM env vars" value={debugData.fcmConfigured ? 'Present' : 'Missing'} ok={debugData.fcmConfigured} bad={!debugData.fcmConfigured} />}
{debugData && <DebugRow label="Firebase Admin" value={debugData.firebaseAdminReady ? 'Ready' : 'Not ready'} ok={debugData.firebaseAdminReady} bad={!debugData.firebaseAdminReady} />}
</div>
<button className="btn btn-sm btn-secondary" onClick={clearToken}>Clear cached token</button>
</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 */}
<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>
);
}
// ── Main modal ──────────────────────────────────────────────────────────────── // ── Main modal ────────────────────────────────────────────────────────────────
export default function SettingsModal({ onClose, onFeaturesChanged }) { export default function SettingsModal({ onClose, onFeaturesChanged }) {
const [tab, setTab] = useState('registration'); const [tab, setTab] = useState('login-type');
const [appType, setAppType] = useState('RosterChirp-Chat'); const [appType, setAppType] = useState('RosterChirp-Chat');
useEffect(() => { useEffect(() => {
@@ -329,12 +413,6 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
const isTeam = appType === 'RosterChirp-Team'; const isTeam = appType === 'RosterChirp-Team';
const tabs = [
isTeam && { id: 'team', label: 'Team Management' },
{ id: 'registration', label: 'Registration' },
{ id: 'pushdebug', label: 'Push Debug' },
].filter(Boolean);
return ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 520 }}> <div className="modal" style={{ maxWidth: 520 }}>
@@ -345,18 +423,21 @@ export default function SettingsModal({ onClose, onFeaturesChanged }) {
</button> </button>
</div> </div>
{/* Tab buttons */} {/* Select navigation */}
<div className="flex gap-2" style={{ marginBottom: 24 }}> <div style={{ marginBottom: 24 }}>
{tabs.map(t => ( <label className="text-sm" style={{ color: 'var(--text-tertiary)', display: 'block', marginBottom: 4 }}>SELECT OPTION:</label>
<button key={t.id} className={`btn btn-sm ${tab === t.id ? 'btn-primary' : 'btn-secondary'}`} onClick={() => setTab(t.id)}> <select className="input" value={tab} onChange={e => setTab(e.target.value)}>
{t.label} <option value="login-type">Login Type</option>
</button> <option value="messages">Messages</option>
))} {isTeam && <option value="team">Tools</option>}
<option value="registration">Registration</option>
</select>
</div> </div>
{tab === 'messages' && <MessagesTab />}
{tab === 'team' && <TeamManagementTab />} {tab === 'team' && <TeamManagementTab />}
{tab === 'login-type' && <LoginTypeTab />}
{tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />} {tab === 'registration' && <RegistrationTab onFeaturesChanged={onFeaturesChanged} />}
{tab === 'pushdebug' && <PushDebugTab />}
</div> </div>
</div> </div>
); );

View File

@@ -74,7 +74,7 @@
/* Mobile FAB */ /* Mobile FAB */
.newchat-fab { .newchat-fab {
position: absolute; position: absolute;
bottom: 80px; bottom: calc(80px + env(safe-area-inset-bottom, 0px));
right: 16px; right: 16px;
width: 52px; width: 52px;
height: 52px; height: 52px;
@@ -181,7 +181,7 @@
.footer-menu { .footer-menu {
position: absolute; position: absolute;
bottom: 68px; bottom: calc(68px + env(safe-area-inset-bottom, 0px));
left: 8px; left: 8px;
right: 8px; right: 8px;
background: white; background: white;

View File

@@ -13,6 +13,75 @@ function nameToColor(name) {
return AVATAR_COLORS[(name || '').charCodeAt(0) % AVATAR_COLORS.length]; 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() { function useAppSettings() {
const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' }); const [settings, setSettings] = useState({ app_name: 'rosterchirp', logo_url: '', color_avatar_public: '', color_avatar_dm: '' });
const fetchSettings = () => { const fetchSettings = () => {
@@ -55,6 +124,12 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const toast = useToast(); const toast = useToast();
const settings = useAppSettings(); 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 = [ const allGroups = [
...(groups.publicGroups || []), ...(groups.publicGroups || []),
...(groups.privateGroups || []) ...(groups.privateGroups || [])
@@ -62,8 +137,18 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
const publicFiltered = allGroups.filter(g => g.type === 'public'); const publicFiltered = allGroups.filter(g => g.type === 'public');
// In groupMessagesMode show only managed groups; on main Messages hide managed groups // 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) => { // 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 && !b.last_message_at) return 0;
if (!a.last_message_at) return 1; if (!a.last_message_at) return 1;
if (!b.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> <div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>MG</div>
) : group.is_managed ? ( ) : group.is_managed ? (
<div className="group-icon" style={{ background: settings.color_avatar_dm || '#a142f4', borderRadius: 8, fontSize: 11, fontWeight: 700 }}>UG</div> <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') }}> <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()} {group.type === 'public' ? '#' : group.name[0]?.toUpperCase()}
@@ -150,7 +237,7 @@ export default function Sidebar({ groups, activeGroupId, onSelectGroup, notifica
</div> </div>
<div className="groups-list"> <div className="groups-list">
{!groupMessagesMode && publicFiltered.length > 0 && ( {!groupMessagesMode && msgPublic && publicFiltered.length > 0 && (
<div className="group-section"> <div className="group-section">
<div className="section-label">PUBLIC MESSAGES</div> <div className="section-label">PUBLIC MESSAGES</div>
{publicFiltered.map(g => <GroupItem key={g.id} group={g} />)} {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 && ( {groupMessagesMode && privateFiltered.length > 0 && (
<div className="group-section"> <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} />)} {privateFiltered.map(g => <GroupItem key={g.id} group={g} />)}
</div> </div>
)} )}

View File

@@ -1,5 +1,7 @@
import { useState, useRef, useEffect } from 'react'; import { useState, useRef, useEffect } from 'react';
import { useAuth } from '../contexts/AuthContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx';
import { api } from '../utils/api.js';
import Avatar from './Avatar.jsx'; import Avatar from './Avatar.jsx';
function useTheme() { function useTheme() {
@@ -11,12 +13,255 @@ function useTheme() {
return [dark, setDark]; 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 }) { export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=false }) {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const [showMenu, setShowMenu] = useState(false); const [showMenu, setShowMenu] = useState(false);
const [dark, setDark] = useTheme(); const [dark, setDark] = useTheme();
const { supported: showPushToggle, enabled: pushEnabled, toggle: togglePush } = usePushToggle();
const menuRef = useRef(null); const menuRef = useRef(null);
const btnRef = useRef(null); const btnRef = useRef(null);
const [showConfirm, setShowConfirm] = useState(false);
const [showTestNotif, setShowTestNotif] = useState(false);
useEffect(() => { useEffect(() => {
if (!showMenu) return; if (!showMenu) return;
@@ -32,6 +277,12 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
const handleLogout = async () => { await logout(); }; const handleLogout = async () => { await logout(); };
const handleToggleConfirm = () => {
togglePush();
setShowConfirm(false);
setShowMenu(false);
};
if (mobileCompact) return ( if (mobileCompact) return (
<div style={{ position:'relative' }}> <div style={{ position:'relative' }}>
<button ref={btnRef} onClick={() => setShowMenu(!showMenu)} style={{ background:'none',border:'none',cursor:'pointer',padding:2,display:'flex',alignItems:'center' }}> <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)' }} <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> 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)' }}> <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> <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>
</div> </div>
)} )}
{showConfirm && <ConfirmToggleModal enabling={!pushEnabled} onConfirm={handleToggleConfirm} onCancel={() => setShowConfirm(false)} />}
{showTestNotif && <TestNotificationsModal onClose={() => setShowTestNotif(false)} />}
</div> </div>
); );
@@ -84,6 +350,12 @@ export default function UserFooter({ onProfile, onHelp, onAbout, mobileCompact=f
{showMenu && ( {showMenu && (
<div ref={menuRef} className="footer-menu"> <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?.(); }}> <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> <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 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> <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 About
</button> </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' }} /> <hr className="divider" style={{ margin: '4px 0' }} />
<button className="footer-menu-item danger" onClick={handleLogout}> <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> <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> </button>
</div> </div>
)} )}
{showConfirm && <ConfirmToggleModal enabling={!pushEnabled} onConfirm={handleToggleConfirm} onCancel={() => setShowConfirm(false)} />}
{showTestNotif && <TestNotificationsModal onClose={() => setShowTestNotif(false)} />}
</div> </div>
); );
} }

View File

@@ -290,6 +290,9 @@ export default function UserManagerModal({ onClose }) {
return ( return (
<div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}> <div className="modal-overlay" onClick={e => e.target === e.currentTarget && onClose()}>
<div className="modal" style={{ maxWidth: 600, width: '100%' }}> <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 }}> <div className="flex items-center justify-between" style={{ marginBottom: 20 }}>
<h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2> <h2 className="modal-title" style={{ margin: 0 }}>User Manager</h2>
<button className="btn-icon" onClick={onClose}> <button className="btn-icon" onClick={onClose}>
@@ -421,6 +424,7 @@ export default function UserManagerModal({ onClose }) {
)} )}
</div> </div>
)} )}
</form>
</div> </div>
</div> </div>
); );

View File

@@ -51,6 +51,18 @@ a { color: inherit; text-decoration: none; }
/* Focus */ /* Focus */
:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } :focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
/* Auto-fill styling — prevents browser yellow/blue override from breaking the theme */
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 from zooming in on focused inputs (requires font-size >= 16px) */
@media (max-width: 768px) {
input:focus, textarea:focus, select:focus { font-size: 16px !important; }
}
/* Utils */ /* Utils */
.flex { display: flex; } .flex { display: flex; }
.flex-col { display: flex; flex-direction: column; } .flex-col { display: flex; flex-direction: column; }

View File

@@ -6,8 +6,15 @@ import './index.css';
// Register service worker // Register service worker
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js') navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(reg => console.log('[SW] Registered, scope:', reg.scope)) .then(reg => {
console.log('[SW] Registered, scope:', reg.scope);
// iOS aggressively HTTP-caches sw.js — force a check whenever the app
// becomes visible so updates are picked up without a full cold launch.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') reg.update().catch(() => {});
});
})
.catch(err => console.error('[SW] Registration failed:', err)); .catch(err => console.error('[SW] Registration failed:', err));
}); });
} }
@@ -59,7 +66,9 @@ if ('serviceWorker' in navigator) {
document.addEventListener('touchmove', function (e) { document.addEventListener('touchmove', function (e) {
if (e.touches.length === 2 && pinchStartDist !== null) { if (e.touches.length === 2 && pinchStartDist !== null) {
// Two-finger pinch: scale fonts, not viewport // Two-finger pinch: scale fonts, not viewport.
// Skip when a lightbox is open — let the browser handle pinch natively there.
if (document.documentElement.dataset.lightboxOpen) return;
e.preventDefault(); e.preventDefault();
const ratio = getTouchDist(e) / pinchStartDist; const ratio = getTouchDist(e) / pinchStartDist;
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchStartScale * ratio)); const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchStartScale * ratio));
@@ -88,11 +97,38 @@ if ('serviceWorker' in navigator) {
document.addEventListener('touchend', function (e) { document.addEventListener('touchend', function (e) {
if (e.touches.length < 2 && pinchStartDist !== null) { if (e.touches.length < 2 && pinchStartDist !== null) {
pinchStartDist = null; pinchStartDist = null;
localStorage.setItem(LS_KEY, currentScale); // Pinch zoom is session-only — do NOT persist to localStorage.
// The saved (slider) scale is only written by ProfileModal.
} }
}, { passive: true }); }, { passive: true });
})(); })();
// ─── iOS virtual keyboard layout fix ────────────────────────────────────────
// iOS Safari/PWA ignores `interactive-widget=resizes-content` and instead
// scrolls the page when the keyboard opens, causing two bugs:
// 1. The chat header scrolls off-screen ("NO MESSAGE TITLE")
// 2. env(safe-area-inset-bottom) stays at ~34px, adding extra padding below input
//
// Fix: track the Visual Viewport height and expose it as --visual-viewport-height
// so .chat-layout always fills exactly the visible area above the keyboard.
// Also toggle a `keyboard-open` class so CSS can remove the safe-area padding
// from the message input (the keyboard covers the home indicator area anyway).
if (window.visualViewport) {
const onViewportChange = () => {
const vv = window.visualViewport;
document.documentElement.style.setProperty('--visual-viewport-height', `${vv.height}px`);
// Expose the visual viewport's vertical offset so .chat-layout can stay
// pinned to the visible area even when iOS scrolls the page on keyboard open.
document.documentElement.style.setProperty('--visual-viewport-offset', `${vv.offsetTop}px`);
// window.innerHeight doesn't shrink on iOS when keyboard opens — the gap IS the keyboard.
const keyboardVisible = (window.innerHeight - vv.height) > 150;
document.documentElement.classList.toggle('keyboard-open', keyboardVisible);
};
window.visualViewport.addEventListener('resize', onViewportChange);
window.visualViewport.addEventListener('scroll', onViewportChange);
onViewportChange(); // set immediately so first render uses the correct height
}
// Clear badge count when user focuses the app // Clear badge count when user focuses the app
window.addEventListener('focus', () => { window.addEventListener('focus', () => {
if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {}); if (navigator.clearAppBadge) navigator.clearAppBadge().catch(() => {});

View File

@@ -41,7 +41,7 @@ export default function ChangePassword() {
<form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <form onSubmit={submit} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>Current Password</label>
<PasswordInput value={current} onChange={e => setCurrent(e.target.value)} autoComplete="new-password" required /> <PasswordInput value={current} onChange={e => setCurrent(e.target.value)} autoComplete="current-password" required />
</div> </div>
<div className="flex-col gap-1"> <div className="flex-col gap-1">
<label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label> <label className="text-sm font-medium" style={{ color: 'var(--text-secondary)' }}>New Password</label>

View File

@@ -1,8 +1,8 @@
.chat-layout { .chat-layout {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh;
height: 100dvh; height: 100dvh;
height: var(--visual-viewport-height, 100dvh); /* iOS keyboard fix: shrinks to visible area above keyboard */
overflow: hidden; overflow: hidden;
background: var(--background); background: var(--background);
} }
@@ -64,7 +64,15 @@
@media (max-width: 767px) { @media (max-width: 767px) {
.chat-layout { .chat-layout {
position: relative; /* position: fixed keeps the layout pinned to the visual viewport even when
iOS scrolls the page on keyboard open. Without this, iOS scrolls the page
to bring the focused textarea into view, moving it to the top of the screen.
--visual-viewport-offset tracks the visual viewport's scrolled position so
the layout follows if iOS shifts it (usually 0, non-zero during keyboard open). */
position: fixed;
top: var(--visual-viewport-offset, 0px);
left: 0;
right: 0;
} }
.chat-body { .chat-body {
overflow: hidden; overflow: hidden;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useSocket } from '../contexts/SocketContext.jsx'; import { useSocket } from '../contexts/SocketContext.jsx';
import { useAuth } from '../contexts/AuthContext.jsx'; import { useAuth } from '../contexts/AuthContext.jsx';
import { useToast } from '../contexts/ToastContext.jsx'; import { useToast } from '../contexts/ToastContext.jsx';
@@ -16,7 +16,9 @@ import GlobalBar from '../components/GlobalBar.jsx';
import AboutModal from '../components/AboutModal.jsx'; import AboutModal from '../components/AboutModal.jsx';
import HelpModal from '../components/HelpModal.jsx'; import HelpModal from '../components/HelpModal.jsx';
import NavDrawer from '../components/NavDrawer.jsx'; import NavDrawer from '../components/NavDrawer.jsx';
import AddChildAliasModal from '../components/AddChildAliasModal.jsx';
import SchedulePage from '../components/SchedulePage.jsx'; import SchedulePage from '../components/SchedulePage.jsx';
import MobileGroupManager from '../components/MobileGroupManager.jsx';
import './Chat.css'; import './Chat.css';
function urlBase64ToUint8Array(base64String) { function urlBase64ToUint8Array(base64String) {
@@ -34,6 +36,9 @@ export default function Chat() {
const toast = useToast(); const toast = useToast();
const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] }); const [groups, setGroups] = useState({ publicGroups: [], privateGroups: [] });
// Ref so visibility/reconnect handlers always see the latest groups without
// being dependencies of the socket effect (which would cause excessive re-runs)
const groupsRef = useRef({ publicGroups: [], privateGroups: [] });
const [onlineUserIds, setOnlineUserIds] = useState(new Set()); const [onlineUserIds, setOnlineUserIds] = useState(new Set());
const [activeGroupId, setActiveGroupId] = useState(null); const [activeGroupId, setActiveGroupId] = useState(null);
const [chatHasText, setChatHasText] = useState(false); const [chatHasText, setChatHasText] = useState(false);
@@ -42,8 +47,11 @@ export default function Chat() {
const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager' const [modal, setModal] = useState(null); // 'profile' | 'users' | 'settings' | 'newchat' | 'help' | 'groupmanager'
const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages' const [page, setPage] = useState('chat'); // 'chat' | 'schedule' | 'groupmessages'
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false }); const [features, setFeatures] = useState({ branding: false, groupManager: false, scheduleManager: false, appType: 'RosterChirp-Chat', teamToolManagers: [], isHostDomain: false, msgPublic: true, msgGroup: true, msgPrivateGroup: true, msgU2U: true });
const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded const [helpDismissed, setHelpDismissed] = useState(true); // true until status loaded
const [addChildPending, setAddChildPending] = useState(false); // defer add-child popup until help closes
const addChildCheckedRef = useRef(false); // only auto-check aliases once per session
const modalRef = useRef(null); // always reflects current modal value in async callbacks
const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [showSidebar, setShowSidebar] = useState(true); const [showSidebar, setShowSidebar] = useState(true);
@@ -73,22 +81,34 @@ export default function Chat() {
useEffect(() => { loadGroups(); }, [loadGroups]); useEffect(() => { loadGroups(); }, [loadGroups]);
// Load feature flags + current user's group memberships on mount // Keep groupsRef in sync so visibility/reconnect handlers can read current groups
useEffect(() => { groupsRef.current = groups; }, [groups]);
// Load feature flags + current user's group memberships on mount (combined for consistent inGuardiansGroup)
const loadFeatures = useCallback(() => { const loadFeatures = useCallback(() => {
api.getSettings().then(({ settings }) => { Promise.all([api.getSettings(), api.getMyUserGroups()])
setFeatures(prev => ({ .then(([{ settings: s }, { userGroups }]) => {
...prev, const memberships = (userGroups || []).map(g => g.id);
branding: settings.feature_branding === 'true', const guardiansGroupId = s.feature_guardians_group_id ? parseInt(s.feature_guardians_group_id) : null;
groupManager: settings.feature_group_manager === 'true', setFeatures(prev => ({
scheduleManager: settings.feature_schedule_manager === 'true', ...prev,
appType: settings.app_type || 'RosterChirp-Chat', branding: s.feature_branding === 'true',
teamToolManagers: JSON.parse(settings.team_tool_managers || settings.team_group_managers || '[]'), groupManager: s.feature_group_manager === 'true',
isHostDomain: settings.is_host_domain === 'true', scheduleManager: s.feature_schedule_manager === 'true',
})); appType: s.app_type || 'RosterChirp-Chat',
}).catch(() => {}); teamToolManagers: JSON.parse(s.team_tool_managers || s.team_group_managers || '[]'),
api.getMyUserGroups().then(({ userGroups }) => { isHostDomain: s.is_host_domain === 'true',
setFeatures(prev => ({ ...prev, userGroupMemberships: (userGroups || []).map(g => g.id) })); msgPublic: s.feature_msg_public !== 'false',
}).catch(() => {}); msgGroup: s.feature_msg_group !== 'false',
msgPrivateGroup: s.feature_msg_private_group !== 'false',
msgU2U: s.feature_msg_u2u !== 'false',
loginType: s.feature_login_type || 'all_ages',
playersGroupId: s.feature_players_group_id ? parseInt(s.feature_players_group_id) : null,
guardiansGroupId,
userGroupMemberships: memberships,
inGuardiansGroup: guardiansGroupId ? memberships.includes(guardiansGroupId) : false,
}));
}).catch(() => {});
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -97,14 +117,104 @@ export default function Chat() {
return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures); return () => window.removeEventListener('rosterchirp:settings-changed', loadFeatures);
}, [loadFeatures]); }, [loadFeatures]);
// Register / refresh FCM push subscription // Keep modalRef in sync so async callbacks can read current modal without stale closure
useEffect(() => { modalRef.current = modal; }, [modal]);
// Auto-popup Add Child Alias modal when guardian user has no children yet
useEffect(() => {
if (addChildCheckedRef.current) return;
if (!features.inGuardiansGroup) return;
if (features.loginType !== 'guardian_only' && features.loginType !== 'mixed_age') return;
addChildCheckedRef.current = true;
api.getAliases().then(({ aliases }) => {
if (!(aliases || []).length) {
if (modalRef.current === 'help') {
setAddChildPending(true); // defer until help closes
} else if (!modalRef.current) {
setModal('addchild');
}
}
}).catch(() => {});
}, [features.loginType, features.inGuardiansGroup]);
// Close help — open deferred add-child popup if pending, or settings for first-time default admin
const handleHelpClose = useCallback(() => {
if (addChildPending) {
setAddChildPending(false);
setModal('addchild');
} else if (!helpDismissed && user?.is_default_admin && !localStorage.getItem('rosterchirp_admin_setup_shown')) {
localStorage.setItem('rosterchirp_admin_setup_shown', '1');
setModal('settings');
} else {
setModal(null);
}
}, [addChildPending, helpDismissed, user]);
// Register / refresh push subscription — FCM for Android/Chrome, Web Push for iOS
useEffect(() => { useEffect(() => {
if (!('serviceWorker' in navigator)) return; if (!('serviceWorker' in navigator)) return;
const registerPush = async () => { // Convert a URL-safe base64 string to Uint8Array for the VAPID applicationServerKey
try { function urlBase64ToUint8Array(base64String) {
if (Notification.permission === 'denied') return; const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const raw = atob(base64);
return Uint8Array.from(raw, c => c.charCodeAt(0));
}
// ── iOS / Web Push path ───────────────────────────────────────────────────
// iOS 16.4+ PWAs use the standard W3C Web Push API via pushManager.subscribe().
// FCM tokens are Google-specific and are not accepted by Apple's push service.
const registerWebPush = async () => {
try {
const configRes = await fetch('/api/push/vapid-public-key');
if (!configRes.ok) { console.warn('[Push] VAPID key not available'); return; }
const { vapidPublicKey } = await configRes.json();
const reg = await navigator.serviceWorker.ready;
// Re-use any existing subscription so we don't lose it on every page load
let subscription = await reg.pushManager.getSubscription();
if (subscription) {
// Check if it's already registered with the server
const cachedEndpoint = localStorage.getItem('rc_webpush_endpoint');
if (cachedEndpoint === subscription.endpoint) {
console.log('[Push] WebPush subscription unchanged — skipping subscribe');
return;
}
} else {
subscription = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey),
});
}
console.log('[Push] WebPush subscription obtained');
const subJson = subscription.toJSON();
const token = localStorage.getItem('tc_token') || sessionStorage.getItem('tc_token');
const subRes = await fetch('/api/push/subscribe-webpush', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ endpoint: subJson.endpoint, keys: subJson.keys }),
});
if (!subRes.ok) {
const err = await subRes.json().catch(() => ({}));
console.warn('[Push] WebPush subscribe failed:', err.error || subRes.status);
localStorage.setItem('rc_fcm_error', `WebPush subscribe failed: ${err.error || subRes.status}`);
} else {
localStorage.setItem('rc_webpush_endpoint', subJson.endpoint);
localStorage.removeItem('rc_fcm_error');
console.log('[Push] WebPush subscription registered successfully');
}
} catch (e) {
console.warn('[Push] WebPush registration failed:', e.message);
localStorage.setItem('rc_fcm_error', e.message);
}
};
// ── Android / Chrome FCM path ─────────────────────────────────────────────
const registerFCM = async () => {
try {
// Fetch Firebase config from backend (returns 503 if FCM not configured) // Fetch Firebase config from backend (returns 503 if FCM not configured)
const configRes = await fetch('/api/push/firebase-config'); const configRes = await fetch('/api/push/firebase-config');
if (!configRes.ok) return; if (!configRes.ok) return;
@@ -121,10 +231,6 @@ export default function Chat() {
const reg = await navigator.serviceWorker.ready; const reg = await navigator.serviceWorker.ready;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Do NOT call deleteToken() here. Deleting the token on every page load (or // Do NOT call deleteToken() here. Deleting the token on every page load (or
// every visibility-change) forces Chrome to create a new Web Push subscription // every visibility-change) forces Chrome to create a new Web Push subscription
// each time. During the brief window between delete and re-register the server // each time. During the brief window between delete and re-register the server
@@ -132,12 +238,22 @@ export default function Chat() {
// deliver. Passing serviceWorkerRegistration directly to getToken() is enough // deliver. Passing serviceWorkerRegistration directly to getToken() is enough
// for Firebase to return the existing valid token without needing a refresh. // for Firebase to return the existing valid token without needing a refresh.
console.log('[Push] Requesting FCM token...'); console.log('[Push] Requesting FCM token...');
const fcmToken = await getToken(firebaseMessaging, { let fcmToken;
vapidKey, try {
serviceWorkerRegistration: reg, fcmToken = await getToken(firebaseMessaging, {
}); vapidKey,
serviceWorkerRegistration: reg,
});
} catch (tokenErr) {
const msg = tokenErr.message || 'getToken() threw an error';
console.warn('[Push] getToken() threw:', msg);
localStorage.setItem('rc_fcm_error', msg);
return;
}
if (!fcmToken) { if (!fcmToken) {
console.warn('[Push] getToken() returned null — notification permission may not be granted at OS level, or VAPID key is wrong'); const msg = 'getToken() returned null — check VAPID key and OS notification permission';
console.warn('[Push]', msg);
localStorage.setItem('rc_fcm_error', msg);
return; return;
} }
console.log('[Push] FCM token obtained:', fcmToken.slice(0, 30) + '...'); console.log('[Push] FCM token obtained:', fcmToken.slice(0, 30) + '...');
@@ -147,6 +263,7 @@ export default function Chat() {
const cachedToken = localStorage.getItem('rc_fcm_token'); const cachedToken = localStorage.getItem('rc_fcm_token');
if (cachedToken === fcmToken) { if (cachedToken === fcmToken) {
console.log('[Push] Token unchanged — skipping subscribe'); console.log('[Push] Token unchanged — skipping subscribe');
localStorage.removeItem('rc_fcm_error');
return; return;
} }
@@ -158,13 +275,37 @@ export default function Chat() {
}); });
if (!subRes.ok) { if (!subRes.ok) {
const err = await subRes.json().catch(() => ({})); const err = await subRes.json().catch(() => ({}));
console.warn('[Push] Subscribe failed:', err.error || subRes.status); const msg = `Subscribe failed: ${err.error || subRes.status}`;
console.warn('[Push]', msg);
localStorage.setItem('rc_fcm_error', msg);
} else { } else {
localStorage.setItem('rc_fcm_token', fcmToken); localStorage.setItem('rc_fcm_token', fcmToken);
localStorage.removeItem('rc_fcm_error');
console.log('[Push] FCM subscription registered successfully'); console.log('[Push] FCM subscription registered successfully');
} }
} catch (e) { } catch (e) {
console.warn('[Push] FCM subscription failed:', e.message); console.warn('[Push] FCM subscription failed:', e.message);
localStorage.setItem('rc_fcm_error', e.message);
}
};
const registerPush = async () => {
try {
if (Notification.permission === 'denied') return;
// Never auto-request permission — that triggers a dialog on PWA launch.
// Permission is requested explicitly from the Notifications tab in the profile modal.
if (Notification.permission !== 'granted') return;
// Respect the user's explicit opt-out from the user menu toggle
if (localStorage.getItem('rc_push_enabled') === 'false') return;
const isIOS = /iphone|ipad/i.test(navigator.userAgent);
if (isIOS) {
await registerWebPush();
} else {
await registerFCM();
}
} catch (e) {
console.warn('[Push] registerPush failed:', e.message);
} }
}; };
@@ -173,7 +314,15 @@ export default function Chat() {
const handleVisibility = () => { const handleVisibility = () => {
if (document.visibilityState === 'visible') registerPush(); if (document.visibilityState === 'visible') registerPush();
}; };
const handlePushInit = () => registerPush(); // When the user explicitly requests push (via the Notifications toggle or
// re-register button), ask for permission if it hasn't been granted yet.
const handlePushInit = async () => {
if (typeof Notification !== 'undefined' && Notification.permission === 'default') {
const result = await Notification.requestPermission();
if (result !== 'granted') return;
}
registerPush();
};
document.addEventListener('visibilitychange', handleVisibility); document.addEventListener('visibilitychange', handleVisibility);
window.addEventListener('rosterchirp:push-init', handlePushInit); window.addEventListener('rosterchirp:push-init', handlePushInit);
return () => { return () => {
@@ -293,6 +442,11 @@ export default function Chat() {
privateGroups: prev.privateGroups.map(update), privateGroups: prev.privateGroups.map(update),
}; };
}); });
// When composite_members is updated, do a full reload so all members
// get the enriched group data (including composite) immediately.
if (group.composite_members != null) {
loadGroups();
}
}; };
// Session displaced: another login on the same device type kicked us out // Session displaced: another login on the same device type kicked us out
@@ -322,14 +476,40 @@ export default function Chat() {
socket.on('group:updated', handleGroupUpdated); socket.on('group:updated', handleGroupUpdated);
socket.on('session:displaced', handleSessionDisplaced); socket.on('session:displaced', handleSessionDisplaced);
// Bug B fix: on reconnect, reload groups to catch any messages missed while offline // On reconnect or visibility restore: reload groups AND badge any groups that
const handleReconnect = () => { loadGroups(); }; // received messages while the iOS PWA was backgrounded (socket was dead, so
// message:new events were never received — only push notifications arrived).
const checkForMissedMessages = () => {
api.getGroups().then(newGroups => {
const prev = groupsRef.current;
setGroups(newGroups);
const allPrev = [...prev.publicGroups, ...prev.privateGroups];
const allNew = [...newGroups.publicGroups, ...newGroups.privateGroups];
setUnreadGroups(prevUnread => {
const next = new Map(prevUnread);
for (const ng of allNew) {
if (ng.id === activeGroupId) continue; // currently open — no badge
if (ng.last_message_user_id === user?.id) continue; // own message
const pg = allPrev.find(g => g.id === ng.id);
const isNewer = ng.last_message_at && (
!pg?.last_message_at ||
new Date(ng.last_message_at) > new Date(pg.last_message_at)
);
if (isNewer && !next.has(ng.id)) {
next.set(ng.id, 1);
}
}
return next;
});
}).catch(() => {});
};
const handleReconnect = () => { checkForMissedMessages(); };
socket.on('connect', handleReconnect); socket.on('connect', handleReconnect);
// Bug B fix: also reload on visibility restore if socket is already connected
const handleVisibility = () => { const handleVisibility = () => {
if (document.visibilityState === 'visible' && socket.connected) { if (document.visibilityState === 'visible' && socket.connected) {
loadGroups(); checkForMissedMessages();
} }
}; };
document.addEventListener('visibilitychange', handleVisibility); document.addEventListener('visibilitychange', handleVisibility);
@@ -359,27 +539,57 @@ export default function Chat() {
setActiveGroupId(id); setActiveGroupId(id);
if (isMobile) { if (isMobile) {
setShowSidebar(false); setShowSidebar(false);
// Push a history entry so swipe-back returns to sidebar instead of exiting the app // The mount sentinel covers the first back gesture — no extra push needed here
window.history.pushState({ rosterchirpChatOpen: true }, '');
} }
// Clear notifications and unread count for this group // Clear notifications and unread count for this group
setNotifications(prev => prev.filter(n => n.groupId !== id)); setNotifications(prev => prev.filter(n => n.groupId !== id));
setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; }); setUnreadGroups(prev => { const next = new Map(prev); next.delete(id); return next; });
}; };
// Handle browser back gesture on mobile — return to sidebar instead of exiting // Establish two history entries on mount (mobile only):
// floor — marks the true exit point; always stays below the sentinel
// sentinel — intercepted by handlePopState on every back gesture
// Two entries are required so that iOS fires popstate (same-document navigation)
// before exiting, giving the handler a chance to push a new sentinel.
useEffect(() => { useEffect(() => {
const handlePopState = (e) => { if (window.innerWidth < 768) {
if (isMobile && activeGroupId) { window.history.replaceState({ rc: 'floor' }, '');
window.history.pushState({ rc: 'chat' }, '');
}
}, []);
// Handle browser back gesture on mobile — step through the navigation hierarchy:
// chat open → list view for the current page → Messages → exit app
useEffect(() => {
const handlePopState = () => {
if (!isMobile) return;
if (activeGroupId) {
// Close the open chat, stay on the current page's list (chat or groupmessages)
setShowSidebar(true); setShowSidebar(true);
setActiveGroupId(null); setActiveGroupId(null);
// Push another entry so subsequent back gestures are also intercepted setChatHasText(false);
window.history.pushState({ rosterchirpChatOpen: true }, ''); window.history.pushState({ rc: 'chat' }, '');
return;
} }
if (page !== 'chat') {
// On a secondary page (groupmessages / users / groups / schedule / hostpanel)
// — return to the default Messages page
setPage('chat');
window.history.pushState({ rc: 'chat' }, '');
return;
}
// Already at root (Messages list, no chat open) — we just popped the sentinel
// and are now on the floor entry. Step one more back so the browser exits the
// PWA (or navigates to the previous URL). Without this explicit go(-1), iOS
// leaves the user stranded on the invisible floor state.
window.history.go(-1);
}; };
window.addEventListener('popstate', handlePopState); window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState); return () => window.removeEventListener('popstate', handlePopState);
}, [isMobile, activeGroupId]); }, [isMobile, activeGroupId, page]);
// Update page title AND PWA app badge with total unread count // Update page title AND PWA app badge with total unread count
useEffect(() => { useEffect(() => {
@@ -431,13 +641,16 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} currentPage={page} isMobile={isMobile} features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} /> unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />} {modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />} {modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div> </div>
); );
} }
@@ -459,13 +672,16 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} currentPage={page} isMobile={isMobile} features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} /> unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />} {modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />} {modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div> </div>
); );
} }
@@ -517,14 +733,17 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} currentPage={page} isMobile={isMobile} features={features} currentPage={page} isMobile={isMobile}
unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} /> unreadMessages={hasUnreadChat} unreadGroupMessages={hasUnreadGroupMessages} />
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />} {modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />} {modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />} {modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); setPage('chat'); }} />}
</div> </div>
); );
} }
@@ -548,6 +767,7 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} features={features}
currentPage={page} currentPage={page}
isMobile={isMobile} isMobile={isMobile}
@@ -555,8 +775,10 @@ export default function Chat() {
{modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />} {modal === 'profile' && <ProfileModal onClose={() => setModal(null)} />}
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />} {modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
</div> </div>
); );
} }
@@ -586,6 +808,7 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} features={features}
currentPage={page} currentPage={page}
isMobile={isMobile} isMobile={isMobile}
@@ -600,7 +823,9 @@ export default function Chat() {
</div> </div>
)} )}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal onClose={() => setModal(null)} />}
</div> </div>
); );
} }
@@ -657,6 +882,7 @@ export default function Chat() {
onSettings={() => { setDrawerOpen(false); setModal('settings'); }} onSettings={() => { setDrawerOpen(false); setModal('settings'); }}
onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }} onUsers={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('users'); }}
onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }} onHostPanel={() => { setDrawerOpen(false); setActiveGroupId(null); setChatHasText(false); setPage('hostpanel'); }}
onAddChild={() => { setDrawerOpen(false); setModal('addchild'); }}
features={features} features={features}
currentPage={page} currentPage={page}
isMobile={isMobile} isMobile={isMobile}
@@ -665,9 +891,10 @@ export default function Chat() {
{modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />} {modal === 'settings' && <SettingsModal onClose={() => setModal(null)} onFeaturesChanged={setFeatures} />}
{modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />} {modal === 'branding' && <BrandingModal onClose={() => setModal(null)} />}
{modal === 'newchat' && <NewChatModal onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />} {modal === 'newchat' && <NewChatModal features={features} onClose={() => setModal(null)} onCreated={(g) => { loadGroups(); setModal(null); setActiveGroupId(g.id); }} />}
{modal === 'about' && <AboutModal onClose={() => setModal(null)} />} {modal === 'about' && <AboutModal onClose={() => setModal(null)} />}
{modal === 'help' && <HelpModal onClose={() => setModal(null)} dismissed={helpDismissed} />} {modal === 'help' && <HelpModal onClose={handleHelpClose} dismissed={helpDismissed} />}
{modal === 'addchild' && <AddChildAliasModal features={features} onClose={() => setModal(null)} />}
</div> </div>
); );
} }

View File

@@ -26,7 +26,7 @@ function UserCheckList({ allUsers, selectedIds, onChange, onIF, onIB }) {
.sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)); .sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name));
return ( return (
<div> <div>
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} autoComplete="new-password" style={{ marginBottom:8 }} autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> <input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }} onFocus={onIF} onBlur={onIB} />
<div style={{ maxHeight:220, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}> <div style={{ maxHeight:220, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
{filtered.map(u => ( {filtered.map(u => (
<label key={u.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}> <label key={u.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
@@ -43,6 +43,29 @@ function UserCheckList({ allUsers, selectedIds, onChange, onIF, onIB }) {
); );
} }
function AliasCheckList({ allAliases, selectedIds, onChange, onIF, onIB }) {
const [search, setSearch] = useState('');
const filtered = allAliases
.filter(a => `${a.first_name} ${a.last_name}`.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) => `${a.first_name} ${a.last_name}`.localeCompare(`${b.first_name} ${b.last_name}`));
return (
<div>
<input className="input" placeholder="Search aliases…" value={search} onChange={e => setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }} onFocus={onIF} onBlur={onIB} />
<div style={{ maxHeight:220, overflowY:'auto', border:'1px solid var(--border)', borderRadius:'var(--radius)' }}>
{filtered.map(a => (
<label key={a.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => { const n=new Set(selectedIds); n.has(a.id)?n.delete(a.id):n.add(a.id); onChange(n); }}
style={{ accentColor:'var(--primary)', width:15, height:15 }} />
<span className="flex-1 text-sm">{a.first_name} {a.last_name}</span>
<span className="text-xs" style={{ color:'var(--text-tertiary)' }}>{a.guardian_display_name || a.guardian_name}</span>
</label>
))}
{filtered.length === 0 && <div style={{ padding:16, textAlign:'center', color:'var(--text-tertiary)', fontSize:13 }}>No aliases found</div>}
</div>
</div>
);
}
function GroupCheckList({ allGroups, selectedIds, onChange }) { function GroupCheckList({ allGroups, selectedIds, onChange }) {
return ( return (
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:220, overflowY:'auto' }}> <div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:220, overflowY:'auto' }}>
@@ -60,13 +83,16 @@ function GroupCheckList({ allGroups, selectedIds, onChange }) {
} }
// ── All Groups tab ──────────────────────────────────────────────────────────── // ── All Groups tab ────────────────────────────────────────────────────────────
function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) { function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB, playersGroupId }) {
const toast = useToast(); const toast = useToast();
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [selected, setSelected] = useState(null); const [selected, setSelected] = useState(null);
const [savedMembers, setSavedMembers] = useState(new Set()); const [savedMembers, setSavedMembers] = useState(new Set());
const [members, setMembers] = useState(new Set()); const [members, setMembers] = useState(new Set());
const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted const [fullMembers, setFullMembers] = useState([]); // full member objects including deleted
const [aliasMembers, setAliasMembers] = useState([]); // child aliases in this group
const [allAliases, setAllAliases] = useState([]); // all aliases for players group management
const [aliasSelection, setAliasSelection] = useState(new Set()); // selected alias ids for players group
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
const [noDm, setNoDm] = useState(false); const [noDm, setNoDm] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -81,18 +107,32 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
const selectGroup = async (g) => { const selectGroup = async (g) => {
setShowDelete(false); setShowDelete(false);
setAccordionOpen(false); setAccordionOpen(false);
const { members: mems } = await api.getUserGroup(g.id); const { members: mems, aliasMembers: aliases } = await api.getUserGroup(g.id);
const ids = new Set(mems.map(m => m.id)); const ids = new Set(mems.map(m => m.id));
setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids); setSelected(g); setEditName(g.name); setMembers(ids); setSavedMembers(ids);
setFullMembers(mems); setFullMembers(mems);
setAliasMembers(aliases || []);
// No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked // No DM → checkbox enabled+checked; has DM → checkbox disabled+unchecked
setNoDm(!g.dm_group_id); setNoDm(!g.dm_group_id);
// Players group: load all aliases for alias-based membership management
if (playersGroupId && g.id === playersGroupId) {
api.getAllAliases().then(({ aliases: all }) => {
setAllAliases(all || []);
setAliasSelection(new Set((aliases || []).map(a => a.id)));
}).catch(() => {});
} else {
setAllAliases([]);
setAliasSelection(new Set());
}
}; };
const clearSelection = () => { const clearSelection = () => {
setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set()); setSelected(null); setEditName(''); setMembers(new Set()); setSavedMembers(new Set());
setShowDelete(false); setFullMembers([]); setNoDm(false); setShowDelete(false); setFullMembers([]); setAliasMembers([]); setNoDm(false);
setAllAliases([]); setAliasSelection(new Set());
}; };
const isPlayersGroup = !!(playersGroupId && selected?.id === playersGroupId);
const handleSave = async () => { const handleSave = async () => {
if (!editName.trim()) return toast('Name required', 'error'); if (!editName.trim()) return toast('Name required', 'error');
setSaving(true); setSaving(true);
@@ -100,11 +140,18 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
if (selected) { if (selected) {
// createDm=true when the group has no DM and the user unchecked "Do not create Group DM" // createDm=true when the group has no DM and the user unchecked "Do not create Group DM"
const createDm = !selected.dm_group_id && !noDm; const createDm = !selected.dm_group_id && !noDm;
const { group: updated } = await api.updateUserGroup(selected.id, { name: editName.trim(), memberIds: [...members], createDm }); const body = isPlayersGroup
? { name: editName.trim(), memberIds: [], aliasMemberIds: [...aliasSelection], createDm }
: { name: editName.trim(), memberIds: [...members], createDm };
const { group: updated } = await api.updateUserGroup(selected.id, body);
toast('Group updated', 'success'); toast('Group updated', 'success');
const { members: fresh } = await api.getUserGroup(selected.id); const { members: fresh, aliasMembers: freshAliases } = await api.getUserGroup(selected.id);
const freshIds = new Set(fresh.map(m => m.id)); const freshIds = new Set(fresh.map(m => m.id));
setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); setSavedMembers(freshIds); setMembers(freshIds); setFullMembers(fresh); setAliasMembers(freshAliases || []);
if (isPlayersGroup) {
setAliasSelection(new Set((freshAliases || []).map(a => a.id)));
setAllAliases(prev => prev); // keep existing list
}
// Reflect new dm_group_id if a DM was just created // Reflect new dm_group_id if a DM was just created
setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id })); setSelected(prev => ({ ...prev, name: editName.trim(), dm_group_id: updated?.dm_group_id ?? prev.dm_group_id }));
if (createDm) setNoDm(false); if (createDm) setNoDm(false);
@@ -201,7 +248,7 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
<div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth: isMobile ? '100%' : 520 }}> <div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth: isMobile ? '100%' : 520 }}>
<div> <div>
<label className="settings-section-label">Group Name</label> <label className="settings-section-label">Group Name</label>
<input className="input" value={editName} onChange={e => setEditName(e.target.value)} autoComplete="new-password" placeholder="e.g. Coaches" style={{ marginTop:6 }} autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> <input className="input" value={editName} onChange={e => setEditName(e.target.value)} autoComplete="off" placeholder="e.g. Coaches" style={{ marginTop:6 }} onFocus={onIF} onBlur={onIB} />
{isCreating && !noDm && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>A matching Direct Message group will be created automatically.</p>} {isCreating && !noDm && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>A matching Direct Message group will be created automatically.</p>}
<label style={{ display:'flex', alignItems:'center', gap:8, marginTop:8, cursor: (selected && selected.dm_group_id) ? 'not-allowed' : 'pointer', opacity: (selected && selected.dm_group_id) ? 0.5 : 1 }}> <label style={{ display:'flex', alignItems:'center', gap:8, marginTop:8, cursor: (selected && selected.dm_group_id) ? 'not-allowed' : 'pointer', opacity: (selected && selected.dm_group_id) ? 0.5 : 1 }}>
<input <input
@@ -216,10 +263,32 @@ function AllGroupsTab({ allUsers, onRefresh, isMobile = false, onIF, onIB }) {
{selected && selected.dm_group_id && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:4 }}>Group DM already exists cannot be removed.</p>} {selected && selected.dm_group_id && <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:4 }}>Group DM already exists cannot be removed.</p>}
</div> </div>
<div> <div>
<label className="settings-section-label">Members</label> <label className="settings-section-label">{isPlayersGroup ? 'Child Aliases' : 'Members'}</label>
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div> {isPlayersGroup ? (
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p> <div style={{ marginTop:6 }}>
<AliasCheckList allAliases={allAliases} selectedIds={aliasSelection} onChange={setAliasSelection} onIF={onIF} onIB={onIB} />
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{aliasSelection.size} selected</p>
</div>
) : (
<>
<div style={{ marginTop:6 }}><UserCheckList allUsers={allUsers} selectedIds={members} onChange={setMembers} onIF={onIF} onIB={onIB} /></div>
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:5 }}>{members.size} selected</p>
</>
)}
</div> </div>
{!isPlayersGroup && aliasMembers.length > 0 && (
<div>
<label className="settings-section-label">Child Aliases</label>
<div style={{ marginTop:6, border:'1px solid var(--border)', borderRadius:'var(--radius)', overflow:'hidden' }}>
{aliasMembers.map((a, i) => (
<div key={a.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'8px 12px', borderBottom: i < aliasMembers.length - 1 ? '1px solid var(--border)' : 'none' }}>
<span style={{ flex:1, fontSize:13 }}>{a.name}</span>
{a.date_of_birth && <span style={{ fontSize:11, color:'var(--text-tertiary)' }}>{a.date_of_birth.slice(0,10)}</span>}
</div>
))}
</div>
</div>
)}
{deletedMembers.length > 0 && ( {deletedMembers.length > 0 && (
<div> <div>
<label className="settings-section-label" style={{ color:'var(--error)' }}> <label className="settings-section-label" style={{ color:'var(--error)' }}>
@@ -376,7 +445,7 @@ function DirectMessagesTab({ allUserGroups, onRefresh, refreshKey, isMobile = fa
<div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth: isMobile ? '100%' : 520 }}> <div style={{ display:'flex', flexDirection:'column', gap:18, maxWidth: isMobile ? '100%' : 520 }}>
<div> <div>
<label className="settings-section-label">DM Name</label> <label className="settings-section-label">DM Name</label>
<input className="input" value={dmName} onChange={e => setDmName(e.target.value)} autoComplete="new-password" placeholder="e.g. Coaches + Players" style={{ marginTop:6 }} autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> <input className="input" value={dmName} onChange={e => setDmName(e.target.value)} autoComplete="off" placeholder="e.g. Coaches + Players" style={{ marginTop:6 }} onFocus={onIF} onBlur={onIB} />
</div> </div>
<div> <div>
<label className="settings-section-label">Member Groups</label> <label className="settings-section-label">Member Groups</label>
@@ -583,8 +652,6 @@ function U2URestrictionsTab({ allUserGroups, isMobile = false, onIF, onIB }) {
<h3 style={{ fontSize:16, fontWeight:700, margin:'0 0 6px' }}>{selectedGroup.name}</h3> <h3 style={{ fontSize:16, fontWeight:700, margin:'0 0 6px' }}>{selectedGroup.name}</h3>
<p style={{ fontSize:13, color:'var(--text-secondary)', margin:0, lineHeight:1.5 }}> <p style={{ fontSize:13, color:'var(--text-secondary)', margin:0, lineHeight:1.5 }}>
Members of <strong>{selectedGroup.name}</strong> can initiate 1-to-1 direct messages with members of all <strong>checked</strong> groups. Members of <strong>{selectedGroup.name}</strong> can initiate 1-to-1 direct messages with members of all <strong>checked</strong> groups.
Unchecking a group blocks initiation existing conversations are preserved.
Admins are always exempt. If a user is in multiple groups, the least restrictive rule applies.
</p> </p>
</div> </div>
@@ -604,8 +671,8 @@ function U2URestrictionsTab({ allUserGroups, isMobile = false, onIF, onIB }) {
Allowed Groups <span style={{ fontWeight:400, color:'var(--text-tertiary)' }}>({otherGroups.length - blockedIds.size} of {otherGroups.length} allowed)</span> Allowed Groups <span style={{ fontWeight:400, color:'var(--text-tertiary)' }}>({otherGroups.length - blockedIds.size} of {otherGroups.length} allowed)</span>
</label> </label>
<input className="input" placeholder="Search groups…" value={search} <input className="input" placeholder="Search groups…" value={search}
onChange={e => setSearch(e.target.value)} autoComplete="new-password" style={{ marginBottom:8 }} onChange={e => setSearch(e.target.value)} autoComplete="off" style={{ marginBottom:8 }}
autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> onFocus={onIF} onBlur={onIB} />
</div> </div>
{loading ? ( {loading ? (
@@ -686,13 +753,18 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
const [allUserGroups, setAllUserGroups] = useState([]); const [allUserGroups, setAllUserGroups] = useState([]);
const [refreshKey, setRefreshKey] = useState(0); const [refreshKey, setRefreshKey] = useState(0);
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const [playersGroupId, setPlayersGroupId] = useState(null);
const onIF = () => setInputFocused(true); const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false); const onIB = () => setInputFocused(false);
const onRefresh = () => setRefreshKey(k => k+1); const onRefresh = () => setRefreshKey(k => k+1);
useEffect(() => { useEffect(() => {
api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active').sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {}); api.searchUsers('').then(({ users }) => setAllUsers(users.filter(u => u.status==='active' && !u.is_default_admin).sort((a, b) => (a.display_name||a.name).localeCompare(b.display_name||b.name)))).catch(() => {});
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {}); api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a, b) => a.name.localeCompare(b.name)))).catch(() => {});
api.getSettings().then(({ settings }) => {
const pgid = (settings || []).find(s => s.key === 'feature_players_group_id')?.value;
setPlayersGroupId(pgid ? parseInt(pgid) : null);
}).catch(() => {});
}, [refreshKey]); }, [refreshKey]);
// Nav item helper — matches Schedule page style // Nav item helper — matches Schedule page style
@@ -745,7 +817,7 @@ export default function GroupManagerPage({ isMobile = false, onProfile, onHelp,
{/* Content */} {/* Content */}
<div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 0 }}> <div style={{ flex:1, display:'flex', overflow: isMobile ? 'auto' : 'hidden', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 0 }}>
{tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} onIF={onIF} onIB={onIB} />} {tab==='all' && <AllGroupsTab allUsers={allUsers} onRefresh={onRefresh} isMobile={isMobile} onIF={onIF} onIB={onIB} playersGroupId={playersGroupId} />}
{tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} onIF={onIF} onIB={onIB} />} {tab==='dm' && <DirectMessagesTab allUserGroups={allUserGroups} onRefresh={onRefresh} refreshKey={refreshKey} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
{tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} onIF={onIF} onIB={onIB} />} {tab==='u2u' && <U2URestrictionsTab allUserGroups={allUserGroups} isMobile={isMobile} onIF={onIF} onIB={onIB} />}
</div> </div>

View File

@@ -93,11 +93,11 @@ export default function Login() {
<form onSubmit={handleSubmit} className="login-form"> <form onSubmit={handleSubmit} className="login-form">
<div className="field"> <div className="field">
<label>Email</label> <label>Email</label>
<input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus placeholder="your@email.com" /> <input className="input" type="email" value={email} onChange={e => setEmail(e.target.value)} required autoFocus placeholder="your@email.com" autoComplete="email" autoCorrect="off" autoCapitalize="off" spellCheck="false" />
</div> </div>
<div className="field"> <div className="field">
<label>Password</label> <label>Password</label>
<PasswordInput value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" /> <PasswordInput value={password} onChange={e => setPassword(e.target.value)} required placeholder="••••••••" autoComplete="current-password" />
</div> </div>
<label className="remember-me"> <label className="remember-me">

View File

@@ -14,12 +14,13 @@ function isValidPhone(p) {
return /^\d{7,15}$/.test(digits); return /^\d{7,15}$/.test(digits);
} }
// Format: email,firstname,lastname,password,role,usergroup (exactly 5 commas / 6 fields) // Format: email,firstname,lastname,dob,password,role,usergroup (exactly 6 commas / 7 fields)
function parseCSV(text, ignoreFirstRow, allUserGroups) { function parseCSV(text, ignoreFirstRow, allUserGroups, loginType) {
const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean); const lines = text.split(/\r?\n/).map(l => l.trim()).filter(Boolean);
const rows = [], invalid = []; const rows = [], invalid = [];
const groupMap = new Map((allUserGroups || []).map(g => [g.name.toLowerCase(), g])); const groupMap = new Map((allUserGroups || []).map(g => [g.name.toLowerCase(), g]));
const validRoles = ['member', 'manager', 'admin']; const validRoles = ['member', 'manager', 'admin'];
const requireDob = loginType === 'mixed_age';
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const line = lines[i];
@@ -27,12 +28,13 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) {
if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue; if (i === 0 && (ignoreFirstRow || /^e-?mail$/i.test(line.split(',')[0].trim()))) continue;
const parts = line.split(','); const parts = line.split(',');
if (parts.length !== 6) { invalid.push({ line, reason: `Must have exactly 5 commas (has ${parts.length - 1})` }); continue; } if (parts.length !== 7) { invalid.push({ line, reason: `Must have exactly 6 commas (has ${parts.length - 1})` }); continue; }
const [email, firstName, lastName, password, roleRaw, usergroupRaw] = parts.map(p => p.trim()); const [email, firstName, lastName, dobRaw, password, roleRaw, usergroupRaw] = parts.map(p => p.trim());
if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email || '(blank)'}"` }); continue; } if (!email || !isValidEmail(email)) { invalid.push({ line, reason: `Invalid email: "${email || '(blank)'}"` }); continue; }
if (!firstName) { invalid.push({ line, reason: 'First name required' }); continue; } if (!firstName) { invalid.push({ line, reason: 'First name required' }); continue; }
if (!lastName) { invalid.push({ line, reason: 'Last name required' }); continue; } if (!lastName) { invalid.push({ line, reason: 'Last name required' }); continue; }
if (requireDob && !dobRaw) { invalid.push({ line, reason: 'Date of birth required in Restricted login type' }); continue; }
const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member'; const role = validRoles.includes(roleRaw.toLowerCase()) ? roleRaw.toLowerCase() : 'member';
const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null; const matchedGroup = usergroupRaw ? groupMap.get(usergroupRaw.toLowerCase()) : null;
@@ -42,6 +44,7 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) {
firstName, firstName,
lastName, lastName,
password, password,
dateOfBirth: dobRaw || null,
role, role,
userGroupId: matchedGroup?.id || null, userGroupId: matchedGroup?.id || null,
userGroupName: usergroupRaw || null, userGroupName: usergroupRaw || null,
@@ -50,6 +53,16 @@ function parseCSV(text, ignoreFirstRow, allUserGroups) {
return { rows, invalid }; return { rows, invalid };
} }
function fmtLastLogin(ts) {
if (!ts) return 'Never';
const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0);
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
const dd = new Date(d); dd.setHours(0,0,0,0);
if (dd >= today) return 'Today';
if (dd >= yesterday) return 'Yesterday';
return dd.toISOString().slice(0, 10);
}
// ── User Row (accordion list item) ─────────────────────────────────────────── // ── User Row (accordion list item) ───────────────────────────────────────────
function UserRow({ u, onUpdated, onEdit }) { function UserRow({ u, onUpdated, onEdit }) {
const toast = useToast(); const toast = useToast();
@@ -79,10 +92,11 @@ function UserRow({ u, onUpdated, onEdit }) {
<Avatar user={u} size="sm" /> <Avatar user={u} size="sm" />
<div style={{ flex:1, minWidth:0 }}> <div style={{ flex:1, minWidth:0 }}>
<div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}> <div style={{ display:'flex', alignItems:'center', gap:6, flexWrap:'wrap' }}>
<span style={{ fontWeight:600, fontSize:14 }}>{u.display_name || u.name}</span> <span style={{ fontWeight:600, fontSize:14, color: u.guardian_approval_required ? 'var(--error)' : 'var(--text-primary)' }}>{u.display_name || u.name}</span>
{u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>} {u.display_name && <span style={{ fontSize:12, color:'var(--text-tertiary)' }}>({u.name})</span>}
<span className={`role-badge role-${u.role}`}>{u.role}</span> <span className={`role-badge role-${u.role}`}>{u.role}</span>
{u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>} {u.status !== 'active' && <span className="role-badge status-suspended">{u.status}</span>}
{!!u.guardian_approval_required && <span className="role-badge" style={{ background:'var(--error)', color:'white' }}>Pending Guardian Approval</span>}
{!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>} {!!u.is_default_admin && <span className="text-xs" style={{ color:'var(--text-tertiary)' }}>Default Admin</span>}
</div> </div>
<div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div> <div style={{ fontSize:12, color:'var(--text-secondary)', marginTop:1, overflow:'hidden', textOverflow:'ellipsis', whiteSpace:'nowrap' }}>{u.email}</div>
@@ -94,15 +108,23 @@ function UserRow({ u, onUpdated, onEdit }) {
</button> </button>
{open && !u.is_default_admin && ( {open && !u.is_default_admin && (
<div style={{ padding:'6px 12px 12px', display:'flex', alignItems:'center', gap:8 }}> <div style={{ padding:'6px 12px 12px', display:'flex', flexDirection:'column', gap:8 }}>
<button className="btn btn-primary btn-sm" onClick={() => { setOpen(false); onEdit(u); }}>Edit User</button> <div style={{ display:'flex', alignItems:'center', gap:14, flexWrap:'wrap', fontSize:12, color:'var(--text-tertiary)', paddingBottom:6, borderBottom:'1px solid var(--border)' }}>
<div style={{ marginLeft:'auto', display:'flex', gap:8 }}> <span>Last Login: <strong style={{ color:'var(--text-secondary)' }}>{fmtLastLogin(u.last_online)}</strong></span>
{u.status === 'active' ? ( {!!u.must_change_password && (
<button className="btn btn-sm" style={{ background:'var(--warning)', color:'white' }} onClick={handleSuspend}>Suspend</button> <span style={{ color:'var(--warning)', fontWeight:600 }}> Must change password</span>
) : u.status === 'suspended' ? ( )}
<button className="btn btn-sm" style={{ background:'var(--success)', color:'white' }} onClick={handleActivate}>Activate</button> </div>
) : null} <div style={{ display:'flex', alignItems:'center', gap:8 }}>
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete</button> <button className="btn btn-primary btn-sm" onClick={() => { setOpen(false); onEdit(u); }}>Edit User</button>
<div style={{ marginLeft:'auto', display:'flex', gap:8 }}>
{u.status === 'active' ? (
<button className="btn btn-sm" style={{ background:'var(--warning)', color:'white' }} onClick={handleSuspend}>Suspend</button>
) : u.status === 'suspended' ? (
<button className="btn btn-sm" style={{ background:'var(--success)', color:'white' }} onClick={handleActivate}>Activate</button>
) : null}
<button className="btn btn-danger btn-sm" onClick={handleDelete}>Delete</button>
</div>
</div> </div>
</div> </div>
)} )}
@@ -111,7 +133,7 @@ function UserRow({ u, onUpdated, onEdit }) {
} }
// ── User Form (create / edit) ───────────────────────────────────────────────── // ── User Form (create / edit) ─────────────────────────────────────────────────
function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, onIF, onIB }) { function UserForm({ user, userPass, allUserGroups, nonMinorUsers, loginType, onDone, onCancel, isMobile, onIF, onIB }) {
const toast = useToast(); const toast = useToast();
const isEdit = !!user; const isEdit = !!user;
@@ -120,7 +142,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
const [email, setEmail] = useState(user?.email || ''); const [email, setEmail] = useState(user?.email || '');
const [phone, setPhone] = useState(user?.phone || ''); const [phone, setPhone] = useState(user?.phone || '');
const [role, setRole] = useState(user?.role || 'member'); const [role, setRole] = useState(user?.role || 'member');
const [dob, setDob] = useState(user?.date_of_birth || ''); const [dob, setDob] = useState(user?.date_of_birth?.slice(0, 10) || '');
const [guardianId, setGuardianId] = useState(user?.guardian_user_id || ''); const [guardianId, setGuardianId] = useState(user?.guardian_user_id || '');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [pwEnabled, setPwEnabled] = useState(!isEdit); const [pwEnabled, setPwEnabled] = useState(!isEdit);
@@ -139,16 +161,6 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
.catch(() => {}); .catch(() => {});
}, [isEdit, user?.id]); }, [isEdit, user?.id]);
const fmtLastLogin = (ts) => {
if (!ts) return 'Never';
const d = new Date(ts); const today = new Date(); today.setHours(0,0,0,0);
const yesterday = new Date(today); yesterday.setDate(yesterday.getDate() - 1);
const dd = new Date(d); dd.setHours(0,0,0,0);
if (dd >= today) return 'Today';
if (dd >= yesterday) return 'Yesterday';
return dd.toISOString().slice(0, 10);
};
const handleSubmit = async () => { const handleSubmit = async () => {
if (!isEdit && (!email.trim() || !isValidEmail(email.trim()))) if (!isEdit && (!email.trim() || !isValidEmail(email.trim())))
return toast('Valid email address required', 'error'); return toast('Valid email address required', 'error');
@@ -163,10 +175,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
try { try {
if (isEdit) { if (isEdit) {
await api.updateUser(user.id, { await api.updateUser(user.id, {
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
phone: phone.trim(), phone: phone.trim(),
role, role,
dateOfBirth: dob || undefined,
guardianUserId: guardianId || undefined,
...(pwEnabled && password ? { password } : {}), ...(pwEnabled && password ? { password } : {}),
}); });
// Sync group memberships: add newly selected, remove deselected // Sync group memberships: add newly selected, remove deselected
@@ -179,11 +193,12 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
toast('User updated', 'success'); toast('User updated', 'success');
} else { } else {
const { user: newUser } = await api.createUser({ const { user: newUser } = await api.createUser({
firstName: firstName.trim(), firstName: firstName.trim(),
lastName: lastName.trim(), lastName: lastName.trim(),
email: email.trim(), email: email.trim(),
phone: phone.trim(), phone: phone.trim(),
role, role,
dateOfBirth: dob || undefined,
...(password ? { password } : {}), ...(password ? { password } : {}),
}); });
// Add to selected groups // Add to selected groups
@@ -233,7 +248,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
value={email} onChange={e => setEmail(e.target.value)} value={email} onChange={e => setEmail(e.target.value)}
disabled={isEdit} disabled={isEdit}
style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }} style={{ width:'100%', ...(isEdit ? { opacity:0.6, cursor:'not-allowed' } : {}) }}
autoComplete="new-password" onFocus={onIF} onBlur={onIB} /> autoComplete="new-password" autoCorrect="off" autoCapitalize="off" spellCheck="false" onFocus={onIF} onBlur={onIB} />
</div> </div>
{/* Row 2: First Name + Last Name */} {/* Row 2: First Name + Last Name */}
@@ -276,24 +291,41 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
{lbl('Date of Birth', false, '(optional)')} {lbl('Date of Birth', false, '(optional)')}
<input className="input" type="text" placeholder="YYYY-MM-DD" <input className="input" type="text" placeholder="YYYY-MM-DD"
value={dob} onChange={e => setDob(e.target.value)} value={dob} onChange={e => setDob(e.target.value)}
disabled autoComplete="off" onFocus={onIF} onBlur={onIB} />
style={{ opacity:0.5, cursor:'not-allowed' }} />
</div>
<div>
{lbl('Guardian', false, '(optional)')}
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
disabled
style={{ opacity:0.5, cursor:'not-allowed' }}>
<option value=""> Select guardian </option>
</select>
</div> </div>
{/* Guardian field — shown for all login types except guardian_only (children are aliases there, not users) */}
{loginType !== 'guardian_only' && (
<div>
{lbl('Guardian', false, '(optional)')}
<div style={{ position:'relative' }}>
<select className="input" value={guardianId} onChange={e => setGuardianId(e.target.value)}
style={ user?.guardian_approval_required ? { borderColor:'var(--error)' } : {} }>
<option value=""> None </option>
{(nonMinorUsers || []).map(u => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</div>
{isEdit && user?.guardian_approval_required && (
<div style={{ display:'flex', alignItems:'center', gap:8, marginTop:6 }}>
<span style={{ fontSize:12, color:'var(--error)', fontWeight:600 }}>Pending approval</span>
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--success)', background:'none', border:'1px solid var(--success)', padding:'2px 8px', cursor:'pointer' }}
onClick={async () => { try { await api.approveGuardian(user.id); toast('Approved', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
Approve
</button>
<button className="btn btn-sm" style={{ fontSize:12, color:'var(--error)', background:'none', border:'1px solid var(--error)', padding:'2px 8px', cursor:'pointer' }}
onClick={async () => { try { await api.denyGuardian(user.id); toast('Denied', 'success'); onDone(); } catch(e) { toast(e.message,'error'); } }}>
Deny
</button>
</div>
)}
</div>
)}
</div> </div>
{/* Row 4b: User Groups */} {/* Row 4b: User Groups */}
{allUserGroups?.length > 0 && ( {allUserGroups?.length > 0 && (
<div style={{ marginBottom:12 }}> <div style={{ marginBottom:12 }}>
{lbl('User Groups', false, '(optional)')} {lbl('User Groups', false, '(optional)')}
<div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:160, overflowY:'auto', marginTop:6 }}> <div style={{ border:'1px solid var(--border)', borderRadius:'var(--radius)', maxHeight:120, overflowY:'auto', marginTop:6 }}>
{allUserGroups.map(g => ( {allUserGroups.map(g => (
<label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 10px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}> <label key={g.id} style={{ display:'flex', alignItems:'center', gap:10, padding:'7px 10px', borderBottom:'1px solid var(--border)', cursor:'pointer' }}>
<input type="checkbox" <input type="checkbox"
@@ -311,8 +343,8 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
<div style={{ marginBottom:16 }}> <div style={{ marginBottom:16 }}>
{lbl('Password', {lbl('Password',
isEdit && pwEnabled, isEdit && pwEnabled,
isEdit && !pwEnabled ? '(not changing — click Reset Password to set a new one)' : isEdit && !pwEnabled ? '(click Reset button to change)' :
!isEdit ? `(optionalblank uses system default)` : null !isEdit ? <>(optional blank uses <strong>{userPass}</strong> as default)</> : null
)} )}
<div style={{ opacity: pwEnabled ? 1 : 0.55 }}> <div style={{ opacity: pwEnabled ? 1 : 0.55 }}>
<PasswordInput <PasswordInput
@@ -358,7 +390,7 @@ function UserForm({ user, userPass, allUserGroups, onDone, onCancel, isMobile, o
} }
// ── Bulk Import Form ────────────────────────────────────────────────────────── // ── Bulk Import Form ──────────────────────────────────────────────────────────
function BulkImportForm({ userPass, allUserGroups, onCreated }) { function BulkImportForm({ userPass, allUserGroups, loginType, onCreated }) {
const toast = useToast(); const toast = useToast();
const fileRef = useRef(null); const fileRef = useRef(null);
const [csvFile, setCsvFile] = useState(null); const [csvFile, setCsvFile] = useState(null);
@@ -373,9 +405,9 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
// Re-parse whenever raw text or options change // Re-parse whenever raw text or options change
useEffect(() => { useEffect(() => {
if (!rawText) return; if (!rawText) return;
const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups); const { rows, invalid } = parseCSV(rawText, ignoreFirst, allUserGroups, loginType);
setCsvRows(rows); setCsvInvalid(invalid); setCsvRows(rows); setCsvInvalid(invalid);
}, [rawText, ignoreFirst, allUserGroups]); }, [rawText, ignoreFirst, allUserGroups, loginType]);
const handleFile = e => { const handleFile = e => {
const file = e.target.files?.[0]; if (!file) return; const file = e.target.files?.[0]; if (!file) return;
@@ -405,11 +437,11 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
{/* Format info box */} {/* Format info box */}
<div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}> <div style={{ background:'var(--background)', border:'1px dashed var(--border)', borderRadius:'var(--radius)', padding:'12px 14px' }}>
<p style={{ fontSize:13, fontWeight:600, marginBottom:8 }}>CSV Format</p> <p style={{ fontSize:13, fontWeight:600, marginBottom:8 }}>CSV Format</p>
<code style={codeStyle}>{'FULL: email,firstname,lastname,password,role,usergroup'}</code> <code style={codeStyle}>{'FULL: email,firstname,lastname,dob,password,role,usergroup'}</code>
<code style={codeStyle}>{'MINIMUM: email,firstname,lastname,,,'}</code> <code style={codeStyle}>{'MINIMUM: email,firstname,lastname,,,,'}</code>
<p style={{ fontSize:12, color:'var(--text-tertiary)', margin:'8px 0 6px' }}>Examples:</p> <p style={{ fontSize:12, color:'var(--text-tertiary)', margin:'8px 0 6px' }}>Examples:</p>
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,,member,parents'}</code> <code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,1970-11-21,,member,parents'}</code>
<code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,Ori0n2026!,member,players'}</code> <code style={codeStyle}>{'example@rosterchirp.com,Barney,Rubble,2013-06-11,Ori0n2026!,member,players'}</code>
<p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:8 }}> <p style={{ fontSize:12, color:'var(--text-tertiary)', marginTop:8 }}>
Blank password defaults to <strong>{userPass}</strong>. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV. Blank password defaults to <strong>{userPass}</strong>. Blank role defaults to member. We recommend using a spreadsheet editor and saving as CSV.
</p> </p>
@@ -425,8 +457,8 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
<div> <div>
<p style={{ fontWeight:600, marginBottom:4 }}>CSV Requirements</p> <p style={{ fontWeight:600, marginBottom:4 }}>CSV Requirements</p>
<ul style={{ paddingLeft:16, margin:0, lineHeight:1.8 }}> <ul style={{ paddingLeft:16, margin:0, lineHeight:1.8 }}>
<li>Exactly 5 commas per row (rows with more or less will be skipped)</li> <li>Exactly six (6) commas per row (rows with more or less will be skipped)</li>
<li><code>email</code>, <code>firstname</code>, <code>lastname</code> are required</li> <li><code>email</code>, <code>firstname</code>, <code>lastname</code> are required fields{loginType === 'mixed_age' ? <> (DOB field required for <strong>Restricted</strong> login type)</> : ''}.</li>
<li>A user can only be added to one group during bulk import</li> <li>A user can only be added to one group during bulk import</li>
<li>Optional fields left blank will use system defaults</li> <li>Optional fields left blank will use system defaults</li>
</ul> </ul>
@@ -434,7 +466,7 @@ function BulkImportForm({ userPass, allUserGroups, onCreated }) {
{allUserGroups?.length > 0 && ( {allUserGroups?.length > 0 && (
<div> <div>
<p style={{ fontWeight:600, marginBottom:4 }}>User Groups available</p> <p style={{ fontWeight:600, marginBottom:4 }}>User Groups available</p>
<div style={{ display:'flex', flexDirection:'column', gap:1 }}> <div style={{ display:'flex', flexDirection:'column', gap:1, paddingLeft:16 }}>
{allUserGroups.map(g => <span key={g.id} style={{ fontFamily:'monospace', fontSize:11 }}>{g.name}</span>)} {allUserGroups.map(g => <span key={g.id} style={{ fontFamily:'monospace', fontSize:11 }}>{g.name}</span>)}
</div> </div>
</div> </div>
@@ -535,6 +567,8 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
const [editUser, setEditUser] = useState(null); const [editUser, setEditUser] = useState(null);
const [userPass, setUserPass] = useState('user@1234'); const [userPass, setUserPass] = useState('user@1234');
const [allUserGroups, setAllUserGroups] = useState([]); const [allUserGroups, setAllUserGroups] = useState([]);
const [loginType, setLoginType] = useState('all_ages');
const [guardiansGroupUserIds, setGuardiansGroupUserIds] = useState(null); // null = not loaded yet
const [inputFocused, setInputFocused] = useState(false); const [inputFocused, setInputFocused] = useState(false);
const onIF = () => setInputFocused(true); const onIF = () => setInputFocused(true);
const onIB = () => setInputFocused(false); const onIB = () => setInputFocused(false);
@@ -548,7 +582,16 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
useEffect(() => { useEffect(() => {
load(); load();
api.getSettings().then(({ settings }) => { if (settings.user_pass) setUserPass(settings.user_pass); }).catch(() => {}); api.getSettings().then(({ settings }) => {
if (settings.user_pass) setUserPass(settings.user_pass);
setLoginType(settings.feature_login_type || 'all_ages');
const guardiansGroupId = settings.feature_guardians_group_id ? parseInt(settings.feature_guardians_group_id) : null;
if (guardiansGroupId) {
api.getUserGroup(guardiansGroupId)
.then(({ members }) => setGuardiansGroupUserIds(new Set((members || []).map(m => m.id))))
.catch(() => setGuardiansGroupUserIds(null));
}
}).catch(() => {});
api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {}); api.getUserGroups().then(({ groups }) => setAllUserGroups([...(groups||[])].sort((a,b) => a.name.localeCompare(b.name)))).catch(() => {});
}, [load]); }, [load]);
@@ -613,6 +656,9 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
)} )}
{/* Content */} {/* Content */}
{/* form wrapper suppresses Chrome Android's autofill chip bar; autoComplete="new-password"
on individual inputs is ignored by Chrome but respected on the form element */}
<form autoComplete="new-password" onSubmit={e => e.preventDefault()} style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0 }}>
<div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:'var(--background)' }}> <div style={{ flex:1, display:'flex', flexDirection:'column', overflow:'hidden', minHeight:0, background:'var(--background)' }}>
{/* LIST VIEW */} {/* LIST VIEW */}
@@ -621,7 +667,7 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
<div style={{ padding:'16px 16px 8px', flexShrink:0 }}> <div style={{ padding:'16px 16px 8px', flexShrink:0 }}>
<input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)} <input className="input" placeholder="Search users…" value={search} onChange={e => setSearch(e.target.value)}
onFocus={onIF} onBlur={onIB} onFocus={onIF} onBlur={onIB}
autoComplete="new-password" autoCorrect="off" spellCheck={false} autoComplete="new-password" autoCorrect="off" spellCheck={false}
style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} /> style={{ width:'100%', maxWidth: isMobile ? '100%' : 400 }} />
</div> </div>
<div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}> <div style={{ flex:1, overflowY:'auto', padding:'0 16px', paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
@@ -649,9 +695,12 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{isFormView && ( {isFormView && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}> <div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
<UserForm <UserForm
key={view === 'edit' ? editUser?.id : 'new'}
user={view === 'edit' ? editUser : null} user={view === 'edit' ? editUser : null}
userPass={userPass} userPass={userPass}
allUserGroups={allUserGroups} allUserGroups={allUserGroups}
nonMinorUsers={users.filter(u => !u.is_minor && u.status === 'active' && (guardiansGroupUserIds === null || guardiansGroupUserIds.has(u.id)))}
loginType={loginType}
onDone={() => { load(); goList(); }} onDone={() => { load(); goList(); }}
onCancel={goList} onCancel={goList}
isMobile={isMobile} isMobile={isMobile}
@@ -664,10 +713,11 @@ export default function UserManagerPage({ isMobile = false, onProfile, onHelp, o
{/* BULK IMPORT */} {/* BULK IMPORT */}
{view === 'bulk' && ( {view === 'bulk' && (
<div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}> <div style={{ flex:1, overflowY:'auto', padding:16, paddingBottom: isMobile ? 'calc(82px + env(safe-area-inset-bottom, 0px))' : 16, overscrollBehavior:'contain' }}>
<BulkImportForm userPass={userPass} allUserGroups={allUserGroups} onCreated={load} /> <BulkImportForm userPass={userPass} allUserGroups={allUserGroups} loginType={loginType} onCreated={load} />
</div> </div>
)} )}
</div> </div>
</form>
{/* Mobile footer — fixed, hidden when keyboard is up */} {/* Mobile footer — fixed, hidden when keyboard is up */}
{isMobile && !inputFocused && ( {isMobile && !inputFocused && (

View File

@@ -69,6 +69,28 @@ export const api = {
const form = new FormData(); form.append('avatar', file); const form = new FormData(); form.append('avatar', file);
return req('POST', '/users/me/avatar', form); return req('POST', '/users/me/avatar', form);
}, },
searchMinorUsers: (q) => req('GET', `/users/search-minors?q=${encodeURIComponent(q || '')}`),
getMinorPlayers: () => req('GET', '/users/minor-players'),
addGuardianChild: (minorId, dateOfBirth) => req('POST', `/users/me/guardian-children/${minorId}`, { dateOfBirth: dateOfBirth || null }),
removeGuardianChild: (minorId) => req('DELETE', `/users/me/guardian-children/${minorId}`),
approveGuardian: (id) => req('PATCH', `/users/${id}/approve-guardian`),
denyGuardian: (id) => req('PATCH', `/users/${id}/deny-guardian`),
linkMinor: (minorId) => req('PATCH', `/users/me/link-minor/${minorId}`),
// Guardian aliases
getAliases: () => req('GET', '/users/me/aliases'),
getAllAliases: () => req('GET', '/users/aliases-all'),
createAlias: (body) => req('POST', '/users/me/aliases', body),
updateAlias: (id, body) => req('PATCH', `/users/me/aliases/${id}`, body),
deleteAlias: (id) => req('DELETE', `/users/me/aliases/${id}`),
uploadAliasAvatar: (aliasId, file) => {
const form = new FormData(); form.append('avatar', file);
return req('POST', `/users/me/aliases/${aliasId}/avatar`, form);
},
// Spouse/Partner
getPartner: () => req('GET', '/users/me/partner'),
setPartner: (partnerId, respondSeparately = false) => req('POST', '/users/me/partner', { partnerId, respondSeparately }),
updatePartnerRespondSeparately: (respondSeparately) => req('PATCH', '/users/me/partner', { respondSeparately }),
removePartner: () => req('DELETE', '/users/me/partner'),
// Groups // Groups
getGroups: () => req('GET', '/groups'), getGroups: () => req('GET', '/groups'),
@@ -104,8 +126,11 @@ export const api = {
updateColors: (body) => req('PATCH', '/settings/colors', body), updateColors: (body) => req('PATCH', '/settings/colors', body),
registerCode: (code) => req('POST', '/settings/register', { code }), registerCode: (code) => req('POST', '/settings/register', { code }),
updateTeamSettings: (body) => req('PATCH', '/settings/team', body), updateTeamSettings: (body) => req('PATCH', '/settings/team', body),
updateMessageSettings: (body) => req('PATCH', '/settings/messages', body),
updateLoginType: (body) => req('PATCH', '/settings/login-type', body),
// Schedule Manager // Schedule Manager
getMyScheduleGroups: () => req('GET', '/schedule/my-groups'),
getEventTypes: () => req('GET', '/schedule/event-types'), getEventTypes: () => req('GET', '/schedule/event-types'),
createEventType: (body) => req('POST', '/schedule/event-types', body), createEventType: (body) => req('POST', '/schedule/event-types', body),
updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body), updateEventType: (id, body) => req('PATCH', `/schedule/event-types/${id}`, body),
@@ -117,9 +142,10 @@ export const api = {
getEvent: (id) => req('GET', `/schedule/${id}`), getEvent: (id) => req('GET', `/schedule/${id}`),
createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount} createEvent: (body) => req('POST', '/schedule', body), // body may include recurrenceRule: {freq,interval,byDay,ends,endDate,endCount}
updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body), updateEvent: (id, body) => req('PATCH', `/schedule/${id}`, body),
deleteEvent: (id) => req('DELETE', `/schedule/${id}`), deleteEvent: (id, scope = 'this', occurrenceStart = null) => req('DELETE', `/schedule/${id}`, { recurringScope: scope, occurrenceStart }),
setAvailability: (id, response) => req('PUT', `/schedule/${id}/availability`, { response }), setAvailability: (id, response, note, aliasId, forPartnerId) => req('PUT', `/schedule/${id}/availability`, { response, note, ...(aliasId ? { aliasId } : {}), ...(forPartnerId ? { forPartnerId } : {}) }),
deleteAvailability: (id) => req('DELETE', `/schedule/${id}/availability`), setAvailabilityNote: (id, note) => req('PATCH', `/schedule/${id}/availability/note`, { note }),
deleteAvailability: (id, aliasId, forPartnerId) => req('DELETE', `/schedule/${id}/availability${aliasId ? `?aliasId=${aliasId}` : forPartnerId ? `?forPartnerId=${forPartnerId}` : ''}`),
getPendingAvailability: () => req('GET', '/schedule/me/pending'), getPendingAvailability: () => req('GET', '/schedule/me/pending'),
bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }), bulkAvailability: (responses) => req('POST', '/schedule/me/bulk-availability', { responses }),
importPreview: (file) => { importPreview: (file) => {
@@ -163,7 +189,9 @@ export const api = {
// Push notifications (FCM) // Push notifications (FCM)
getFirebaseConfig: () => req('GET', '/push/firebase-config'), getFirebaseConfig: () => req('GET', '/push/firebase-config'),
getVapidPublicKey: () => req('GET', '/push/vapid-public-key'),
subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }), subscribePush: (fcmToken) => req('POST', '/push/subscribe', { fcmToken }),
subscribeWebPush: (subscription) => req('POST', '/push/subscribe-webpush', subscription),
unsubscribePush: () => req('POST', '/push/unsubscribe'), unsubscribePush: () => req('POST', '/push/unsubscribe'),
testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`), testPush: (mode = 'notification') => req('POST', `/push/test?mode=${mode}`),
pushDebug: () => req('GET', '/push/debug'), pushDebug: () => req('GET', '/push/debug'),

206
summary.md Normal file
View File

@@ -0,0 +1,206 @@
# Mobile UI Fixes Summary
**Date:** March 29, 2026
**Version:** 0.12.45
**Focus:** Android Chrome mobile browser issues
---
## 🎯 **Objective**
Fix mobile UI issues in RosterChirp application on Android Chrome browser:
1. Chrome autocomplete bar covering input fields
2. Calendar popup appearing when selecting end time
3. Time dropdowns being hidden by keyboard
4. Inconsistent date/time row behavior
---
## 📁 **Files Modified**
### **Frontend Components**
#### **1. `frontend/src/components/SchedulePage.jsx`**
**Changes Made:**
- **TimeInput component:** Added `inputMode="text"` and `enterKeyHint="done"` to prevent calendar popup
- **Form wrapper:** Changed from `autoComplete="new-password"` to `autoComplete="off"` for standard behavior
- **Dropdown positioning:** Increased z-index from 300 to 9999 to prevent calendar interference
- **Input attributes:** Added explicit `type="text"` and `inputMode="text"` for strong calendar prevention
**Lines Modified:** 187-222
#### **2. `frontend/src/components/MobileEventForm.jsx`**
**Changes Made:**
- **TimeInputMobile component:** Complete rewrite with intelligent positioning
- **Visual Viewport API:** Added keyboard detection and dynamic positioning
- **Smart dropdown logic:** Calculates available space above/below input
- **Input attributes:** Added `inputMode="text"` and `enterKeyHint="done"`
- **Z-index fix:** Increased to 9999 to prevent calendar interference
- **End date/time row structure:** Fixed to match start date/time row
**Lines Modified:** 72-181, 554-568
#### **3. `frontend/src/components/UserManagerModal.jsx`**
**Changes Made:**
- **Form wrapper:** Added `autoComplete="off"` to suppress Chrome autocomplete
- **Input consistency:** Ensured all inputs use standard autocomplete suppression
**Lines Modified:** 293-295
#### **4. `frontend/src/pages/GroupManagerPage.jsx`**
**Changes Made:**
- **Form wrapper:** Added `autoComplete="off"` for consistent behavior
- **Input attributes:** Standardized autocomplete suppression
**Lines Modified:** 744-746
#### **5. `frontend/src/pages/UserManagerPage.jsx`**
**Changes Made:**
- **Form wrapper:** Added `autoComplete="off"` for mobile compatibility
- **Input consistency:** Updated all input fields
**Lines Modified:** 624-626
#### **6. `frontend/src/index.css`**
**Changes Made:**
- **Cleaned up aggressive CSS:** Removed `-webkit-autofill` overrides that could affect iOS/desktop
- **Reverted to standard:** Cross-browser compatible approach
**Lines Modified:** 35-62 (removed aggressive autocomplete CSS)
---
## 🔧 **Technical Solutions Implemented**
### **1. Chrome Autocomplete Suppression**
```jsx
// Standard approach (safe for all browsers)
<input autoComplete="off" />
<form autoComplete="off">
```
### **2. Calendar Popup Prevention**
```jsx
// Strong signals to prevent date picker
<input
type="text"
inputMode="text"
enterKeyHint="done"
autoComplete="off"
/>
```
### **3. Smart Dropdown Positioning**
```js
// Visual Viewport API for keyboard detection
const handleViewportChange = () => {
if (window.visualViewport) {
const offset = window.innerHeight - window.visualViewport.height;
setKeyboardOffset(offset > 0 ? offset : 0);
}
};
// Intelligent positioning based on available space
const spaceAbove = rect.top;
const spaceBelow = window.innerHeight - rect.bottom - keyboardOffset;
if (spaceBelow >= dropdownHeight) {
setDropdownPosition({ top: '100%', bottom: 'auto' });
} else {
setDropdownPosition({ top: 'auto', bottom: '100%' });
}
```
### **4. Z-Index Hierarchy Fix**
```jsx
// Time dropdowns above all other UI elements
zIndex: 9999
```
### **5. Consistent Date/Time Row Structure**
```jsx
// Before (problematic)
<div onClick={()=>setShowEndDate(true)} style={{cursor:'pointer'}}>
<span>{date}</span>
<TimeInputMobile />
</div>
// After (fixed)
<div>
<span onClick={()=>setShowEndDate(true)} style={{cursor:'pointer'}}>{date}</span>
<TimeInputMobile />
</div>
```
---
## 🐛 **Issues Resolved**
### **✅ Chrome Autocomplete Bar**
- **Status:** Intended Chrome behavior (not a bug)
- **Solution:** Standard `autoComplete="off"` implementation
- **Result:** Consistent with Google Calendar's own behavior
### **✅ Calendar Popup on End Time**
- **Root Cause:** Z-index conflict and row structure issues
- **Solution:** Increased z-index + separate clickable areas
- **Result:** Calendar only triggers on date click, not time click
### **✅ Keyboard Covering Dropdowns**
- **Root Cause:** Fixed positioning without keyboard awareness
- **Solution:** Visual Viewport API + intelligent positioning
- **Result:** Dropdowns appear above keyboard or reposition automatically
### **✅ Inconsistent Date/Time Rows**
- **Root Cause:** Different HTML structure between start/end rows
- **Solution:** Made both rows structurally identical
- **Result:** Consistent behavior for both date and time interactions
---
## 📱 **Cross-Browser Compatibility**
### **✅ iOS Safari**
- Respects standard `autoComplete="off"`
- No aggressive CSS overrides affecting behavior
- Smart positioning works with Visual Viewport API
### **✅ Desktop Browsers**
- Standard autocomplete behavior
- No interference from mobile-specific fixes
- Consistent time dropdown functionality
### **✅ Android Chrome**
- Standard autocomplete suppression (as intended by Chrome)
- Proper time dropdown positioning
- No calendar interference with time selection
---
## 🎯 **Key Learnings**
1. **Chrome Android autocomplete is intended behavior**, not a bug
2. **Even Google Calendar can't suppress it** - confirmed by user testing
3. **Standard HTML attributes are the safest approach** for cross-browser compatibility
4. **Visual Viewport API provides reliable keyboard detection**
5. **Z-index hierarchy is critical** for preventing UI element conflicts
6. **Consistent HTML structure prevents interaction conflicts**
---
## 📦 **Version History**
- **0.12.38:** Initial autocomplete attempts
- **0.12.39:** Aggressive CSS and JavaScript overrides
- **0.12.40:** Cleaned up to standard approach
- **0.12.41:** Fixed calendar z-index issues
- **0.12.42:** Added smart dropdown positioning
- **0.12.43:** Fixed date/time row structure
- **0.12.44:** Refined positioning logic
- **0.12.45:** Final stable implementation
---
## 🚀 **Ready for Deployment**
All changes are cross-browser compatible and follow web standards. The implementation now:
- Works consistently across iOS Safari, Android Chrome, and desktop browsers
- Provides intelligent dropdown positioning
- Maintains clean, maintainable code
- Follows React best practices